Skip to main content
Glama
README.md
<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.**

![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)
![dbt](https://img.shields.io/badge/dbt-1.8-FF694B?logo=dbt&logoColor=white)
![DuckDB](https://img.shields.io/badge/DuckDB-1.5-FFF000?logo=duckdb&logoColor=black)
![MCP](https://img.shields.io/badge/MCP-stdio-6E56CF)
![Testes](https://img.shields.io/badge/testes-89-success)
![Cobertura](https://img.shields.io/badge/cobertura-86%25-success)
![Rejeição](https://img.shields.io/badge/rejei%C3%A7%C3%A3o%20advers%C3%A1ria-100%25-success)

[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

A4/5.0

Scored across 4 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues