Skip to main content
Glama
GoncaloSDev

agentsystem-mcp-server

by GoncaloSDev
README.md
# testeai-mcp-server

Servidor MCP só-de-leitura sobre a base de dados de referência do distribuidor de
materiais de construção ([testeai-db](../testeai-db)).

Expõe quatro tools a um cliente MCP (Claude Desktop, Claude Code, MCP Inspector)
e garante, por três camadas independentes, que nenhuma delas consegue escrever.

Opcionalmente — e desligado por omissão — expõe mais cinco tools que criam,
editam, arquivam, repõem e apagam **uma linha de cada vez** nas tabelas de dados
mestre. Ver [Escrita](#escrita-opcional-desligada-por-omissão) e
[Arquivo](#arquivo-soft-delete).

**Quem vê que tools é decidido por permissions**, não por uma application role
ou por uma condição espalhada no código. Há perfis técnicos de infraestrutura,
declarados em `src/acesso/perfis.ts`, que apenas escolhem a capacidade de
PostgreSQL disponível:

| Perfil | Tools | Precisa de |
|---|---|---|
| `read` (omissão) | as 4 de leitura | `DATABASE_URL_READONLY` |
| `write` | as 9 | também `DATABASE_URL_WRITE` |

No **stdio** o perfil técnico é do processo e escolhe-se com `MCP_PERFIL`; no
**HTTP** as permissions vêm da identidade de quem chama, num token Bearer
assinado, uma por sessão — e o mesmo processo serve os dois. Ver
[Autorização RBAC](docs/AUTORIZACAO-RBAC.md), [Perfis de acesso](#perfis-de-acesso) e
[Transporte HTTP](#transporte-http-streamable-http).

## Tools

| Tool | O que faz |
|---|---|
| `list_tables` | Tabelas do schema public, com contagem aproximada de linhas, tamanho em disco e se aceitam escrita |
| `describe_table` | Colunas, tipos, nullable, valores por omissão, chave primária e chaves estrangeiras |
| `sample_rows` | Amostra de linhas de uma tabela (1 a 50, por omissão 10) |
| `run_query` | Corre uma query SELECT, com as três camadas de validação |

O `run_query` e o `sample_rows` aceitam `incluir_arquivados` (por omissão `false`)
— ver [Arquivo](#arquivo-soft-delete).

Só quando a permission de escrita está presente e o perfil técnico `write` está
configurado (o que exige `DATABASE_URL_WRITE`):

| Tool | O que faz |
|---|---|
| `insert_row` | Cria uma linha numa tabela de dados mestre |
| `update_row` | Altera uma linha, identificada pela chave primária completa |
| `archive_row` | **Arquiva** uma linha: deixa de ser vista, mas não é apagada. Reversível |
| `restore_row` | Devolve ao ativo uma linha arquivada |
| `delete_row` | **Destrói** uma linha já arquivada, e tudo o que lhe aponta, em cascata (`confirmar=true`) |

## Arquivo (soft delete)

Todas as 19 tabelas têm uma coluna `arquivado_em` (`timestamptz`, `NULL` = ativo).
Uma linha arquivada **deixa de existir para este servidor**: não aparece no
`run_query`, no `sample_rows`, nas contagens do `list_tables`, nem dentro de
JOINs, subqueries ou CTEs.

O que interessa perceber é **onde é que esse filtro vive**: não vive aqui. Nenhuma
linha deste repositório acrescenta `WHERE arquivado_em IS NULL` a query nenhuma.
Quem esconde as linhas é uma política de **Row-Level Security** do PostgreSQL,
aplicada pelo seed do `testeai-db`.

A razão é o `run_query`, que aceita SQL escrito de fora. Filtrar em TypeScript
obrigaria a reescrever a árvore de cada `SELECT` para lhe acrescentar a condição
em todas as tabelas mencionadas, incluindo as de dentro de subqueries e CTEs. Um
único caso esquecido não dá erro nenhum — devolve linhas arquivadas em silêncio,
que é exatamente o que a funcionalidade existe para impedir. Com o filtro no
planeador do Postgres não há nada a reescrever e não há casos a esquecer.

```
run_query: SELECT count(*) FROM clientes WHERE no_cli = 1001   ->  0 linhas
run_query: ... com incluir_arquivados=true                     ->  1 linha
```

### Arquivar, repor, destruir

```
archive_row  (clientes, {no_cli: 1001})                  reversível, é o caminho normal
restore_row  (clientes, {no_cli: 1001})                  desfaz o anterior, sem perdas
delete_row   (clientes, {no_cli: 1001})                  pré-visualiza; NÃO apaga
delete_row   (clientes, {no_cli: 1001}, confirmar: true) destrói, e leva o resto atrás
```

**Uma linha só pode ser apagada depois de arquivada.** Não há atalho: são duas
chamadas separadas, com um estado reversível pelo meio. É nesse intervalo que um
engano se vê e se desfaz — um único pedido mal formado nunca destrói nada.

`arquivado_em` não se altera por `insert_row` nem por `update_row`. Se se
alterasse, o pré-requisito acima deixava de significar seja o que for, porque
qualquer update genérico o satisfazia.

### O que o `delete_row` faz agora, e porque é que é diferente

Todas as chaves estrangeiras desta base têm `ON DELETE CASCADE`. Apagar um cliente
já **não** é recusado por ter documentos: apaga o cliente, os documentos de venda,
as linhas desses documentos, os movimentos de conta corrente e as comissões. Um
`taxas_iva` arrasta famílias, artigos, preços, stocks e linhas de documento.

Essa cascata é executada pelo Postgres, por gatilhos de integridade referencial
que correm com os privilégios do dono das tabelas — **não passam pela whitelist
deste servidor, nem pelas políticas de RLS, nem pela guarda de linhas da Camada
3**. Nenhuma camada deste projeto vê essas linhas a desaparecer.

Por isso o `delete_row` percorre o grafo de chaves estrangeiras antes de apagar e
conta, tabela a tabela, o que vai levar consigo. Chamado **sem** `confirmar`, não
apaga nada e devolve só essa contagem:

```json
{
  "operacao": "pre_visualizacao",
  "apagado": false,
  "linhas_que_seriam_arrastadas": 654,
  "cascata": [
    { "tabela": "linhas_doc",     "linhas": 512, "via": "linhas_doc.id_doc -> docs_venda.id" },
    { "tabela": "cc_clientes",    "linhas":  74, "via": "cc_clientes.cod_cli -> clientes.no_cli" },
    { "tabela": "docs_venda",     "linhas":  40, "via": "docs_venda.no_cli -> clientes.no_cli" },
    { "tabela": "comissoes_vend", "linhas":  28, "via": "comissoes_vend.id_doc -> docs_venda.id" }
  ]
}
```

### Encontrar o que está arquivado

O `list_tables` traz uma coluna `arquivadas` por tabela, e o `describe_table` traz
`linhas_arquivadas`. Para ver as linhas propriamente ditas:

```
run_query com incluir_arquivados=true:
  SELECT no_cli, nome, arquivado_em FROM clientes WHERE arquivado_em IS NOT NULL
```

## As três camadas

A ideia não é ter *mais* verificações — é ter verificações que **não partilham o
mesmo ponto de falha**. Para uma escrita passar, teriam de falhar ao mesmo tempo
coisas que não têm nada a ver umas com as outras.

**Camada 0 — o utilizador da base de dados.** A ligação usa o `mcp_readonly`,
que tem `SELECT` e mais nada. Vive na `DATABASE_URL_READONLY`.

**Camada 1 — o parser** ([src/seguranca/camada1-parser.ts](src/seguranca/camada1-parser.ts)).
Confirma que a query é exatamente uma instrução `SELECT` e que não há nenhum nó
de escrita em toda a árvore, incluindo dentro de CTEs e subqueries. Usa o
**parser real do PostgreSQL** (`libpg-query`, o parser do servidor compilado
para WebAssembly), não um regex — porque comentários, ponto e vírgula e CTEs
contornam qualquer inspeção de texto.

**Camada 2 — a transação** ([src/db.ts](src/db.ts)). Cada query corre dentro de
`BEGIN TRANSACTION READ ONLY`. É redundante face à Camada 0 de propósito: a
Camada 0 é uma defesa de *configuração* (vive num `.env` que pode ser trocado por
engano), esta é uma defesa *do próprio Postgres*, que recusa escritas mesmo com
uma ligação de superutilizador.

**Camada 3 — os limites** ([src/seguranca/camada3-limites.ts](src/seguranca/camada3-limites.ts)).
Queries sem `LIMIT` recebem `LIMIT 200`; um `LIMIT` explícito acima de 500 é
recusado; cada query tem 5 segundos de `statement_timeout` aplicado do lado do
Postgres.

## Perfis de acesso

Um perfil técnico junta as ligações e a capacidade máxima de infraestrutura.
As permissions efetivas vêm do token/authorization layer e são a fonte de
verdade para o que uma sessão pode fazer. Vivem em `src/acesso/perfis.ts` e
`src/autorizacao.ts`.

```
read      ->  leitura: DATABASE_URL_READONLY                                 4 tools máximas
write     ->  leitura: DATABASE_URL_READONLY  +  escrita: DATABASE_URL_WRITE  9 tools máximas
```

Três propriedades que não são acidentais:

- **As leituras de TODOS os perfis saem por `mcp_readonly`.** Mesmo no caminho de
  escrita — resolver a tabela, ler o estado de arquivo, contar a cascata, o
  `run_query` de um admin — tudo passa pelo `executarSoLeitura`, dentro de
  `BEGIN TRANSACTION READ ONLY`. Só o `INSERT`/`UPDATE`/`DELETE` sai por
  `mcp_escrita`.
- **Um pool por connection string, não por perfil.** Os dois perfis leem pela
  mesma variável, portanto partilham um pool. Um papel novo custa zero ligações
  ao Postgres.
- **A ligação de escrita e as tools de escrita são um grupo só** na declaração do
  perfil. Um perfil com tools de escrita e sem ligação por onde as executar não é
  "recusado no arranque" — **não compila**.

### Escolher o perfil no stdio

`MCP_PERFIL=read` (omissão) ou `MCP_PERFIL=write`. Só o perfil pedido é
configurado: um processo lançado como `read` **nem lê do ambiente** a
connection string de escrita, quanto mais abrir um pool com ela.

Um perfil pedido e não configurado — `write` sem `DATABASE_URL_WRITE` — **faz o
servidor não arrancar**, em vez de cair caladamente no perfil de omissão. Um
servidor com menos tools do que quem o arrancou julga descobre-se por uma tool
"que desapareceu", e isso custa mais tempo do que uma linha no stderr.

## Escrita (opcional, desligada por omissão)

No perfil `read`, este servidor é exatamente o que sempre foi: quatro tools,
nenhuma capaz de escrever. **Nada muda para quem não configurar a escrita.** É a
mesma build — a decisão é de configuração, o que permite registar o mesmo
`dist/index.js` duas vezes no cliente MCP, uma entrada só-leitura e outra com
escrita.

> **Ter a `DATABASE_URL_WRITE` definida já não chega.** Sem `MCP_PERFIL=write` o
> servidor arranca em só-leitura à
> mesma e nem sequer lê essa variável.

### As tools de escrita não aceitam SQL

Recebem um nome de tabela, um objeto `coluna -> valor` e a chave primária. O SQL
é construído pelo servidor: os nomes vêm do catálogo do Postgres depois de
validados, os valores vão todos como parâmetros `$n`. Toda a classe de ataques
que a Camada 1 existe para apanhar não tem por onde entrar.

### Só nos dados mestre — 12 das 19 tabelas

`clientes`, `artigos`, `fornecedores`, `vendedores`, `precos_art`, `artfam`,
`artsubfam`, `armazens`, `taxas_iva`, `cond_pag`, `escaloes_cli`, `doc_tipos`.

As sete de movimento — `docs_venda`, `linhas_doc`, `compras`, `linhas_compra`,
`cc_clientes`, `stocks`, `comissoes_vend` — estão bloqueadas, e a razão é
específica desta base: **não há um único `CHECK` nem um único trigger.** Os
invariantes de negócio (o total do documento bater com a soma das linhas, o saldo
da conta corrente, a cadeia orçamento→encomenda→guia→fatura, as comissões, o
stock) existem só no código que gerou os dados. Um `INSERT` em `linhas_doc` não
atualiza o total do documento; um `INSERT` em `docs_venda` não gera o movimento
de conta corrente nem mexe no stock. A base aceitava tudo isso em silêncio.

Nos dados mestre não há invariantes a atravessar tabelas, e as chaves
estrangeiras tratam do resto: apagar um cliente que ainda tem documentos é
recusado pelo próprio Postgres.

Escrever documentos exigirá tools ao nível do **agregado** — criar a fatura, as
linhas, o movimento de c/c e a comissão na mesma transação, com os totais
calculados — e não `INSERT`s linha a linha.

### As camadas do caminho de escrita

**Camada 0 — o utilizador da base de dados.** O `mcp_escrita`, com
`INSERT/UPDATE/DELETE` concedido **tabela a tabela**, nunca por
`ALTER DEFAULT PRIVILEGES`: uma tabela criada amanhã nasce sem escrita. Sem DDL,
sem `TRUNCATE`, nunca superutilizador. Os `GRANT` são aplicados pelo seed do
[testeai-db](../testeai-db), a partir de `seed/src/tabelasEscrita.ts`.

O servidor **recusa-se a arrancar** em modo de escrita se a ligação for de
superutilizador. Colar aqui a `DATABASE_URL_ADMIN` não funciona.

**Camada 1 — o alvo** ([src/seguranca/escrita-camada1-alvo.ts](src/seguranca/escrita-camada1-alvo.ts)).
Whitelist de tabelas (a gémea independente da lista do seed), colunas validadas
contra o `pg_attribute`, e a chave primária tem de vir **exata** — nem uma coluna
a menos (senão o `WHERE` deixava de identificar uma linha só) nem uma a mais (a
chave é um identificador, não um filtro). Alterar a própria chave primária num
`update_row` é recusado.

**Camada 2 — a guarda de linhas** ([src/seguranca/escrita-camada3-linhas.ts](src/seguranca/escrita-camada3-linhas.ts)).
No caminho de leitura, a defesa que não depende de configuração nenhuma é o
`BEGIN TRANSACTION READ ONLY`. No caminho de escrita essa defesa não pode existir
— a transação tem de poder escrever — e é isto que ocupa o lugar dela: **depois**
da instrução correr e **antes** do `COMMIT`, o `rowCount` que o Postgres devolveu
é comparado com 1. Se não bater, há `ROLLBACK` e nada chega a disco. É a única
posição em que a verificação funciona.

**Camada 3 — o timeout.** O mesmo `statement_timeout` das leituras, aplicado do
lado do Postgres.

As ligações de leitura e de escrita são **dois pools distintos**, em dois
ficheiros distintos ([src/db.ts](src/db.ts) e [src/db-escrita.ts](src/db-escrita.ts)),
com duas connection strings. A validação de catálogo das tools de escrita corre
pela ligação **só-de-leitura**: a de escrita é usada para a instrução final e
para mais nada.

### Antes de ligar

Escrever na base faz as **50 respostas do `EVAL-QUESTIONS.md` deixarem de bater
certo**. Para as repor, correr outra vez o seed.

```powershell
npm run teste:escrita   # 31 verificações; cria, edita e apaga um cliente de teste
```

## Pré-requisitos

- Node.js 22+
- O container do [testeai-db](../testeai-db) a correr e povoado

## Atualizar uma instalação anterior

### Vens de antes do arquivo (soft delete)?

Faz primeiro a parte do `testeai-db` — o arquivo depende de uma coluna nova e
de políticas de RLS que só o seed cria. Sem isso, este servidor arranca e falha à
primeira tool.

```powershell
git pull
npm install
npm run build
```

O `.env` não muda. As tools novas (`archive_row`, `restore_row`) aparecem sozinhas
se já tinhas a `DATABASE_URL_WRITE` definida; sem ela o servidor continua com as 4
de leitura, agora com o parâmetro `incluir_arquivados`.

**O `delete_row` mudou de significado** — deixou de ser o "apagar" do dia-a-dia e
passou a ser destruição definitiva em cascata, só possível depois de
`archive_row`. Ver [Arquivo](#arquivo-soft-delete). Quem tivesse automatismos a
chamá-lo passa a receber um erro explícito, não um apagar silencioso.

```powershell
npm run teste            # 22/22
npm run teste:escrita    # 31/31 — precisa da DATABASE_URL_WRITE
```

### Vens de antes das tools de escrita?

Se já tinhas este servidor a correr **antes de existirem as tools de escrita**, é
este o caminho. Faz primeiro a parte do
[testeai-db](../testeai-db#atualizar-uma-instalação-anterior) — sem o role
criado do lado da base, o passo 2 aqui não tem nada a que se ligar.

```powershell
git pull
npm install
npm run build
```

Se parares aqui, **está tudo a funcionar** e o servidor continua só-de-leitura,
com as 4 tools de sempre. Nada do que se segue é obrigatório.

**1. Ligar a escrita** — acrescenta ao `.env` a linha `DATABASE_URL_WRITE` que
puseste no `.env` do `testeai-db` (é exatamente a mesma):

```
DATABASE_URL_WRITE=postgresql://mcp_escrita:<password>@localhost:5434/distribuidor
```

**2. Verificar:**

```powershell
npm run teste            # 22/22 — a leitura não regrediu
npm run teste:escrita    # 31/31 — precisa da DATABASE_URL_WRITE e de MCP_PERFIL=write
```

O `teste:escrita` cria, edita e apaga um cliente `999999` e confirma no fim que a
base ficou como estava.

**3. Cliente MCP.** O `.env` **não** chega ao Claude Desktop nem ao Claude Code:
eles lançam o processo sem shell e sem diretório de trabalho, portanto o `dotenv`
não encontra ficheiro nenhum. A variável tem de ser repetida na configuração do
cliente — ver as secções 4 e 5.

## 1. Instalar e configurar

```powershell
npm install
Copy-Item .env.example .env
```

Abre o `.env` e preenche a `DATABASE_URL_READONLY`. A password é a
`READONLY_PASSWORD` do `.env` do `testeai-db`, e a porta é a
`POSTGRES_HOST_PORT` desse mesmo ficheiro (**5434** na configuração atual, não a
5432 por omissão do Postgres):

```
DATABASE_URL_READONLY=postgresql://mcp_readonly:<password>@localhost:5434/distribuidor
```

> Nunca coles aqui a `DATABASE_URL_ADMIN`. O caminho de leitura não tem nenhuma
> razão para conseguir escrever — e o de escrita recusa-se a arrancar com uma
> ligação de superutilizador.

Para ligar as tools de escrita são precisas **duas** coisas: a
`DATABASE_URL_WRITE` (a do `mcp_escrita`, criada pelo seed do `testeai-db`) **e**
`MCP_PERFIL=write`. Sem as duas o servidor arranca só-de-leitura — que é o
comportamento por omissão e o recomendado. Ver
[Perfis de acesso](#perfis-de-acesso).

## 2. Compilar e confirmar

```powershell
npm run build
npm start
```

O arranque tem de mostrar, no **stderr**:

```
[mcp] parser do PostgreSQL carregado.
[mcp] ligado a distribuidor (PostgreSQL 18.4)
[mcp] current_user = mcp_readonly          <-- a confirmação que interessa
[mcp] perfil 'read': 4 tools — list_tables, describe_table, sample_rows, run_query.
[mcp] perfil em uso: read.
[mcp] servidor pronto, à escuta em stdio.
```

Aquele `current_user` é a verificação mais barata de que a ligação não está, por
engano, a usar a connection string de admin. Se lá aparecer `admin_dist`, o
`.env` está errado.

Com `MCP_PERFIL=write` e a `DATABASE_URL_WRITE` definida:

```
[mcp] current_user = mcp_readonly
[mcp] ESCRITA LIGADA — current_user = mcp_escrita
[mcp] o Postgres concede INSERT a este utilizador em 12 tabelas: armazens, artfam, ...
[mcp] perfil 'write': 9 tools — list_tables, ..., delete_row.
[mcp] arquivo: archive_row esconde (reversível); delete_row destrói em cascata e exige que a linha já esteja arquivada.
[mcp] perfil em uso: write.
```

Com `MCP_PERFIL=write` mas **sem** a `DATABASE_URL_WRITE`, o servidor não sobe:

```
[mcp] perfil 'write' não configurado: falta DATABASE_URL_WRITE (a escrita é opt-in).
[mcp] FALHA NO ARRANQUE: Nenhum perfil de acesso pôde ser configurado — o servidor não teria nada que fazer.
```

Se aparecerem 19 tabelas em vez de 12, os `GRANT` do seed ficaram largos de mais
— o servidor avisa e recusa-as à mesma, mas vale a pena voltar a correr o seed.

Aqui não é possível enganar-se com o utilizador: **o servidor recusa-se a
arrancar** se a `DATABASE_URL_WRITE` for de superutilizador, e sai com código 1.

O servidor fica à espera de mensagens JSON-RPC no stdin — é suposto parecer que
está pendurado. `Ctrl+C` para sair.

## 3. Testar com o MCP Inspector

O Inspector é a ferramenta oficial de debug do protocolo: lança o servidor,
lista as tools e deixa chamá-las à mão, sem ser preciso um cliente MCP completo.

```powershell
npm run inspector
```

(equivale a `npm run build && npx @modelcontextprotocol/inspector node dist/index.js`)

Abre uma UI no browser e imprime no terminal um URL com token de sessão. Lá
dentro: **Connect** → separador **Tools** → **List Tools** → escolhe uma → enche
os argumentos → **Run Tool**.

Não é preciso passar a variável de ambiente ao Inspector: o servidor lê o `.env`
sozinho. O stderr do servidor aparece no painel inferior do Inspector.

### Queries para experimentar no `run_query`

| # | Query | Resultado esperado |
|---|---|---|
| 1 | `SELECT nome, escalao FROM clientes ORDER BY nome LIMIT 5;` | ✅ 5 linhas |
| 2 | `WITH f AS (SELECT no_cli, sum(total) AS t FROM docs_venda WHERE cod_doc='FAT' GROUP BY 1) SELECT c.nome, round(f.t,2) FROM f JOIN clientes c ON c.no_cli=f.no_cli ORDER BY f.t DESC LIMIT 5;` | ✅ CTE + agregação passam |
| 3 | `UPDATE clientes SET nome='x' WHERE no_cli=1;` | ❌ "Esta instrução é do tipo UPDATE" |
| 4 | `WITH apagados AS (DELETE FROM clientes RETURNING *) SELECT * FROM apagados;` | ❌ "operação não permitida (DeleteStmt)" |
| 5 | `SELECT 1; DROP TABLE clientes;` | ❌ "contém 2 instruções" |
| 6 | `SELECT FROM WHERE clientes;` | ❌ "SQL inválido: syntax error" |
| 7 | `SELECT * FROM artigos;` | ✅ 200 linhas, com `LIMIT 200` automático |
| 8 | `SELECT * FROM artigos LIMIT 5000;` | ❌ "excede o máximo permitido de 500" |
| 9 | `SELECT * INTO nova FROM clientes;` | ❌ "operação não permitida (intoClause)" |

O nº 7 é o que confirma a Camada 3: a resposta traz `limite_aplicado` e
`sql_executado` a mostrar exatamente o que foi corrido.

O nº 9 é o mais instrutivo — `SELECT ... INTO` **cria uma tabela**, e a instrução
de topo continua a ser um `SelectStmt`. Só a varredura recursiva o apanha.

### A mesma bateria, automatizada

```powershell
npm run cobertura       # 50/50 — as queries do EVAL-QUESTIONS.md passam a Camada 1
npm run teste           # 22 verificações por um cliente MCP real, por stdio
npm run teste:escrita   # 31 verificações do caminho de escrita
npm run teste:http      # 25 verificações da autenticação e dos perfis, por HTTP
```

O `teste:http` lança o servidor num porto próprio, com segredos gerados na hora,
e afirma pelo cabo real: sem token → 401, assinatura adulterada → 401, expirado →
401, audiência errada → 401, perfil técnico não configurado → 403,
`read`/`read-commercial` → 4 tools, `write` → 9, e — a que decide a fase —
**uma sessão com um conjunto de permissions diferente do token → 403**. Precisa
da `DATABASE_URL_WRITE`, senão metade não teria o que verificar.

O `teste:escrita` só corre com a `DATABASE_URL_WRITE` definida — sem ela as tools
de escrita nem sequer são registadas, e o script sai a dizê-lo em vez de falhar
trinta e uma vezes seguidas. Cria, edita e apaga um cliente `999999` e a última
verificação é que a base ficou **como estava**: pode correr-se contra a base de
referência sem estragar as respostas do `EVAL-QUESTIONS.md`.

O `cobertura` precisa do `EVAL-QUESTIONS.md`, que vive no **outro** repositório.
Procura-o sozinho em `../testeai-db/` e `../mcpdbteste/` (relativos a este
repositório). Se o tiveres noutro sítio, aponta-lho:

```powershell
$env:CAMINHO_EVALS="C:\caminho\para\EVAL-QUESTIONS.md"; npm run cobertura
```

O `teste` faz o mesmo que se faria a clicar no Inspector, mas de forma
reproduzível. O `cobertura` é o contrapeso: prova que a Camada 1 **não** rejeita
queries legítimas — uma camada de segurança que bloqueia trabalho válido é tão
inútil como uma que não bloqueia nada.

### Ver a Camada 2 a trabalhar sozinha

As camadas 1 e 3 apanham quase tudo antes de a 2 entrar em jogo, portanto ela
quase nunca dispara — e é preciso prová-la à parte. Como **admin**, de propósito:

```powershell
cd ..\testeai-db
docker compose exec db psql -U admin_dist -d distribuidor -c "BEGIN TRANSACTION READ ONLY; UPDATE clientes SET nome='x' WHERE no_cli=1;"
```

Responde `ERROR: cannot execute UPDATE in a read-only transaction`. É a
demonstração de que a Camada 2 trava escrita mesmo com a connection string
errada — que é exatamente para isso que ela existe.

### Ver a Camada 0 da escrita a trabalhar sozinha

Sem passar pelo servidor, só com `psql`. É a forma mais rápida de confirmar que o
lado da base ficou bem configurado — útil sobretudo depois de instalar noutra
máquina, porque não precisa de Node nem de build.

```powershell
cd ..\testeai-db
$env:PGPASSWORD = "<a ESCRITA_PASSWORD do .env>"

# 1. Não é superutilizador — tem de responder "off"
docker compose exec -T -e PGPASSWORD=$env:PGPASSWORD db psql -U mcp_escrita -d distribuidor -t -A -c "SELECT current_setting('is_superuser');"

# 2. Dados mestre — tem de FUNCIONAR
docker compose exec -T -e PGPASSWORD=$env:PGPASSWORD db psql -U mcp_escrita -d distribuidor -c "INSERT INTO clientes (no_cli, nome) VALUES (999999, 'teste'); DELETE FROM clientes WHERE no_cli = 999999;"

# 3. Tabela de movimento — tem de ser RECUSADO
docker compose exec -T -e PGPASSWORD=$env:PGPASSWORD db psql -U mcp_escrita -d distribuidor -c "DELETE FROM docs_venda WHERE id = 1;"

# 4. DDL — tem de ser RECUSADO
docker compose exec -T -e PGPASSWORD=$env:PGPASSWORD db psql -U mcp_escrita -d distribuidor -c "CREATE TABLE xpto (id int);"

# 5. TRUNCATE — tem de ser RECUSADO
docker compose exec -T -e PGPASSWORD=$env:PGPASSWORD db psql -U mcp_escrita -d distribuidor -c "TRUNCATE clientes;"
```

Respostas esperadas: `off`, depois `INSERT 0 1` / `DELETE 1`, e a seguir três
recusas — `permission denied for table docs_venda`, `permission denied for schema
public` e `permission denied for table clientes`. A última é a distinção que
interessa: o role tem `DELETE` em `clientes` mas **não** tem `TRUNCATE`.

E a garantia do lado do servidor, que se prova em dois segundos — pôr a
`DATABASE_URL_ADMIN` na `DATABASE_URL_WRITE` e arrancar:

```
[mcp] FALHA NO ARRANQUE: A DATABASE_URL_WRITE liga-se como 'admin_dist', que é SUPERUTILIZADOR.
```

O processo sai com código 1. Um superutilizador ignora as permissões por tabela,
que são a camada 0 de toda a funcionalidade de escrita — por isso não é um aviso.

## 4. Registar no Claude Desktop

Abre a configuração:

```powershell
code $env:AppData\Claude\claude_desktop_config.json
```

```json
{
  "mcpServers": {
    "distribuidor": {
      "command": "node",
      "args": ["C:\\DEV\\testesmcps\\testeai-mcp-server\\dist\\index.js"],
      "env": {
        "DATABASE_URL_READONLY": "postgresql://mcp_readonly:<password>@localhost:5434/distribuidor"
      }
    }
  }
}
```

Para ligar as tools de escrita, acrescenta **duas** entradas ao bloco `env` — e a
vírgula no fim da primeira, senão o JSON fica inválido e o Claude Desktop não
arranca o servidor de todo:

```json
      "env": {
        "DATABASE_URL_READONLY": "postgresql://mcp_readonly:<password>@localhost:5434/distribuidor",
        "DATABASE_URL_WRITE": "postgresql://mcp_escrita:<password>@localhost:5434/distribuidor",
        "MCP_PERFIL": "write"
      }
```

Sem o `MCP_PERFIL=write` ficas com as 4 tools de leitura, mesmo tendo a
`DATABASE_URL_WRITE` ali. É de propósito: a capacidade técnica é pedida por
nome.

> Se preferires ter as duas coisas em simultâneo — uma ligação só-de-leitura para
> o dia-a-dia e outra com escrita para quando precisares —, regista **a mesma
> build duas vezes**, com nomes diferentes e blocos `env` diferentes. É o mesmo
> `dist/index.js`; a diferença é o `MCP_PERFIL`.

Reinicia o Claude Desktop a seguir (fechar a janela não chega — tem de sair pelo
ícone da barra de tarefas).

Três pormenores que dão dores de cabeça:

- **O caminho tem de ser absoluto, e as barras duplicadas.** O `\` é o caractere
  de escape do JSON, portanto `C:\DEV\...` seria inválido.
- **As variáveis de ambiente TÊM de ir no bloco `env`.** O Claude Desktop lança o
  processo sem herdar o shell nem o diretório de trabalho, por isso o `.env` do
  projeto não é encontrado. É por isso que a configuração o repete — e é por isso
  que pôr a `DATABASE_URL_WRITE` só no `.env` não liga a escrita aqui.
- **Tem de estar compilado.** O `args` aponta para `dist/index.js`, não para o
  TypeScript. Se mexeres no código, `npm run build` antes de reiniciar.

No log (`mcp-server-distribuidor.log`) confirma-se pela linha `perfil em uso:` e
pelo número de tools: **4** no `read`/`read-commercial`, **9** no `write`.

Os logs (o nosso stderr) ficam em `%AppData%\Claude\logs\mcp-server-distribuidor.log`.

## 5. Registar no Claude Code

```powershell
claude mcp add distribuidor --env DATABASE_URL_READONLY="postgresql://mcp_readonly:<password>@localhost:5434/distribuidor" -- node C:\DEV\testesmcps\testeai-mcp-server\dist\index.js
```

Com escrita, mais dois `--env` (a ligação **e** o perfil):

```powershell
claude mcp add distribuidor --env DATABASE_URL_READONLY="postgresql://mcp_readonly:<password>@localhost:5434/distribuidor" --env DATABASE_URL_WRITE="postgresql://mcp_escrita:<password>@localhost:5434/distribuidor" --env MCP_PERFIL="write" -- node C:\DEV\testesmcps\testeai-mcp-server\dist\index.js
```

Ou, para o servidor ficar disponível só neste projeto, um `.mcp.json` na raiz com
o mesmo conteúdo do bloco `mcpServers` acima.

## Transporte HTTP (Streamable HTTP)

O stdio acima é o modo de desenvolvimento local: um processo por cliente,
lançado pelo próprio cliente. O **Streamable HTTP** é o outro cabo pelo qual o
mesmo servidor pode ser servido — um processo à escuta num porto, vários
clientes ao mesmo tempo, **e vários papéis** — e é o modo que corre em produção.

**Exige a `MCP_TOKEN_SEGREDO`, e recusa-se a arrancar sem ela.** Ver
[Autenticação](#autenticação-só-no-modo-http).

```powershell
npm run build
$env:MCP_TOKEN_SEGREDO = "<pelo menos 32 caracteres>"
npm run start:http          # ou: npm run dev:http (sem compilar)
```

```
[mcp] autenticação por token ligada.
[mcp] perfil 'read': 4 tools — ...
[mcp] perfil 'write': 9 tools — ...
[mcp] perfis técnicos servidos por este endpoint: read, read-commercial, write.
[mcp] servidor pronto, à escuta em http://127.0.0.1:3000/mcp (Streamable HTTP).
```

Repara na diferença face ao stdio: aqui **não há `MCP_PERFIL`**. Configuram-se
todos os perfis que o ambiente permitir, e quem escolhe é o token de cada pedido.
Um perfil sem configuração fica de fora e um token que o peça leva **403** — é
assim que a escrita continua opt-in: sem `DATABASE_URL_WRITE`, o `write` não
existe neste endpoint.

Para o apontar o Inspector: `npx @modelcontextprotocol/inspector`, e na UI
escolher o transporte **Streamable HTTP** com o URL `http://127.0.0.1:3000/mcp`
(em vez de deixar o Inspector lançar o processo).

**As tools são exatamente as mesmas**, e não por coincidência: os dois pontos de
entrada — `src/index.ts` e `src/http.ts` — constroem o servidor pela mesma função
`criarServidor()` do `src/servidor.ts`. Uma tool nova aparece nos dois modos sem
ninguém se lembrar disso.

Um endpoint, três métodos, como manda a especificação do transporte:

| Método | Para quê |
|---|---|
| `POST /mcp` | as mensagens JSON-RPC; a resposta vem em SSE na mesma ligação |
| `GET /mcp` | stream SSE avulsa — notificações do servidor e retoma (`Last-Event-ID`) |
| `DELETE /mcp` | encerrar a sessão |

**Sessões com estado.** O `initialize` abre uma sessão e o servidor devolve o
`Mcp-Session-Id` num cabeçalho; todos os pedidos seguintes têm de o trazer. Os
pools de ligação **não** pertencem à sessão — são do processo, abertos uma vez no
arranque — e é isso que torna uma sessão barata: um `McpServer` e as closures das
suas tools, mais nada. O raciocínio completo, incluindo porque não é *stateless*,
está no cabeçalho do `src/http.ts`.

### Autenticação (só no modo HTTP)

A identidade viaja num token Bearer assinado. As permissions e o perfil técnico
que vêm lá dentro decidem as tools daquela sessão.

**Porque é um token e não basic auth**, que é a pergunta óbvia: o `mcp_servers`
do MCP connector da API da Anthropic aceita `type`, `url`, `name` e
`authorization_token`, e mais nada. Não há campo para cabeçalhos arbitrários, e
o `Authorization` é **um só** — credenciais embutidas no URL viram `Basic`, o
`authorization_token` vira `Bearer`, e não cabem os dois. Um endpoint único com
um endpoint único com permissions diferentes obriga a que a identidade venha no
token. Não se deve sobrepor Basic Auth ao mesmo `Authorization`: se o Coolify
tiver Basic Auth, ele só pode ser uma camada externa separada e não substitui o
Bearer do MCP.

| | |
|---|---|
| Formato | `base64url(claims).base64url(hmac-sha256)`, com `node:crypto` |
| Claims | `sub`, `permissoes`, `autorizacaoVersao`, `perfilTecnico`, `iat`, `exp`, `aud="mcp-distribuidor"`, `jti` |
| Validade | 15 minutos |
| Segredo | `MCP_TOKEN_SEGREDO`, mínimo 32 caracteres |
| Rotação | `MCP_TOKEN_SEGREDO_ANTERIOR`, aceite só na verificação |

Não é JWT de propósito: aqui só existe um algoritmo e ele **não vem do token**,
portanto o `alg` confuso não é um risco que se corra — é um campo que não existe.

**Todos os métodos reautenticam**, e a sessão é confrontada com a identidade. O
`Mcp-Session-Id` é criado no `initialize` e reutilizado depois; sem esse
confronto, ele seria ele próprio uma credencial de longa duração e um token de
uma sessão com permissions diferentes podia continuar aberta com um token mais
privilegiado. Foi confirmado por
sonda que o connector manda o `Authorization` em **todos** os pedidos da sessão,
e não só no primeiro — é o que torna isto exequível.

O deployment pode usar `GET /healthz` para liveness e `GET /readyz` para
readiness. Estes endpoints não expõem dados da base nem dispensam a
autenticação do `/mcp`.

Emitir um token à mão:

```powershell
npx tsx scripts/emitirToken.ts write --sujeito ana
```

(Com opções, sempre por `npx tsx` — o `npm run token` fica para si com os
argumentos que começam por `--`.)

### O que ainda não está aqui

- **Sem TLS neste processo.** Termina-se o TLS à frente (Cloudflare + Traefik, em
  produção). O servidor liga-se a `127.0.0.1` por omissão — mudar isso com
  `MCP_HTTP_HOST` é um ato deliberado, para um deployment;
- **Rate limiting de negócio.** O endpoint continua a precisar de rate limiting
  no reverse proxy para proteção contra abuso de rede; o servidor aplica limites
  de sessões por identidade e no total, e expira sessões inativas;
- **Sem `EventStore`.** A retoma por `Last-Event-ID` funciona dentro do processo,
  mas o estado não é partilhado — duas instâncias atrás de um balanceador exigem
  *sticky sessions* ou um armazenamento comum (Redis).

O que **está** feito é a validação do cabeçalho `Origin` (só origens locais, ou
as que a `MCP_HTTP_ORIGENS` listar). Não é autenticação: é a proteção contra *DNS
rebinding* que a especificação do MCP recomenda, e sem ela qualquer página aberta
no browser desta máquina podia correr queries sobre a base de dados. Clientes
nativos, que não enviam `Origin`, passam.

## Estrutura

```
src/
  index.ts                    ponto de entrada STDIO (desenvolvimento local)
  http.ts                     ponto de entrada Streamable HTTP, sobre Hono
  servidor.ts                 arranque, criação do McpServer, encerramento — partilhado
  log.ts                      o log dos dois modos, e porque vai tudo para o stderr
  db.ts                       pool de LEITURA + CAMADA 2 (transação READ ONLY)
  db-escrita.ts               pool de ESCRITA + transação com guarda de linhas
  erros.ts                    sanitização das mensagens que saem para o cliente
  identificadores.ts          uso seguro de nomes de tabela dentro de SQL
  seguranca/
    camada1-parser.ts         uma única instrução SELECT, sem nós de escrita
    camada3-limites.ts        LIMIT automático / recusa acima do máximo
    escrita-camada1-alvo.ts   whitelist de tabelas, colunas e chave primária
    escrita-camada3-linhas.ts a guarda: exatamente uma linha, ou ROLLBACK
    arquivo.ts                o soft delete — e a explicação de porque NÃO filtra
    cascata.ts                conta o que um apagar leva consigo, antes de o levar
  tools/
    listTables.ts  describeTable.ts  sampleRows.ts  runQuery.ts
    insertRow.ts   updateRow.ts      deleteRow.ts
    archiveRow.ts  restoreRow.ts
    escritaComum.ts           schema Zod e fragmentos de SQL partilhados
    resposta.ts               formatação JSON das respostas
scripts/
  testeCamadas.ts             bateria de testes por cliente MCP real
  testeEscrita.ts             o mesmo para o caminho de escrita (limpa atrás de si)
  coberturaEvals.ts           deteção de falsos positivos da Camada 1
```

As camadas estão em ficheiros separados de propósito: é a forma de a
independência entre elas ser visível na árvore de ficheiros, e não apenas uma
afirmação neste README.

## Configuração opcional

Todas com valores por omissão sensatos; ver o `.env.example`.

| Variável | Omissão | O que faz |
|---|---|---|
| `MCP_TIMEOUT_MS` | 5000 | `statement_timeout` por query (leitura e escrita) |
| `MCP_LIMITE_AUTOMATICO` | 200 | `LIMIT` acrescentado a queries sem um |
| `MCP_LIMITE_MAXIMO` | 500 | `LIMIT` explícito máximo aceite |
| `DATABASE_URL_WRITE` | *(vazia)* | A ligação do `mcp_escrita`. Sem ela o perfil técnico `write` não existe |
| `MCP_PERFIL` | `read` | Que perfil técnico o processo serve. **Só no stdio** — no HTTP vem do token |
| `MCP_TOKEN_SEGREDO` | — | **Obrigatória no modo HTTP.** Assina a identidade; mínimo 32 caracteres |
| `MCP_TOKEN_SEGREDO_ANTERIOR` | *(vazia)* | Segredo antigo, aceite só na verificação, para rodar sem janela de falha |
| `MCP_HTTP_PORT` | 3000 | Porto do endpoint MCP (só no modo HTTP) |
| `MCP_HTTP_HOST` | 127.0.0.1 | Interface onde escuta. `0.0.0.0` expõe à rede — ver os avisos acima |
| `MCP_HTTP_ORIGENS` | *(vazia)* | Origens aceites além das locais, separadas por vírgula |
| `MCP_SESSAO_TTL_MINUTOS` | 30 | Minutos de inatividade até expirar uma sessão |
| `MCP_SESSOES_MAXIMAS` | 1000 | Limite total de sessões em memória |
| `MCP_SESSOES_POR_SUJEITO` | 10 | Limite de sessões simultâneas por identidade |

O limite de linhas por escrita **não** é configurável, de propósito: é sempre 1.
Uma variável de ambiente para o subir só serviria para desligar a defesa.

## Notas

**`npm audit` reporta 2 vulnerabilidades moderadas.** Ambas são a mesma coisa:
`@hono/node-server` com um problema de *path traversal* no `serve-static`. Desde
o transporte HTTP que este pacote deixou de ser só uma dependência transitiva do
SDK do MCP e passou a ser usado diretamente — mas o que se usa dele é o `serve`,
não o `serve-static`. Este servidor não serve ficheiros estáticos e nunca importa
o módulo em causa: o único caminho é o `/mcp`, e quem responde nele é o
transporte do SDK. A correção que o npm sugere é descer o SDK uma versão major,
o que traria problemas reais em troca de um risco que aqui não existe.

**Funções customizadas no schema.** O schema atual só tem tabelas. Uma função
acrescentada mais tarde seria chamável a coberto de um `SELECT`, e a Camada 1
deixá-la-ia passar — para o parser, `SELECT f()` é uma leitura como outra
qualquer.

O que fecha esta porta está do lado da base de dados, não aqui: o `public` tem
`EXECUTE` **revogado de `PUBLIC`**, ao contrário do que o Postgres faz por
omissão, e as únicas funções concedidas ao `mcp_readonly` são as do `unaccent` e
do `pg_trgm`, de que as queries de deteção de duplicados dependem. Uma função
nova nasce, portanto, **não** chamável por este servidor — tem de haver um
`GRANT` explícito, que é uma decisão visível em vez de um efeito colateral. Ver
`reporPermissoes()` no `seed/src/seed.ts` e o `docker/initdb/01-init-readonly.sh`
do repositório da base de dados.

A revogação é `IN SCHEMA public` e não toca no `pg_catalog` — as tools deste
servidor continuam a chamar `count()`, `pg_size_pretty()` e
`has_table_privilege()` normalmente.

O que a Camada 2 cobre aqui, verificado na prática e não por suposição: uma
função `SECURITY DEFINER` que faça `UPDATE` **é bloqueada** dentro da transação
READ ONLY (`ERROR: cannot execute UPDATE in a read-only transaction`,
`CONTEXT: SQL function ...`). O `SECURITY DEFINER` muda o *utilizador* com que a
função corre, não o estado da transação — e o modo só-leitura é uma propriedade
da transação, que a função não consegue contornar.

Fica na mesma por rever, porque há duas coisas que a Camada 2 não cobre:
funções assim podem **ler** dados a que o `mcp_readonly` não deveria ter acesso,
e efeitos laterais que não passam pelo motor de transações (`dblink`, que abre
uma ligação nova e independente, `COPY TO PROGRAM`, ou linguagens não confiáveis
como `plpython3u`) escapam ao modo só-leitura por completo.