Skip to main content
Glama
GoncaloSDev

agentsystem-mcp-server

by GoncaloSDev

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).

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 e Arquivo.

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, Perfis de acesso e Transporte 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.

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)

Related MCP server: TextToSQL MCP Server

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:

{
  "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). 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). 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). 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 INSERTs 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, 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). 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). 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 e 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.

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 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.

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. Quem tivesse automatismos a chamá-lo passa a receber um erro explícito, não um apagar silencioso.

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 — sem o role criado do lado da base, o passo 2 aqui não tem nada a que se ligar.

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:

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

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.

2. Compilar e confirmar

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.

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

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:

$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:

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.

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:

code $env:AppData\Claude\claude_desktop_config.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:

      "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

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):

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.

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:

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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Read-only SQL Server MCP server enabling safe database queries, table listing, and schema inspection with built-in security protections.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    A read-only, schema-aware MCP server that connects to SQL databases and provides tools for refining questions, browsing schema, validating, explaining, and running SELECT statements with safety guards.
    MIT
  • F
    license
    A
    quality
    C
    maintenance
    A read-only MCP server for browsing and querying SQL Server databases, providing tools to list schemas, tables, describe columns, and execute safe SELECT queries with validated parameters.
    15
    -
  • A
    license
    Not graded
    quality
    A
    maintenance
    A read-only-by-default Model Context Protocol (MCP) server for Microsoft SQL Server with schema discovery, SELECT-only queries, execution-plan analysis, and opt-in writes per profile. Profile-based configuration serves multiple databases and servers from one toolset deployment.
    9
    MIT