semantic-mcp
<div align="center">
# semantic-mcp
### Uma camada semântica governada, exposta a agentes de IA via MCP
**O agente escolhe métricas de um vocabulário fechado. Nunca escreve SQL.**







[A tese](#a-tese) •
[Como rodar](#como-rodar) •
[Resultados](#resultados) •
[Arquitetura](#arquitetura) •
[SDD](#como-este-projeto-foi-especificado) •
[Limitações](#limitações)
</div>
---
## A tese
Times de dados estão conectando agentes de IA direto ao warehouse. O padrão
dominante é *text-to-SQL*: o modelo recebe o schema, escreve SQL, o banco
executa.
Isso falha de três formas, e as três são invisíveis para quem perguntou.
**Alucinação de schema.** O modelo referencia uma coluna que não existe — ou
que existe com outro significado. No melhor caso, erro. No pior, a query roda e
devolve um número plausível e errado.
**Divergência de definição.** `receita` calculada de três formas em três
conversas. O modelo não sabe que a empresa exclui frete, porque isso não está
no schema — está na cabeça de alguém.
**Superfície de risco.** SQL arbitrário, gerado por modelo, executado no
warehouse, sem revisão.
O problema não é que o modelo seja ruim. É que estamos pedindo a ele a coisa
errada.
Este projeto inverte a fronteira. O agente não recebe um schema — recebe um
**vocabulário**: métricas e dimensões declaradas por um analytics engineer, com
descrição de negócio e notas de quando usar e quando **não** usar. Ele monta uma
chamada tipada. O servidor compila o SQL.
Alucinação de coluna deixa de ser improvável e passa a ser **impossível por
construção**: nenhum nome de coluna atravessa a fronteira.
O custo é expressividade — perguntas fora do contrato são recusadas. A aposta é
que **recusar é melhor que aproximar**, e que a forma de cobrir mais perguntas é
estender o contrato, um ato deliberado de modelagem, em vez de afrouxar a
fronteira.
## Como rodar
Sem credencial, sem conta em nuvem, sem chave de API.
```bash
git clone https://github.com/stefbartieri/semantic-mcp
cd semantic-mcp
pip install -r requirements.txt
make demo
```
`make demo` gera os dados, materializa o warehouse com dbt, roda os testes e
imprime o relatório de avaliação. Leva menos de um minuto.
Para usar como servidor MCP:
```bash
make serve # stdio
```
```jsonc
// claude_desktop_config.json
{
"mcpServers": {
"semantic": {
"command": "python",
"args": ["-m", "semantic_mcp.server"],
"cwd": "/caminho/para/semantic-mcp",
"env": { "PYTHONPATH": "src" }
}
}
}
```
## Resultados
```
Avaliação — modo deterministic
----------------------------------------------------------
Resolução 30/30 100.0% CA2 >= 90% [PASS]
Rejeição 10/10 100.0% CA3 == 100% [PASS]
Correção 7/7 100.0% CA4 diff 0 [PASS]
----------------------------------------------------------
Código exato do erro: 10/10
```
Três eixos, porque acertar mais perguntas não vale nada se a fronteira vazar:
| Eixo | O que mede |
|---|---|
| **Resolução** | A pergunta de negócio vira a chamada de ferramenta correta? |
| **Rejeição** | As 10 perguntas adversariais são recusadas, com o código de erro certo? |
| **Correção** | O SQL compilado bate com uma query de referência escrita à mão? Diferença esperada: **exatamente 0**. |
Regra da constituição do projeto: *uma melhora na resolução que piore a rejeição
não é uma melhora.*
### O que os casos adversariais exercitam
| Caso | Classe de ataque |
|---|---|
| a01 | Métrica inexistente, mas plausível no domínio (`margem_bruta`) |
| a04 | Coluna **real** do banco, fora do contrato (`preco_unitario`) |
| a05 | Injeção de SQL no valor do filtro |
| a06 | Injeção de SQL no **nome do campo** |
| a08 | Operador não declarado (`like`) |
| a10 | Campo existe no contrato, mas não é permitido **nesta** métrica |
Nenhum deles chega ao warehouse. Todos voltam como erro estruturado com o
vocabulário válido — para o agente se corrigir na próxima chamada, em vez de
tentar de novo ao acaso:
```json
{
"error": "unknown_metric",
"message": "A métrica 'receta' não existe no contrato.",
"did_you_mean": ["receita_liquida"],
"available": ["clientes_ativos", "itens_vendidos", "pedidos",
"receita_liquida", "taxa_cancelamento", "ticket_medio"]
}
```
## Arquitetura
```
FRONTEIRA DE CONFIANÇA
│
Agente de IA │ Sistema governado
(não confiável) │ (determinístico)
│
pergunta em PT │
│ │
▼ │
┌───────────┐ chamada tipada │ ┌──────────────┐
│ resolver │ ────────────────────▶│ guardrails │
└───────────┘ {metric,dims, │ └──────┬───────┘
filters,range} │ │ tudo validado
│ │ contra o contrato
│ ▼
│ ┌──────────────┐
│ │ compiler │
│ └──────┬───────┘
│ │ SQL parametrizado
│ ▼
│ ┌──────────────┐
│ │ warehouse │ DuckDB (read-only)
│ └──────┬───────┘ ▲
│ │ │ dbt build
│ ▼ ┌──┴─────┐
│ rows + SQL + def │ marts │
│ └────────┘
```
**Nenhuma string escrita pelo agente chega ao warehouse.** O que atravessa a
fronteira é um conjunto de identificadores que só existem se estiverem no
contrato.
### As 4 ferramentas MCP
| Ferramenta | Para quê |
|---|---|
| `list_metrics` | O vocabulário completo. O agente começa por aqui. |
| `describe_metric` | Definição, expressão, grão, e as notas de *quando usar / quando não usar*. |
| `query_metric` | Executa. Devolve as linhas **e o SQL compilado**, para auditoria. |
| `explain_lineage` | Cadeia de modelos dbt do source ao mart, lida do `manifest.json`. |
### O contrato é o produto
```yaml
- name: ticket_medio
label: Ticket médio
expr: "SUM(f.item_total - f.item_desconto) / NULLIF(COUNT(DISTINCT f.order_id), 0)"
dimensions: [mes, canal, categoria, regiao, segmento]
when_to_use: >
Use para comparar o valor típico de compra entre canais, regiões ou períodos.
when_not_to_use: >
Não recorte por categoria. O denominador conta o pedido inteiro, mas o
numerador só os itens daquela categoria — o resultado não tem significado.
```
A descrição **é o prompt**. É literalmente o que o agente lê para decidir. Por
isso `when_not_to_use` é campo obrigatório: o contrato não carrega só o cálculo,
carrega o julgamento de quem modelou.
E quando o agente cai numa dessas armadilhas, o servidor não recusa — ele
entrega o número **com o aviso**, porque o número existe, só engana:
```json
"warnings": ["Ticket médio recortado por categoria mistura grãos: o denominador
conta o pedido inteiro e o numerador só os itens da categoria."]
```
## Como este projeto foi especificado
Construído com **Spec-Driven Development**. A ordem foi constituição → spec →
plano → tarefas → código, e os artefatos estão versionados:
| Documento | O que fixa |
|---|---|
| [`.specify/memory/constitution.md`](.specify/memory/constitution.md) | 7 princípios inegociáveis. Requisito que conflita com princípio perde. |
| [`specs/001-semantic-layer/spec.md`](specs/001-semantic-layer/spec.md) | Problema, hipótese, escopo, RF/RNF, critérios de aceitação, riscos |
| [`specs/001-semantic-layer/plan.md`](specs/001-semantic-layer/plan.md) | Arquitetura, decisões técnicas com alternativa descartada, rastreabilidade |
| [`specs/001-semantic-layer/tasks.md`](specs/001-semantic-layer/tasks.md) | 24 tarefas, cada uma com critério de pronto verificável |
O plano inclui uma seção **"Como sei que falhei"** — sinais que invalidariam a
hipótese. Um deles se confirmou; está em Limitações, logo abaixo.
## Limitações
Declaradas, não escondidas.
**O resolver determinístico acerta 100%, e isso é um resultado ruim.** O plano
previa que um baseline de regras acertando quase tudo significaria uma suíte
fácil demais para discriminar. Foi o que aconteceu: as perguntas de avaliação
usam o vocabulário do próprio contrato, então casamento por sinônimo resolve. Os
números de **resolução** medem a cobertura do contrato, não inteligência. Os de
**rejeição** e **correção** continuam valendo — esses testam a fronteira.
**40 casos são poucos** para conclusão estatística. A suíte é extensível por
YAML; o número honesto aqui é "nenhum vazamento em 10 classes de ataque", não
"seguro".
**Sem cobertura temporal relativa.** "Últimos 3 meses" não resolve — só recorte
por mês. É limitação do contrato, não da arquitetura.
**DuckDB local não é um warehouse de produção.** O compilador gera SQL ANSI e o
dialeto está isolado em `warehouse.py`, mas concorrência, custo de query e
permissão por linha não foram exercitados.
**O modo LLM existe mas não foi avaliado em escala** — precisa de
`ANTHROPIC_API_KEY` e ficou fora do caminho padrão por decisão de
reprodutibilidade (Princípio V).
## Estrutura
```
.specify/memory/constitution.md princípios do projeto
specs/001-semantic-layer/ spec, plano, tarefas
semantic/contract.yml o contrato — fonte única de verdade
dbt/ staging + marts, 30 testes dbt
src/semantic_mcp/
contract.py carga e validação do contrato
guardrails.py validação da requisição (a fronteira)
compiler.py requisição tipada -> SQL parametrizado
warehouse.py DuckDB, read-only, binding
service.py lógica das 4 ferramentas
server.py servidor MCP (stdio)
resolver.py baseline determinístico PT -> chamada
evals/
cases.yaml 30 válidos + 10 adversariais
runner.py resolução / rejeição / correção
reference.sql queries de conferência escritas à mão
tests/ 89 testes
```
## Licença
MIT — veja [LICENSE](LICENSE).
TDQS
Scored across 4 tools
Each tool has a clearly distinct role: list_metrics enumerates the vocabulary, describe_metric gives the full contract for one metric, query_metric executes it, and explain_lineage traces its dbt provenance. The list-vs-describe overlap is mitigated by the descriptions specifying different granularity and by explicit guidance to start with list_metrics.
All four tools follow a uniform verb_noun snake_case pattern (list_metrics, describe_metric, query_metric, explain_lineage). The convention is predictable and readable throughout, with no mixing of styles.
Four tools is well-scoped for a semantic-layer contract server: discovery, definition, execution, and lineage each earn their place. There is no redundant or filler tool.
The surface covers the full read lifecycle of a metric (discover, inspect, query, trace lineage), which is appropriate for a read-only semantic contract. A minor gap is the lack of a way to enumerate valid dimension/filter values (e.g. distinct dimension members) to help build recortes.