mcp-toolkit
by romeuow
README.md
# mcp-toolkit
Servidor MCP (Model Context Protocol) em Python que expõe, para agentes de IA, um conjunto de
ferramentas tipadas sobre sistemas de negócio — CRM, base analítica de clientes e publicação de
páginas HTML — com autenticação na borda, contratos testados e modo demo 100% offline.
[](https://github.com/romeuow/mcp-toolkit/actions/workflows/ci.yml)




## Caso de uso real
Em uma empresa do setor de energia, diversos agentes de IA — um atendimento por voz para clientes
e assistentes internos usados pelos times de operação — precisavam das mesmas capacidades:
consultar e atualizar contatos no CRM, localizar clientes por telefone, CPF ou código de
instalação numa base analítica, e publicar relatórios e apresentações em HTML sob um link estável
que pudesse ser enviado a um cliente ou colado numa conversa.
Cada agente tinha nascido com integrações próprias: três formas diferentes de chamar o CRM, três
formas de tratar erro, três lugares para corrigir quando um campo mudava. Regras importantes
(quais campos um agente pode editar, como mascarar PII nos logs, como validar um slug) estavam
duplicadas ou simplesmente ausentes em alguma das cópias.
O padrão aplicado foi centralizar essas capacidades num único servidor MCP, com contratos tipados
e prefixos de nome por sistema (`crm_*`, `db_*`, `pages_*`), autenticação por token na borda e uma
suíte de testes que trava o contrato exposto aos agentes. Qualquer cliente MCP — Claude, IDEs,
orquestradores — passou a usar as mesmas ferramentas, e a equipe passou a evoluir uma única
integração por sistema, com revisão de código e testes, em vez de três.
Este repositório é uma **reimplementação genérica** desse padrão, com dados sintéticos e
integrações fake em memória. Nenhum nome, endpoint, schema ou dado real de empresa ou cliente está
presente; as implementações "reais" (`HttpCRMClient`, `PostgresCustomerRepository`) apontam para
APIs e schemas genéricos e servem como ponto de adaptação.
## O que este projeto demonstra
- **Servidor MCP com o SDK oficial (`mcp` 2.x)** — tools, resource template e prompt registrados
via `MCPServer`, transporte `streamable-http`, saída estruturada (`structuredContent`) derivada
de modelos Pydantic.
- **Composição ASGI** — o app MCP é montado dentro de um Starlette próprio que também serve as
páginas publicadas (`GET /p/<slug>/`) e um health check, com o lifespan do session manager do
SDK integrado ao lifespan da aplicação.
- **Autenticação na borda** em middleware ASGI puro: `Authorization: Bearer` validado com
`hmac.compare_digest`, suporte a múltiplos tokens (rotação) sem early-exit.
- **Rate limiting** por token (token bucket em memória) com `Retry-After`.
- **Integrações plugáveis** por `Protocol` (`CRMClient`, `CustomerRepository`): fakes em memória
para demo/testes, cliente HTTP com retry e backoff exponencial, repositório Postgres com queries
parametrizadas (psycopg 3). `APP_MODE=demo|prod` escolhe a implementação.
- **Validação de domínio** — allowlist de campos editáveis no CRM, detecção de tipo de
identificador (telefone, CPF com dígito verificador, código de instalação), regex de slug,
sanitização de HTML (scripts, iframes, handlers `on*`, URLs `javascript:`), limites de tamanho.
- **Observabilidade básica** — logging JSON estruturado com `request_id` propagado por
`contextvars`, uma linha de acesso por requisição e mascaramento automático de CPF/telefone.
- **Testes em três camadas** (unit, integration, contract) 100% offline, incluindo o cliente MCP
oficial falando com o app através do transporte streamable-http real em processo, e snapshot JSON
do contrato (nomes, schemas de entrada/saída, anotações).
- **Empacotamento de produção** — `uv`, Dockerfile multi-stage sem `uv` na imagem final e usuário
não-root, `docker-compose` com Postgres opcional por profile, CI com ruff + pytest + gate de
cobertura.
## Arquitetura
```mermaid
flowchart LR
subgraph Clients["Clientes MCP"]
A[Agente de voz]
B[Claude / IDE]
C[scripts/demo_client.py]
end
subgraph App["Starlette (ASGI)"]
direction TB
M1[RequestIdMiddleware] --> M2[BearerAuthMiddleware] --> M3[RateLimitMiddleware]
M3 --> R1["/mcp (streamable-http)"]
M3 --> R2["/p/{slug}/ (páginas públicas)"]
M3 --> R3["/healthz"]
R1 --> S[MCPServer]
end
subgraph Tools["tools/"]
T1["crm_* "] & T2["db_*"] & T3["pages_*"]
T4["resource://customers/{id}"]
T5["prompt summarize_call"]
end
subgraph Services["services/"]
SV1[CRMService<br/>allowlist, normalização]
SV2[CustomerService<br/>detecção de identificador]
SV3[PagesService<br/>slug, sanitize, FS atômico]
end
subgraph Clients2["clients/ (Protocols)"]
C1[FakeCRMClient] -.-> P1((CRMClient))
C2[HttpCRMClient] -.-> P1
C3[FakeCustomerRepository] -.-> P2((CustomerRepository))
C4[PostgresCustomerRepository] -.-> P2
end
A & B & C -->|Bearer token| M1
S --> T1 & T2 & T3 & T4 & T5
T1 --> SV1 --> P1
T2 & T4 --> SV2 --> P2
T3 --> SV3 --> FS[(published/<slug>/index.html)]
R2 --> FS
```
### Componentes
| Camada | Arquivos | Responsabilidade |
| --- | --- | --- |
| Borda HTTP | `server.py`, `middleware.py` | Compõe o Starlette: request id, auth Bearer, rate limit, rotas públicas, mount do app MCP, lifespan. |
| MCP | `tools/*.py` | Registra tools/resource/prompt no `MCPServer`. Só traduz: valida tipos (Pydantic) e mapeia exceções de domínio para `ToolError` com prefixos estáveis (`validation_error`, `not_found`, `upstream_error`). |
| Domínio | `services/*.py` | Regras de negócio independentes de transporte e fornecedor: allowlist, normalização, detecção de identificador, sanitização, publicação atômica. |
| Integrações | `clients/*.py` | `Protocol`s + implementações fake e reais. É a única camada que conhece HTTP de terceiros ou SQL. |
| Config/Infra | `settings.py`, `container.py`, `logging.py` | `pydantic-settings`, wiring por modo (`demo`/`prod`), JSON logging com mascaramento. |
### Fluxo de uma requisição (`tools/call db_lookup_customer`)
1. O cliente MCP faz `POST /mcp` com `Authorization: Bearer <token>` e um JSON-RPC
`tools/call`.
2. `RequestIdMiddleware` lê ou gera `X-Request-ID`, coloca-o num `contextvar` (todos os logs da
requisição o carregam) e devolve-o no header de resposta.
3. `BearerAuthMiddleware` compara o token com todos os configurados via `hmac.compare_digest`;
sem match, responde `401` e a requisição nunca chega ao SDK.
4. `RateLimitMiddleware` consome um token do bucket daquele credential; vazio, responde `429`
com `Retry-After`.
5. O app streamable-http do SDK valida o envelope JSON-RPC, resolve a tool e valida os argumentos
contra o schema Pydantic (`query: str`).
6. `db_lookup_customer` chama `CustomerService.lookup`, que detecta o tipo do identificador
(`"+55 11 90000-0003"` → telefone, normalizado para `5511900000003`) e consulta
`CustomerRepository.find_by_phone` (fake em demo, Postgres em prod).
7. O `CustomerLookupResult` (Pydantic) é serializado pelo SDK como `structuredContent` e também
como texto; erros de domínio viram `isError: true` com mensagem acionável em vez de exceção.
8. A linha de acesso é logada em JSON com `request_id`, método, caminho, status e duração.
### Decisões técnicas
| Decisão | Alternativa considerada | Por quê |
| --- | --- | --- |
| `mcp` 2.x (`MCPServer`) em vez de fixar `mcp<2` (`FastMCP`) | Pinar a 1.x, cuja API aparece na maioria dos tutoriais | A versão instalada pelo `uv add mcp` é a 2.x; a API foi inspecionada no pacote real (`MCPServer`, `Client(server)` in-memory, `streamable_http_client(http_client=...)`). Pinar numa major antiga só adia a migração. |
| Starlette externo com `Mount("/", mcp_app)` | `custom_route` do SDK para tudo | O SDK só expõe rotas sem auth via `custom_route`; um Starlette próprio dá controle total da ordem dos middlewares e das rotas públicas (`/p/`, `/healthz`). O lifespan chama `session_manager.run()` explicitamente. |
| Auth em middleware ASGI próprio com Bearer estático | OAuth 2.1 / `TokenVerifier` do SDK | Clientes são serviços internos (agente de voz, orquestradores): tokens de serviço rotacionáveis resolvem o caso com uma fração da complexidade. O SDK permite plugar OAuth depois sem mudar as tools. |
| `Protocol` + fakes em memória | Mocks com `unittest.mock` nos testes | Fakes com comportamento real (busca, mutação visível) tornam o modo demo utilizável por humanos e deixam os testes legíveis. `runtime_checkable` garante que fakes e reais têm a mesma superfície. |
| Sanitizador em `html.parser` da stdlib | Regex; `bleach`/`nh3` | Regex quebra em casos simples (`<scr<script>ipt>`); dependências externas trazem supply-chain e versão. O parser da stdlib basta para remover elementos e atributos perigosos preservando CSS, que as páginas de apresentação precisam. Limitações estão em Segurança. |
| Rate limit em memória (token bucket) | Redis / limiter distribuído | O servidor roda como uma instância por ambiente; um bucket por token em processo cobre o objetivo (proteger integrações a jusante de um agente em loop). Documentado como single-process. |
| Saída estruturada via modelos Pydantic no retorno | `dict` solto / texto | O SDK gera `outputSchema` e `structuredContent` automaticamente; o snapshot de contrato captura mudanças de schema em code review. |
| Erros de domínio → `ToolError` com prefixo | Exceções livres | `isError: true` com `validation_error: ...` permite ao agente corrigir a chamada em vez de abortar; o prefixo é estável para prompts e métricas. |
| Testes com `anyio` pytest plugin | `pytest-asyncio` | O SDK usa task groups do anyio; o plugin do anyio executa setup/teardown de fixtures assíncronas na mesma task, evitando `cancel scope` em task diferente. |
| `psycopg` 3 com connection factory injetável | `asyncpg`, SQLAlchemy | Placeholders `%s` e `dict_row` simples; a factory injetável permite testar a camada SQL sem banco. Pool fica como roadmap. |
## Como executar
### Pré-requisitos
- Python 3.12+ e [`uv`](https://docs.astral.sh/uv/) (o projeto fixa `.python-version` em 3.12;
`uv` baixa o interpretador se necessário).
- Docker e Docker Compose (opcional).
### Instalação e modo demo (sem credenciais)
```bash
git clone https://github.com/romeuow/mcp-toolkit.git
cd mcp-toolkit
make install # uv sync
cp .env.example .env # opcional; os defaults já funcionam em demo
make dev # uvicorn em http://127.0.0.1:8000 com reload
```
O modo demo (`APP_MODE=demo`, padrão) usa `FakeCRMClient` e `FakeCustomerRepository` com dez
clientes sintéticos (`Ana Exemplo` … `Joao Exemplo`, CPFs `000.000.000-NN`, telefones
`+55 11 90000-00NN`) e aceita o token `demo-token`. Nenhuma rede externa é usada.
Variáveis relevantes (ver `.env.example` para todas):
| Variável | Default | Descrição |
| --- | --- | --- |
| `APP_MODE` | `demo` | `demo` usa fakes; `prod` exige `MCP_AUTH_TOKENS`, `CRM_API_KEY` e `DATABASE_URL`. |
| `MCP_AUTH_TOKENS` | `demo-token` (só em demo) | Tokens aceitos em `POST /mcp`, separados por vírgula. |
| `PUBLIC_BASE_URL` | `http://localhost:8000` | Base das URLs devolvidas por `pages_publish`. |
| `PUBLISHED_DIR` | `./published` | Onde as páginas são gravadas. |
| `RATE_LIMIT_RATE` / `RATE_LIMIT_BURST` | `10` / `20` | Reposição por segundo e capacidade do bucket por token. |
| `MCP_STATELESS` / `MCP_JSON_RESPONSE` | `true` / `true` | Modo sem sessão e respostas JSON (facilita `curl`). |
### Rodar com Docker
```bash
make up # build + sobe o serviço mcp em modo demo
make up PROFILE=prod # também sobe o Postgres (scripts/init_db.sql é aplicado no primeiro boot)
make down
```
Em `prod`, defina `APP_MODE=prod`, `MCP_AUTH_TOKENS`, `CRM_BASE_URL`, `CRM_API_KEY` e
`DATABASE_URL` no `.env` (o compose já aponta `DATABASE_URL` para o serviço `postgres`).
### Exemplos com `curl`
Health check (público):
```bash
curl -s http://localhost:8000/healthz
# {"status":"ok","mode":"demo","version":"0.1.0"}
```
Sem token, o endpoint MCP recusa:
```bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -d '{}'
# 401
```
Listar tools:
```bash
curl -s -X POST http://localhost:8000/mcp \
-H 'Authorization: Bearer demo-token' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq '.result.tools[].name'
# "crm_get_contact"
# "crm_update_contact"
# "crm_register_call"
# "db_lookup_customer"
# "db_open_tickets"
# "pages_publish"
# "pages_list"
```
Buscar cliente por telefone (detecção automática do tipo):
```bash
curl -s -X POST http://localhost:8000/mcp \
-H 'Authorization: Bearer demo-token' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"db_lookup_customer","arguments":{"query":"+55 11 90000-0003"}}}' \
| jq '.result.structuredContent'
# {
# "query": "+55 11 90000-0003",
# "detected_kind": "phone",
# "normalized_query": "5511900000003",
# "customer": { "id": "C003", "name": "Carla Exemplo", "installation_code": "INST-0000003", ... },
# "found": true
# }
```
Publicar uma página (o `<script>` é removido e reportado) e abri-la:
```bash
curl -s -X POST http://localhost:8000/mcp \
-H 'Authorization: Bearer demo-token' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"pages_publish","arguments":{"slug":"relatorio-q3","html":"<h1>Relatorio Q3</h1><script>alert(1)</script>"}}}' \
| jq '.result.structuredContent'
# {
# "slug": "relatorio-q3",
# "url": "http://localhost:8000/p/relatorio-q3/",
# "bytes_written": 21,
# "sanitized": true,
# "removed": ["element <script>"]
# }
curl -s http://localhost:8000/p/relatorio-q3/
# <h1>Relatorio Q3</h1>
```
Tentar editar um campo fora da allowlist retorna erro de tool (não exceção):
```bash
# ... "name":"crm_update_contact","arguments":{"contact_id":"crm-1001","fields":{"lifecycle_stage":"x"}}
# .result.isError == true
# "Error executing tool crm_update_contact: validation_error: fields not editable: lifecycle_stage.
# Allowed: city, consent_marketing, email, first_name, last_name, notes, phone"
```
### Cliente MCP de exemplo
`scripts/demo_client.py` usa o cliente do SDK sobre streamable-http, lista as tools e chama
`db_lookup_customer`:
```bash
make demo-client
# ou
MCP_URL=http://localhost:8000/mcp MCP_TOKEN=demo-token \
uv run python scripts/demo_client.py "000.000.000-07"
```
Saída (resumida):
```
Connected to http://localhost:8000/mcp (server: mcp-toolkit)
Tools:
- crm_get_contact: Find a CRM contact by phone, e-mail or document (CPF).
- ...
Calling db_lookup_customer(query='000.000.000-07') ...
{ "detected_kind": "document", "customer": { "id": "C007", "name": "Gabriela Exemplo", ... }, "found": true }
```
Para usar com um cliente MCP genérico, configure a URL `http://localhost:8000/mcp` com o header
`Authorization: Bearer demo-token`.
## Testes
```bash
make test
# ou
uv run pytest -q --cov=src --cov-report=term-missing
```
Todos os testes rodam offline, sem chaves nem rede. Organização:
| Camada | Diretório | O que cobre |
| --- | --- | --- |
| Unit | `tests/unit/` | Detecção/normalização de identificadores (incl. dígito verificador de CPF), sanitização HTML (elementos, `on*`, schemes ofuscados, comentários, entidades), slug e publicação atômica, allowlist e coerção de campos do CRM, mascaramento de PII e formatter JSON, token bucket e middlewares isolados, `HttpCRMClient` com `httpx.MockTransport` (retry, backoff, 404, 4xx sem retry), `PostgresCustomerRepository` com conexão fake (queries parametrizadas, fechamento), settings por modo, container, entrypoint. |
| Integration | `tests/integration/` | App ASGI completo em processo via `httpx.ASGITransport`: `401` sem token, `200` com token, `tools/list` e `tools/call` por JSON-RPC cru, `429` com `Retry-After`, página publicada servida em `/p/<slug>/`, redirect sem barra, 404 para slugs inválidos, lifespan fechando o container. E o **cliente MCP oficial** conectado ao app pelo transporte streamable-http real (`streamable_http_client` + `httpx2.ASGITransport`): round-trip de todas as tools, erros reportados como `isError`, resource e prompt. |
| Contract | `tests/contract/` | Lista tools/resource templates/prompts com o cliente in-memory do SDK e compara nomes, descrições, `inputSchema`, `outputSchema` e anotações com `tests/snapshots/tools.json`. Mudanças de contrato falham o teste até o snapshot ser regenerado conscientemente (`UPDATE_SNAPSHOTS=1`). Invariantes adicionais: prefixo por sistema, descrição presente, saída estruturada em toda tool. |
Estratégia de fakes: as integrações externas são substituídas por implementações completas em
memória (`FakeCRMClient`, `FakeCustomerRepository`) que respeitam os mesmos `Protocol`s das reais;
as reais são testadas com transporte HTTP mockado e conexão SQL fake, sem subir serviços.
Cobertura atual medida (`pytest-cov`, branch coverage ligado):
```
146 passed in 1.9s
TOTAL 1086 stmts 8 miss 248 branches 12 partial 99%
```
## Segurança
**Autenticação na borda.** `POST /mcp` exige `Authorization: Bearer <token>`. A comparação usa
`hmac.compare_digest` e percorre todos os tokens configurados sem retorno antecipado, para não vazar
por tempo qual token bateu. Múltiplos tokens permitem rotação sem janela de indisponibilidade. Em
`prod`, a ausência de `MCP_AUTH_TOKENS` impede o boot (validação em `Settings`).
**Validação de entrada.** Argumentos de tool são validados pelo SDK contra schemas Pydantic
(`Literal` para enumerações, tipos estritos). Acima disso, a camada de serviços aplica regras de
domínio: allowlist de campos editáveis no CRM (qualquer chave fora dela rejeita a chamada inteira),
limites de tamanho (`summary` ≤ 4000 chars, HTML ≤ 2 MiB), normalização de telefone/e-mail/CPF.
**Páginas publicadas.** O slug é validado por regex estrita (minúsculas, dígitos, hífens, 3–64) e
o caminho final é resolvido e confinado ao diretório `published/` (bloqueia path traversal mesmo que
a regex falhasse). O HTML passa por um sanitizador baseado em `html.parser` que remove `script`,
`iframe`, `object`, `embed`, `applet`, `noscript`, `base`, atributos `on*`, URLs com
`javascript:`/`vbscript:`/`data:text/html` (inclusive com espaços e quebras de linha intercalados),
`<meta http-equiv="refresh">`, comentários e instruções de processamento. A escrita é atômica
(`tmp` + `replace`). Slugs reservados (`mcp`, `healthz`, `p`, ...) são recusados.
**Segredos.** Lidos apenas de variáveis de ambiente via `pydantic-settings`; `.env` está no
`.gitignore` e `.env.example` só tem placeholders. Tokens nunca são logados: o rate limiter e os logs
usam um hash SHA-256 truncado do token.
**PII em logs.** O formatter JSON mascara CPF (`***.***.***-07`) e telefones (`***-0003`) em
mensagens, campos extras (recursivamente) e tracebacks. Os serviços logam apenas metadados
(`kind`, `found`, `by`), nunca o identificador consultado.
**Superfícies consideradas.** Agente em loop esgotando integrações a jusante (rate limit por
token); XSS via páginas publicadas (sanitização + `Content-Type: text/html` só para arquivos dentro
do diretório confinado); SQL injection (queries parametrizadas, nunca interpolação); escrita
indevida no CRM (allowlist); DNS rebinding do transporte (delegado ao reverse proxy — ver abaixo);
imagem Docker rodando como usuário não-root e sem ferramentas de build.
**O que NÃO está coberto (honestamente).**
- Não há autorização por escopo: qualquer token válido acessa todas as tools. Perfis por token
(ex.: agente de voz só lê) são roadmap.
- Tokens são estáticos; não há OAuth 2.1, expiração nem revogação individual além de remover o
token da configuração e reiniciar.
- O sanitizador é uma lista de bloqueio, não uma lista de permissão: CSS inline e `<style>` são
mantidos integralmente (incluindo `url()` em CSS), e `<link rel=stylesheet>`/`<form>` passam. É
adequado para HTML gerado por agentes sob controle da própria empresa, não para conteúdo de
terceiros hostis. Páginas em `/p/` são públicas por design (link estável), sem CSP própria.
- A proteção contra DNS rebinding do SDK está desligada no app (`TransportSecuritySettings`), pois
a validação de `Host`/`Origin` é esperada no reverse proxy ou ingress em produção.
- Rate limiting e cache de buckets são por processo; múltiplas réplicas multiplicam o limite.
- Não há auditoria persistente das mutações (`crm_update_contact`, `crm_register_call`,
`pages_publish`) além dos logs estruturados.
- `HttpCRMClient` e `PostgresCustomerRepository` são testados com fakes/mocks; não há teste de
integração contra um Postgres ou CRM reais neste repositório.
## Estrutura do projeto
```
mcp-toolkit/
├── src/mcp_toolkit/
│ ├── __main__.py # python -m mcp_toolkit -> uvicorn
│ ├── server.py # build_mcp_server() + create_app(): Starlette, rotas, lifespan
│ ├── middleware.py # RequestId, BearerAuth (compare_digest), RateLimit (token bucket)
│ ├── settings.py # pydantic-settings; APP_MODE demo|prod; validação de prod
│ ├── container.py # wiring: escolhe fakes ou reais e monta os serviços
│ ├── logging.py # JSON formatter, request_id contextvar, mask_pii()
│ ├── schemas/ # Pydantic: Contact, Customer, Ticket, PublishResult, ...
│ ├── clients/
│ │ ├── protocols.py # CRMClient, CustomerRepository (+ NotFoundError, UpstreamError)
│ │ ├── fake_crm.py # FakeCRMClient (10 contatos sintéticos, mutações em memória)
│ │ ├── fake_customers.py # FakeCustomerRepository (10 clientes, 5 tickets)
│ │ ├── http_crm.py # HttpCRMClient: httpx, retry com backoff, 404 -> None/NotFound
│ │ └── postgres_customers.py # psycopg 3, queries parametrizadas, factory injetável
│ ├── services/
│ │ ├── identifiers.py # detect_identifier(), is_valid_cpf(), normalizações
│ │ ├── sanitize.py # sanitize_html() baseado em html.parser
│ │ ├── crm_service.py # allowlist EDITABLE_CONTACT_FIELDS, coerção, limites
│ │ ├── customer_service.py
│ │ ├── pages_service.py # validate_slug(), publicação atômica, listagem, leitura segura
│ │ └── errors.py # DomainError, InvalidInputError
│ └── tools/
│ ├── _base.py # register_tool(): cleandoc + mapeamento de erros -> ToolError
│ ├── crm.py db.py pages.py # crm_*, db_*, pages_*
│ ├── resources.py # resource://customers/{id}
│ └── prompts.py # summarize_call
├── scripts/
│ ├── demo_client.py # cliente MCP do SDK: lista tools e chama db_lookup_customer
│ └── init_db.sql # schema + seed sintético para o Postgres (modo prod)
├── tests/
│ ├── conftest.py # settings temporárias, container, servidor, app, cliente httpx
│ ├── unit/ integration/ contract/
│ └── snapshots/tools.json # contrato MCP versionado
├── .github/workflows/ci.yml # ruff + pytest (3.12 e 3.13) com --cov-fail-under=80
├── Dockerfile # multi-stage: builder com uv, runtime slim non-root sem uv
├── docker-compose.yaml # serviço mcp + postgres (profile prod)
├── Makefile # install, dev, test, lint, format, up, down, demo-client
├── pyproject.toml # deps, grupo dev, ruff, pytest (anyio auto), coverage
└── .env.example
```
## Roadmap / limitações conhecidas
- **Escopos por token** (leitura vs. escrita) e, em seguida, OAuth 2.1 via `TokenVerifier` do SDK
para clientes interativos.
- **Allowlist de HTML** (tags/atributos permitidos) e CSP nas páginas servidas, substituindo a
lista de bloqueio atual.
- **Pool de conexões** (`psycopg_pool`) e timeouts explícitos no repositório Postgres.
- **Rate limiting distribuído** (Redis) quando houver mais de uma réplica.
- **Auditoria** das mutações numa tabela própria, com `request_id` e identificador do token.
- **Métricas** (Prometheus/OpenTelemetry) além dos logs; o SDK 2.x já expõe hooks OTel.
- **Paginação** em `pages_list` e `db_open_tickets` para volumes maiores.
- **Testes de integração opcionais** contra Postgres real via `testcontainers`, fora do caminho
padrão do CI.
- A detecção de identificador para 11 dígitos sem formatação usa o dígito verificador do CPF como
desempate; um telefone cujos dígitos formem um CPF válido seria classificado como documento.
Clientes que conhecem o tipo devem usar `crm_get_contact(by=...)`.
## Licença
MIT — veja [LICENSE](LICENSE). Copyright (c) 2026 Romeu Oliveira.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues