postgres-mcp-hardened

Substituto em Rust mantido para o arquivado @modelcontextprotocol/server-postgres. Gravações são recusadas duas vezes: validação AST do sqlparser antes da execução, além de default_transaction_read_only no nível do banco e um statement_timeout por sessão. Binário único, stdio e Streamable HTTP, inspeção de esquema, redação de colunas, proteção de custo EXPLAIN, registro de auditoria com encadeamento hash, OAuth 2.1 opcional, imagem distroless. MIT.

Documentação

postgres-mcp-hardened

🚧 Versão 0.1.10 — o restante de uma revisão externa, e uma correção que foi contundente demais

Publicado: binários para cinco plataformas com checksums, assinaturas Sigstore e proveniência de build; .mcpb bundles para instalação com um clique; uma imagem no ghcr.io para amd64 e arm64; um pacote no npm; e uma entrada no registro oficial do MCP.

0.1.8 e 0.1.9 fecharam seis bypasses que quatro revisores independentes encontraram no 0.1.7, nenhum deles encontrado por nós. Dois dias do nosso próprio trabalho adversarial tinham voltado quase limpos no dia anterior. Passar nos testes que você pensou em escrever não é o mesmo que olhar. O que mais importava não precisa de privilégios nenhum: com uma coluna redigida, um join nela através de USING respondia se um determinado valor estava presente, o que é um oráculo completo de igualdade contra o leitor de privilégio mínimo que este projeto diz para você configurar.

0.1.10 fecha os três achados que ficaram em aberto. Com MCP_ALLOW_SCHEMAS=public, SELECT * FROM secret.salaries foi recusado enquanto describe_table entregava todas as colunas, tipos e defaults da mesma tabela. Uma string de conexão comum sem sslmode usava o prefer do driver, que envia tudo em texto claro sempre que o servidor recusa TLS, e qualquer pessoa no fio pode fazê-lo recusar. E MCP_SSLROOTCERT adicionou uma autoridade certificadora privada a 242 públicas em vez de substituí-las, então um operador que acreditava ter fixado a confiança ao seu próprio emissor não tinha.

A primeira versão dessa correção de TLS estava errada de um jeito que vale a pena ler. Ela perguntava "isto é loopback" e exigia TLS de todo o resto, o que derrubou seis jobs de versões do PostgreSQL, a execução de conformidade, a verificação do contêiner e o corpus adversarial em um único push. Todos eles alcançam o banco de dados do jeito que implantações comuns fazem: postgres://user:pass@postgres:5432/db, um nome de serviço em uma rede privada. Docker Compose e Kubernetes não são a internet pública, e um servidor que exige TLS de um contêiner em uma rede bridge é um servidor que as pessoas desligam por completo. A pergunta agora é se alguém não confiável pode se sentar no fio, não se o endereço é loopback, e um socket real para um PostgreSQL alcançável apenas por nome de serviço está na suíte de testes, porque uma regra sobre redes deveria ser testada através de uma rede.

Um limite de recurso está documentado e não resolvido, em THREAT_MODEL.md: 49 bytes de SQL fazem o PostgreSQL dobrar uma constante em 5,9 GB de memória do backend durante o planejamento, e um statement_timeout de cinco segundos não o impede. As formas óbvias são recusadas; o problema geral está a montante de qualquer coisa que este servidor possa fazer.

Cada mudança aqui foi reproduzida contra um servidor em execução antes de ser corrigida, e cada uma está em CHANGELOG.md com a consulta.

Tudo aqui é 0.1.x porque ninguém fora deste projeto o executou contra seus próprios dados.

O servidor MCP oficial do Postgres foi descontinuado em 2024 e ainda recebe 391 mil downloads por mês. Sua defesa inteira é uma transação somente leitura no nível do banco de dados — e isso sozinho não impede toda escrita. Este é um substituto mantido em Rust com defesa em profundidade.

Um servidor Model Context Protocol drop-in que permite a um agente de IA consultar PostgreSQL — somente leitura, aplicado no nível do banco de dados, com validação real de SQL, timeouts, limites de custo, OAuth 2.1 e uma trilha de auditoria. Fala Streamable HTTP e stdio, e negocia a revisão do MCP: 2026-07-28 (atual, e o padrão desde que o upstream o lançou em 2026-08-03), 2025-11-25 e 2025-06-18 — o que a maioria dos clientes em uso ainda fala hoje. Um cliente pede o que conhece; não é negociado para baixo.

Tente quebrá-lo — um comando, sem banco de dados

A proteção somente leitura tem um modo offline. Entregue uma declaração e ela diz o que decidiu: sem banco de dados, sem configuração, nada instalado permanentemente.

npx postgres-mcp-hardened --validate "/* comment */ DROP TABLE users"
# REJECT: non-read-only statement: Drop

npx postgres-mcp-hardened --validate "SELECT 1; DROP TABLE users"
# REJECT: multiple statements are forbidden

npx postgres-mcp-hardened --validate "WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d"
# REJECT: non-read-only statement: non-read-only query (CTE / SELECT INTO / FOR UPDATE)

npx postgres-mcp-hardened --validate "SELECT * FROM orders WHERE id = 1"
# ALLOW

Se algo que escreve voltar como ALLOW, isso é a coisa mais valiosa que alguém pode nos enviar. Não precisa de exploit funcional nem de relatório — uma linha de SQL e "isto não deveria ser permitido" é um relatório completo. Qualquer coisa que passe pela proteção vai para SECURITY.md; todo o resto é uma issue comum, e o critério para abrir uma é isto parece errado para mim, não tenho certeza.

O fuzzer é determinístico e imprime sua semente, então o que ele encontrar se reproduz em uma máquina que nunca viu a sua — um milhão de mutações leva cerca de um minuto:

npx postgres-mcp-hardened --fuzz 1000000
# fuzz: 1000000 iterations, seed 1592594996, slowest validation 8 ms
# RESULT: 0 invariant violations

Para o conjunto completo contra um banco de dados real, docker compose -f examples/docker-compose.yml up -d sobe PostgreSQL com dados de exemplo e o servidor na frente dele, conectando-se como um papel que detém SELECT e nada mais.

Cada bypass encontrado até agora vive no corpus MUST_REJECT em src/validate.rs e roda em cada commit, registrado com o que custou em vez de varrido para debaixo do tapete. O seu se juntaria a eles.

Por quê

@modelcontextprotocol/server-postgres está descontinuado no npm (última publicação em dezembro de 2024) e ainda vê 475.790 downloads nos 30 dias até 9 de agosto de 2026. Crédito onde é devido: sua abordagem não é ingênua — ela envolve cada consulta em BEGIN TRANSACTION READ ONLY e sempre faz ROLLBACK, o que é uma defesa real e que este servidor agora também adota.

O problema é que é a única defesa, e não é completa:

  • Uma transação somente leitura não bloqueia toda escrita, e um rollback não desfaz tudo o que ela deixa passar. Dois fatos separados, e o segundo é o que importa.

    gin_clean_pending_list() roda dentro de SET TRANSACTION READ ONLY e seu trabalho sobrevive ao rollback: um índice com 25 páginas pendentes tem 0 depois que a transação é revertida. pg_backup_start() coloca a sessão em estado de backup, sobrevive a DISCARD ALL e, com o fast => false padrão, espera por um checkpoint espalhado enquanto força full_page_writes a ligar, o que é um custo real em um servidor ocupado. pg_import_system_collations() também executa sem levantar SQLSTATE 25006, mas tenha cuidado com quanto peso você coloca nisso: esse É desfeito por um rollback, então contra um servidor que sempre faz rollback é uma curiosidade em vez de um bypass.

    Reproduza, mas leia as duas pré-condições primeiro, porque sem elas você verá um zero ou um erro e concluirá que inventamos isso. Todos os três precisam de superusuário ou propriedade do objeto. E a importação só restaura collations que estão faltando, então algo precisa ser removido primeiro:

    -- as superuser, and note these are three separate transactions: a statement that errors
    -- inside a block aborts the whole block, so they cannot be run as one.
    DELETE FROM pg_collation WHERE oid IN (SELECT oid FROM pg_collation ORDER BY oid DESC LIMIT 200);
    
    BEGIN READ ONLY;
      DELETE FROM pg_collation WHERE collname LIKE 'zu%';  -- ERROR: cannot execute DELETE ...
    ROLLBACK;
    
    BEGIN READ ONLY;
      SELECT pg_import_system_collations('pg_catalog');    -- 200, no error
    COMMIT;                                                -- and now the rows are there
    

    Ambos são escritas, ambos estão dentro de uma transação somente leitura, e um é recusado enquanto o outro não é. Essa assimetria é por que este servidor não trata a transação como sua única defesa. É também por que o papel importa mais do que qualquer uma dessas coisas: cada exemplo acima precisa de privilégios que um leitor de privilégio mínimo não tem, e este servidor se recusa a iniciar como ouvinte de rede quando o papel que lhe foi dado pode escrever. O que ele não pode controlar é qual string de conexão alguém cola em uma configuração de cliente, e a resposta usual é qualquer uma que eles já tinham.

  • Sem timeout de declaração, sem proteção de custo, sem limite de linhas — uma consulta pode rodar até o servidor desistir.

  • Sem autenticação, sem trilha de auditoria, sem tratamento de injeção de prompt através dos dados de linha retornados.

  • Um arquivo fonte de 143 linhas, sem manutenção desde dezembro de 2024, sem suíte de testes.

Este servidor mantém o rollback, adiciona validação de AST na frente dele e adiciona as camadas operacionais que o original nunca teve.

postgres-mcp-hardened vs o original arquivado

server-postgres arquivadopostgres-mcp-hardened
Aplicação de somente leituraBEGIN TRANSACTION READ ONLY + ROLLBACK — uma camada, e o PostgreSQL deixa algumas escritas passarem por elaValidação de AST (sqlparser) mais a mesma transação somente leitura e rollback, mais uma denylist para funções que escrevem apesar disso
Multi-declaração / DROP via CTEalcança o banco de dados e é parado apenas pela transaçãorejeitado pelo parser, antes de alcançar o banco de dados
Timeout de declaraçãonenhumstatement_timeout + idle_in_transaction_session_timeout aplicados
Consultas descontroladas / carasrodam sem limiteproteção de custo EXPLAIN as rejeita antes da execução
Injeção de prompt via dados de linhasaída brutatrusted="false" envolvido + escape de delimitador
Mensagens de errovazam schema (relation X does not exist)estruturadas, sem vazamento, acionáveis
AutenticaçãonenhumaOAuth 2.1 (RS256 JWT, escopo + audiência + emissor)
Auditorianenhumalog encadeado por hash à prova de adulteração
Schema como recursos MCP✅✅ — mais comentários, chaves primárias e estrangeiras
Testes / CInenhumsuítes unitárias e de ponta a ponta contra PostgreSQL ao vivo, um harness de fuzzing determinístico, conformidade dirigida pelo SDK oficial do MCP, clippy + cargo audit + build de contêiner em cada push
Transportestdio / SSE descontinuadoStreamable HTTP + stdio
Mantido❌ descontinuado desde 2024✅

Instalação

Cinco formas de entrar, na ordem que a maioria das pessoas quer.

Um clique, para um cliente que aceita bundles .mcpb: baixe postgres-mcp-hardened-<your-platform>.mcpb do último release e abra-o. O bundle pede a string de conexão e a armazena no keychain do SO em vez de em um arquivo de configuração em texto plano. Nada para instalar, nada para editar.

Através do npm — o mais curto, e aquele para o qual a configuração do seu cliente MCP pode apontar diretamente. Não há runtime Node envolvido em tempo de execução: o pacote é um launcher que busca o binário nativo para a sua plataforma e verifica seu checksum antes de executá-lo.

{
  "mcpServers": {
    "postgres": {
      "command": "npx",
      "args": ["-y", "postgres-mcp-hardened", "--stdio"],
      "env": { "DATABASE_URL": "postgres://readonly_user:PASSWORD@localhost:5432/mydb" }
    }
  }
}

A string de conexão vai em env, não em args, de propósito: argumentos aparecem na saída de ps e no histórico do shell em uma máquina compartilhada, e uma senha de banco de dados não pertence aí.

Um binário da página de releases — um arquivo, nada para manter atualizado, e a opção a tomar se sua máquina não tem Node nenhum. (Não é um binário estático, como esta página afirmou até 0.1.7: os alvos -gnu e macOS linkam a biblioteca C do sistema como qualquer outro programa nativo. Simplesmente não há nada para instalar ao lado dele.) Cada release carrega builds para Linux, macOS e Windows em x86-64 e arm64, cada um com um checksum e uma assinatura; verificá-los é a próxima seção.

Como contêiner, se é assim que você roda as coisas. A imagem é distroless e roda como um usuário não-root, e as mesmas assinaturas o cobrem assim como cobrem os binários.

docker run --rm -p 127.0.0.1:8080:8080 --memory=512m \
  -e DATABASE_URL="postgres://readonly_user:PASSWORD@db-host:5432/mydb" \
  -e MCP_ADDR=0.0.0.0:8080 \
  -e MCP_BEARER_TOKEN="$(openssl rand -hex 32)" \
  ghcr.io/eszetael/postgres-mcp-hardened:latest

--memory não é decoração. O servidor fica ocioso em 7,7 MB e uma requisição normal custa megabytes de um dígito, mas um chamador pode escrever SELECT repeat('x', 100000000) e levar a memória de pico a 400 MB — não através do resultado, que permanece limitado a 300 bytes, mas através do próprio EXPLAIN da proteção de custo, que o PostgreSQL preenche com a constante que dobrou durante o planejamento. Isso é um risco residual nomeado em THREAT_MODEL.md, com os três reparos que foram tentados e o que cada um quebrou. Até que seja fechado, o limite de memória é a coisa que segura, então defina um: --memory aqui, MemoryMax= sob systemd.

MCP_ADDR deve vincular 0.0.0.0 e não 127.0.0.1, ou o servidor escuta em uma interface que só existe dentro do contêiner e a porta publicada não responde nada. A outra fácil: localhost em DATABASE_URL significa o contêiner, não sua máquina, então um PostgreSQL rodando no host precisa de host.docker.internal (Docker Desktop) ou o endereço do host na bridge (172.17.0.1 por padrão no Linux). Ambos foram percorridos de ponta a ponta contra a imagem publicada antes de serem escritos aqui, incluindo que uma leitura retorna linhas e DROP TABLE volta como -32602 non-read-only statement: Drop.

A partir do código-fonte — cargo build --release em um clone. Não cargo install: este crate não está no crates.io, e uma instrução que falha é pior do que uma que está faltando.

Verificando o que você baixou

Todo binário publicado é assinado com assinatura sem chave do Sigstore — não há chave privada para perdermos, e o certificado nomeia o workflow, o repositório e a tag que produziram o arquivo. Cada artefato acompanha um .sig e um .pem ao lado:

F=postgres-mcp-hardened-x86_64-unknown-linux-gnu.tar.gz
cosign verify-blob "$F" --bundle "$F.bundle" \
  --certificate-identity-regexp '^https://github.com/Eszetael/postgres-mcp-hardened/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Fixe a identidade, não apenas a assinatura. Sem --certificate-identity-regexp e --certificate-oidc-issuer, a verificação responde "alguém assinou isto", que não é a pergunta. Um certificado verificado nomeia o workflow, o repositório e a tag que compilaram o arquivo — você pode lê-lo com base64 -d "$F.pem" | openssl x509 -noout -text (o cosign grava o certificado codificado em base64, o que surpreende quem tenta openssl diretamente nele).

Versões antigas do cosign são anteriores ao --bundle; arquivos separados .sig e .pem são publicados junto para elas, usados como --signature "$F.sig" --certificate "$F.pem". O cosign atual marca esses flags como obsoletos, então prefira o bundle.

Lançamentos públicos também carregam proveniência de build SLSA, verificável com gh attestation verify <file> --repo Eszetael/postgres-mcp-hardened.

Use no Claude Desktop / Cursor (stdio)

{
  "mcpServers": {
    "postgres": {
      "command": "postgres-mcp-hardened",
      "args": ["--stdio"],
      "env": { "DATABASE_URL": "postgres://readonly_user:YOUR_PASSWORD@localhost:5432/mydb" }
    }
  }
}

Ou execute como servidor remoto (Streamable HTTP)

DATABASE_URL="postgres://readonly_user:YOUR_PASSWORD@host:5432/mydb" \
MCP_ADDR="0.0.0.0:8080" \
postgres-mcp-hardened
# POST /mcp   ·   GET /health   ·   GET /ready   ·   GET /metrics

TLS: conexões ao PostgreSQL são criptografadas sempre que o servidor suporta, e sslmode=require, verify-ca e verify-full são todos aceitos (a cadeia de certificados e o hostname são sempre verificados, então require se comporta como verify-full) — portanto, Postgres gerenciado (RDS, Supabase, Neon, Render) funciona de imediato. Certificados e nomes de host são sempre verificados — verificados (aceitação: "um certificado nomeando outro host é recusado, pelo nome"); para uma CA privada, aponte MCP_SSLROOTCERT para o bundle PEM. Não existe opção de "confiar em qualquer coisa".

Dica: aponte DATABASE_URL para um papel somente leitura com privilégio mínimo. O servidor impõe somente leitura por conta própria, mas um papel de banco de dados escopado é defesa em profundidade.

Ou execute em uma plataforma de contêineres (Apify Standby)

O servidor não precisa de mudanças de código para rodar como um Apify Actor em modo Standby. Ele lê a porta que a plataforma atribui de ACTOR_WEB_SERVER_PORT e vincula 0.0.0.0 lá — essa porta vence sobre MCP_ADDR, de forma ruidosa, no stderr, porque vincular em qualquer outro lugar significa que a execução nunca é marcada como pronta e a falha parece um timeout misterioso. GET / responde à sonda de prontidão da plataforma (x-apify-container-server-readiness-probe) sem tocar no banco de dados: prontidão de contêiner não é prontidão de banco de dados, e uma sonda que espera em um pool ocupado transforma um banco lento em um contêiner que nunca inicia.

EndpointMétodoFinalidade
/mcpPOSTo endpoint MCP (Streamable HTTP). DELETE encerra uma sessão.
/GETsonda de prontidão; caso contrário, uma placa nomeando o endpoint real
/healthGETo processo está vivo
/readyGETo processo e uma conexão com o banco estão disponíveis
/metricsGETcontadores (requer MCP_METRICS_TOKEN)
/.well-known/mcp/server-card.jsonGETo que um registro lê: revisões, transportes, ferramentas

Entrada é uma solicitação JSON-RPC no corpo do POST — initialize, tools/list, tools/call, resources/list, resources/read, server/discover. Saída é uma resposta JSON-RPC; de 2025-11-25, uma declaração recusada retorna como erro de execução de ferramenta (isError: true) com o motivo no conteúdo, para que o modelo possa reescrever a consulta. tools/list é a descrição autoritativa de cada argumento.

A autenticação lá é da plataforma, não nossa. O Apify verifica o token do chamador antes de rotear para o contêiner, então o servidor não exige adicionalmente MCP_BEARER_TOKEN — exigir um segundo segredo significaria que um agente que encontra este servidor não pode chamá-lo. Essa isenção é estreita: requer ambos APIFY_IS_AT_HOME e ACTOR_WEB_SERVER_PORT; apenas um sozinho não muda nada, e o cartão do servidor então reporta "type": "apify-platform" em vez de reivindicar um bloqueio que não seguramos. Em qualquer outro lugar, o servidor ainda se recusa a iniciar em um endereço de rede sem autenticação. Defina MCP_BEARER_TOKEN também se quiser um segundo bloqueio na mesma porta.

O outro portão de início é inalterado e importa mais aqui: um papel que pode escrever tem um listener de rede recusado. Aponte DATABASE_URL para um papel somente leitura — --print-setup-sql escreve as declarações.

Migrando do servidor obsoleto

Os problemas mais discutidos relatados contra @modelcontextprotocol/server-postgres foram reproduzidos contra este servidor; aqui está como cada um se comporta:

O que as pessoas relataramAqui
Duas instâncias (prod + dev) são indistinguíveis, o cliente escolhe umaURIs de recursos carregam o nome do banco (postgres:///mydb/public/orders/schema) e MCP_SERVER_LABEL nomeia a instância na interface do cliente
Um banco por instância, porque a string de conexão é um argumento de linha de comandoMCP_DATABASE_URLS="prod=…;dev=…" atende vários bancos a partir de um servidor; toda ferramenta aceita um database opcional, e os recursos abrangem todos eles
no pg_hba.conf entry … SSL offO erro diz que o servidor exige TLS e nomeia a correção (?sslmode=require)
Somente leitura contornado ao injetar COMMIT / ENDRejeitado — o portão de múltiplas declarações funciona em tokens, antes do parser, e COMMIT sozinho é recusado como escrita
spawn npx ENOENT, problemas de versão do NodeUm único binário nativo; sem Node, sem npx, sem node_modules
Trava indefinidamente contra RDS sem saída ou erroLimitado: um host inacessível responde em ~8 s com o motivo, nunca silenciosamente
self-signed certificate in certificate chainAponte MCP_SSLROOTCERT para o bundle de CA; o erro nomeia essa variável
String de conexão apenas como argumento de linha de comandoDATABASE_URL ou o argumento posicional — a invocação original continua funcionando
INVALID_URL com caracteres especiais na senhaO erro diz quais caracteres percentual-codificar, e como
Filhos de partição inundam as listas de tabelas e recursosOcultos por padrão; MCP_SHOW_PARTITIONS=1 os traz de volta
-32601 Method not found, Unexpected end of JSON inputping e recursos implementados; JSON multilinha é armazenado em buffer até completar; lotes são recusados com um erro claro em vez de silêncio
Sem limite de linhas — uma consulta inunda o contextoAuto-LIMIT, um limite de 8 MB de bytes e um flag explícito truncated

Testes

Além dos testes unitários, o repositório carrega dois harnesses que rodam no CI a cada mudança:

  • --fuzz — um fuzzer determinístico que muta um corpus de escritas conhecidas com transformações que não mudam o significado do SQL (comentários, maiúsculas/minúsculas, aspas com dólar, Unicode invisível, parênteses) e afirma que nenhuma delas jamais se torna uma declaração permitida.
  • tests/acceptance.sh — uma suíte ponta a ponta que inicia seu próprio PostgreSQL e verifica 317 comportamentos: todo bypass de escrita relatado contra o servidor obsoleto (incluindo a injeção COMMIT/END), resultados verdadeiros, introspecção de esquema, conformidade de protocolo, erros de configuração falhando ruidosamente, detecção de adulteração de auditoria, uso justo sob carga e implantações multi-banco.

Cada problema relatado, respondido

docs/COMMUNITY_ISSUES.md é o registro completo: cada problema relatado contra o servidor obsoleto e cada issue aberto contra as alternativas mantidas, cada um com o que acontece aqui — incluindo os poucos que não pudemos corrigir em código, ditos claramente.

O que aprendemos com as alternativas

Todo servidor neste espaço tem um rastreador de issues, e esses rastreadores são um mapa do que dá errado. Aqueles contra os quais construímos deliberadamente:

  • Uma imagem publicada que fica atrás do código. A reclamação aberta mais apoiada contra a alternativa líder. Nosso contêiner é construído e enviado a partir da mesma tag que produz os binários, então não pode divergir.
  • Um timeout de consulta fixo. Também entre as configurações mais solicitadas deles. MCP_STATEMENT_TIMEOUT é configurável e validado na inicialização.
  • Acesso irrestrito por padrão. Alguns servidores padronizam leitura/escrita e dependem do operador para restringir. Este não tem nenhum caminho de escrita.
  • Credenciais na configuração do cliente. MCP_PASSWORD_FILE mantém a senha fora dela.
  • Tabelas em um esquema não padrão silenciosamente não encontradas. MCP_SEARCH_PATH corrige a busca, e as ferramentas aceitam um schema explícito de qualquer forma.
  • Transporte obsoleto. HTTP+SSE foi substituído por Streamable HTTP em 2025-03-26, três revisões atrás (esta página dizia 2025-06-18 até 0.1.7, o que estava errado por uma revisão; o changelog da própria especificação para 2025-03-26 registra a substituição). Falamos o transporte atual.

Solução de problemas

Respostas às perguntas que as pessoas realmente fizeram sobre o servidor obsoleto, para que ninguém precise abrir um issue para encontrá-las.

spawn npx ENOENT / "qual versão do Node eu preciso?" — nenhuma. Este é um único binário nativo, sem runtime para instalar ao lado. Baixe-o da página de lançamentos e aponte seu cliente para o arquivo. Não há node_modules, não há npx, nada para manter atualizado.

"O servidor inicia, mas nada está escutando em uma porta." — isso é modo stdio, que é correto para Claude Desktop e Cursor: o cliente fala com o processo pela entrada e saída padrão, não por um socket. Se quiser um endpoint de rede, inicie sem --stdio; ele então imprime MCP HTTP listening on http://… e fala Streamable HTTP.

"Meu cliente em outra máquina pode alcançar o banco?" — sim: execute o servidor ao lado do banco em modo HTTP, exponha-o e habilite OAuth (JWT_PUBKEY_PEM, JWT_AUD, JWT_ISS). As credenciais do banco então nunca saem do host onde o servidor roda.

"Não foi possível anexar ao servidor MCP." — o processo saiu antes do handshake. Execute o mesmo comando em um terminal: um erro de configuração imprime o motivo e sai com status 2 em vez de morrer silenciosamente, e um problema de conexão é relatado na primeira consulta com a causa.

self-signed certificate in certificate chain / unable to verify the first certificate — seu provedor usa uma CA privada (Supabase, GCP e RDS todos usam). Baixe o bundle de CA deles e defina MCP_SSLROOTCERT para ele. A mensagem de erro nomeia o passo para seu provedor. Não oferecemos uma opção "confiar em qualquer coisa".

Provedores gerenciados

Este servidor sempre verifica o certificado do banco, inclusive com sslmode=require. Isso é um desvio deliberado do libpq, onde require criptografa sem verificar e uma máquina no meio pode, portanto, ler e reescrever cada consulta e resultado sem ninguém notar. O custo de ser rigoroso é que um provedor com CA privada precisa de um passo extra; o custo de ser frouxo é que você nunca descobre. Se você discorda da troca, verify-full com o bundle abaixo é a mesma quantidade de trabalho e não deixa dúvida de qualquer forma.

ProvedorO que esperar
SupabaseCA privada. Painel → Configurações do Projeto → Banco de Dados → configuração SSL → baixe o certificado, então defina MCP_SSLROOTCERT para ele. O host direto (db.<ref>.supabase.co) é somente IPv6 — em uma rede IPv4, use a string do pooler Supavisor (porta 6543), que também se ajusta a conexões serverless e de curta duração.
Amazon RDS / AuroraCA privada: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem. Autenticação IAM funciona — coloque o token gerado no campo de senha e lembre-se de que expira em 15 minutos.
Google Cloud SQLCA privada: Conexões → Segurança → server-ca.pem. Através do Cloud SQL Auth Proxy, conecte-se ao proxy em localhost e o TLS é assunto do proxy.
Azure Database for PostgreSQLCA pública — nada para baixar. O Azure rotacionou sua raiz para DigiCert Global Root G2 durante o Q1 de 2026; enviamos o armazenamento de raízes Mozilla, então a rotação não exige nada de você.
NeonCA pública (ISRG Root X1, Let's Encrypt) — nada para baixar. Endpoints pooled e diretos funcionam ambos.
DigitalOceanCA privada: baixe o certificado da página Overview do cluster.
Não sabe em qual caso você se encontra? Pergunte ao próprio servidor, antes de configurar qualquer coisa:
echo | openssl s_client -starttls postgres -connect YOUR_HOST:5432 2>/dev/null \
  | openssl x509 -noout -issuer

Um emissor bem conhecido (DigiCert, ISRG, Google Trust Services) significa que funcionará sem problemas; qualquer coisa que nomeie seu provedor significa que você precisa do pacote deles.

Com um pooler de conexões (Supavisor, PgBouncer) em modo de transação, observe que este servidor define statement_timeout e idle_in_transaction_session_timeout por sessão e executa cada consulta em uma transação explícita somente leitura. Ambos são compatíveis com o pooling de transações; SET em nível de sessão fora de uma transação não é, e é por isso que não fazemos nenhum dos dois.

no pg_hba.conf entry … no encryption — o servidor aceita apenas conexões TLS para esse host e usuário. Adicione ?sslmode=require à string de conexão.

INVALID_URL / invalid connection string — uma senha contendo @, :, /, # ou ? deve ser codificada em percentual (@ → %40, : → %3A, / → %2F, # → %23).

"Minha tabela tem centenas de partições e a lista é inutilizável." — os filhos das partições ficam ocultos por padrão; o pai é listado. Defina MCP_SHOW_PARTITIONS=1 se precisar deles.

"Preciso de produção e staging ao mesmo tempo." — ou execute um servidor por banco de dados (eles são distinguíveis: defina MCP_SERVER_LABEL), ou configure ambos em um servidor com MCP_DATABASE_URLS e passe database nos argumentos da ferramenta.

Recursos

Cada tabela e visão é exposta como um recurso MCP (postgres:///<schema>/<table>/schema), para que um cliente possa navegar pelo esquema sem emitir uma consulta — a mesma capacidade que o servidor descontinuado oferecia, além de comentários de colunas, chaves primárias e chaves estrangeiras no payload.

Quando o banco de dados não respondeu, resources/list retorna uma lista vazia com o motivo em _meta em vez de um erro de protocolo. Um catálogo que inspeciona este servidor o inicia sem banco de dados algum e chama resources/list logo após initialize; responder a isso com um erro parece um servidor que não funciona. A lista vazia não é uma afirmação de que não há tabelas — initialize diz que o banco de dados não respondeu, security_posture dá o detalhe, e o motivo viaja com a própria lista. Um banco de dados que responde e recusa ainda é um erro, porque relatar "sem recursos" para um privilégio ausente é a falha silenciosa que este servidor existe para evitar. verificado (aceitação: "um host pode inspecionar o servidor sem banco de dados e com mcp-proxy na frente")

Ferramentas

  • explain_query — o plano de execução; com analyze ele executa a declaração e relata tempos reais e uso de buffers, o que é seguro aqui porque a declaração é validada como somente leitura e executa dentro de uma transação que é sempre revertida. O plano vem com um summary: qual nó gastou o tempo (tempo próprio, não inclusivo), e onde a estimativa de linhas do planejador estava mais distante da realidade — porque uma estimativa ruim geralmente é o motivo do plano ser ruim.
  • database_health — taxa de acerto de cache, conexões (este banco de dados e o cluster), a declaração mais longa em execução e a transação abandonada mais longa como números separados, backlog de vacuum, índices inválidos, sequências próximas do teto, atraso de replicação, tabelas que nunca foram analisadas (sem estatísticas de planejador — o motivo usual de um banco de dados parecer saudável e rodar devagar), e a janela que os contadores cobrem. Qualquer coisa que o papel não possa ver é declarada em vez de retornada como um zero confiante.
  • analyze_indexes — índices não utilizados, duplicatas e tabelas varridas sequencialmente com frequência suficiente para que um índice valha a pena.
  • top_queries — as declarações mais pesadas, de pg_stat_statements.
  • security_posture — o que esta implantação realmente pode fazer ao seu banco de dados, perguntado ao PostgreSQL em vez de assumido: se o papel pode escrever, ignorar a segurança em nível de linha ou acessar arquivos do servidor; se o transporte é autenticado; se a cadeia de auditoria é chaveada; se a conexão é criptografada. Retorna uma nota — a pior constatação, nunca uma média — e, para qualquer coisa errada, o comando que a corrige. O mesmo resumo chega ao modelo através de initialize, porque sob stdio ninguém vê o stderr e o agente é o único mensageiro que o operador tem.
  • query — executa uma consulta SQL somente leitura (validada, com LIMIT automático, protegida por custo). A resposta declara o que fez: returnedRows, appliedLimit, truncated, além de requestedLimit quando uma solicitação maior foi limitada ao máximo de 10000 linhas, offset ao paginar e redactedColumns quando a mascaramento está configurado — para que um agente nunca precise adivinhar se recebeu a resposta completa.
  • list_schemas, list_tables, describe_table — descoberta progressiva de esquema (parametrizada, segura contra injeção). describe_table retorna os comentários de esquema (COMMENT ON TABLE/COLUMN), chaves primárias, chaves estrangeiras e padrões, para que o agente leia o que uma coluna significa e para onde aponta em vez de adivinhar pelo nome — e uma tabela ausente é um erro, não uma lista de colunas vazia.

Revisões de protocolo

O servidor responde ao initialize com a revisão que o cliente pediu quando a implementa, e com a mais nova caso contrário. Via HTTP, a revisão vem do cabeçalho MCP-Protocol-Version, por solicitação — a negociação de um cliente não pode mudar o contrato sob o qual outro cliente é atendido.

Se uma solicitação não trouxer cabeçalho, o servidor não cai direto no contrato mais antigo. Ele lê a revisão que esta sessão concordou em initialize, que é o que a especificação de transporte pede: o padrão se aplica apenas "se o servidor não receber um cabeçalho MCP-Protocol-Version, e não tiver outra forma de identificar a versão — por exemplo, contando com a versão de protocolo negociada durante a inicialização". Uma sessão é essa outra forma, então um cliente que negociou 2025-11-25 e depois omitiu o cabeçalho mantém o contrato que concordou em vez de ser rebaixado silenciosamente.

Um cabeçalho que não conseguimos analisar é diferente de um cabeçalho ausente, e a especificação é explícita sobre isso: "Se o servidor receber uma solicitação com um MCP-Protocol-Version inválido ou não suportado, ele DEVE responder com 400 Bad Request." Uma versão que não implementamos — 2025-03-26 ou not-a-date — é recusada com 400 e a lista de revisões que falamos, em vez de ser atendida sob um contrato que o cliente nunca concordou. Recuar é para silêncio, não para discordância.

Apenas uma solicitação sem cabeçalho e sem sessão recua, e recua para 2025-06-18 — a revisão mais antiga que este servidor implementa — em vez do 2025-03-26 que a especificação nomeia. Essa revisão não é implementada aqui, e responder sob um contrato que o servidor não pode honrar seria pior do que responder sob a mais antiga que ele pode.

A diferença que importa é para onde vai uma recusa. Sob 2025-06-18 "esta declaração não é somente leitura" era um erro JSON-RPC: o cliente via uma chamada quebrada e o modelo muitas vezes nunca via o motivo. A partir de 2025-11-25 (SEP-1303) isso chega como um erro de execução de ferramenta — isError: true com o motivo no conteúdo — então o modelo reescreve a consulta em vez de entregar uma falha ao usuário. O que não muda é a auditoria: a recusa é registrada pelo código que recusa, e a suíte de aceitação afirma ambas as metades juntas, então erros mais amigáveis nunca podem significar silenciosamente um log mais quieto.

Mcp-Method e Mcp-Name são mantidos em concordância, não presença. O rascunho os exige; revisões anteriores não, e exigi-los quebraria todo cliente em uso hoje. Mas um gateway que roteia ou autoriza com base em Mcp-Method enquanto o servidor executa o corpo decidiu sobre uma solicitação diferente da que executa — e isso é verdade sob qualquer revisão em vigor. Então um cabeçalho presente deve corresponder ao corpo sob toda revisão, enquanto um cliente que não envia nenhum fica intocado.

Falhas de protocolo permanecem falhas de protocolo. Um envelope malformado, um método desconhecido ou um token ausente não é algo que um modelo possa corrigir reescrevendo SQL, e o tratamento de erros de um cliente espera esses onde sempre estiveram.

A próxima revisão, antes de chegar

2026-07-28 é a maior quebra que o MCP já teve: sem initialize, sem cabeçalho de sessão, sem ping. Esse identificador vem de LATEST_PROTOCOL_VERSION no esquema do rascunho, e não é uma data de lançamento — o MCP nomeia uma revisão pela última data em que uma mudança incompatível com versões anteriores foi feita, então descreve a história do rascunho em vez de um cronograma. Cada solicitação carrega sua própria versão de protocolo em _meta, e um novo server/discover substitui o handshake. Implementamos cedo atrás de um interruptor, porque um rascunho se move e um servidor anunciando suporte a um alvo em movimento estará errado em público. O upstream cortou schema/2026-07-28 em 2026-08-03 — o esquema lançado difere do rascunho que verificamos em quatro URLs de documentação e nada mais — então o interruptor se foi e é isso que o servidor fala por padrão. Clientes em 2025-11-25 e 2025-06-18 são respondidos como antes.

server/discover responde sob toda revisão, porque a especificação espera que clientes o usem como uma sonda de compatibilidade reversa — o que só funciona se servidores mais antigos responderem. O nosso responde com as revisões que falamos e, em _meta, a postura de segurança completa. Isso é deliberado: um cliente pode saber que está falando com um servidor conectado como superusuário antes de enviar uma consulta, como dados estruturados em vez de prosa que um modelo precisa notar.

Duas das regras do rascunho são controles de segurança aqui, não formalidades. Mcp-Method e Mcp-Name devem concordar com o corpo da solicitação, e recusamos a incompatibilidade (-32020) — os cabeçalhos existem para que um gateway possa rotear e autorizar sem analisar o corpo, e se cabeçalho e corpo podem discordar, então a coisa que autorizou e a coisa que executa viram duas solicitações diferentes. E um cliente declarando uma versão que não implementamos é informado disso (-32022) em vez de ser atendido silenciosamente sob um contrato que nunca concordou.

O que a segurança custa

Medido, não afirmado: tests/bench/, contra o driver pg executando a mesma consulta na mesma máquina (PostgreSQL 18.6 em Docker, tabela de 50 mil linhas, 300 solicitações sequenciais, limite de taxa desligado). Remedido em 2026-08-17 em um VPS compartilhado com carga média de 2.6 — mediana de duas execuções:

consultadrivereste servidordiferença
busca por ponto0.43 ms5.9 ms+5.5 ms
varredura pequena1.0 ms8.3 ms+7.3 ms
agregação4.7 ms11.3 ms+6.6 ms

Uma tabela anterior aqui dizia +3.6/+5.2/+3.7 e "cerca de 4 ms". Esses números vieram de uma máquina mais silenciosa, e o piso do driver mudou com eles — 0.28 ms contra os 0.43 de hoje para a mesma busca — então era o hardware falando, não o código. Duas coisas foram verificadas antes de mudar o número, porque o suspeito óbvio era nossa própria compilação: o binário 0.1.6, construído antes de lto ser ativado, mede +5.4/+7.8/+5.9 nesta máquina dentro da mesma hora. Idêntico. O perfil de lançamento reduziu o binário pela metade e não custou nada aqui.

Espere 5 a 8 ms por consulta, e trate qualquer número único nesta página como uma leitura de uma máquina em um dia. A forma importa mais que o tamanho: a sobrecarga é quase constante. Se a validação de AST fosse o custo, ela cresceria com a consulta. Não cresce. O tempo vai em idas e voltas — a sessão é redefinida, os timeouts e o sinalizador de somente leitura são definidos, uma transação somente leitura é aberta, o guarda de custo planeja a declaração, então a consulta executa e a transação é revertida. Cinco ou seis trocas onde o driver tem uma. Isso é uma troca deliberada, e você pode ver exatamente o que ela compra. Para um agente fazendo dezenas de chamadas, é invisível; se você está colocando isso diante de um caminho de serviço crítico para latência, você está usando a ferramenta errada, e ela não é essa.

Sob concorrência, o número interessante não é a taxa de transferência, mas o que acontece além dos limites: com 8 clientes concorrentes, ele atendeu 373 requisições/segundo e recusou mais 192 com "too many requests in flight", que é o limite de requisições em voo fazendo seu trabalho, em vez de uma fila crescendo até algo cair.

Este índice ajudaria? — respondido sem criar um

A única capacidade pela qual a alternativa líder é genuinamente conhecida é o ajuste de índices: ela pode dizer se um índice valeria a pena antes de você construí-lo. Ela chega lá por padrão a uma conexão que pode criar índices reais — seguro apenas se você lembrou de restringi-la.

simulate_index responde à mesma pergunta a partir de uma conexão que não pode escrever nada. hypopg registra um índice hipotético na memória do backend: o planejador o vê, o armazenamento nunca o vê, e ele desaparece quando a chamada retorna. Você obtém o plano e o custo com e sem ele, e — separadamente — se o planejador realmente o utilizou, porque um custo que mal se move e um índice que o planejador ignorou são respostas diferentes.

A ferramenta recebe uma tabela e uma lista de colunas. Não é uma instrução CREATE INDEX. A definição é montada no lado do servidor a partir de identificadores que o catálogo confirmou existirem, citados pelo próprio PostgreSQL, então não há caminho de um argumento de ferramenta para DDL arbitrário — um nome de coluna carregando SQL morre na consulta ao catálogo, e há um teste que dispara exatamente isso. Os números são estimativas do planejador: trate uma grande melhoria como um motivo para testar o índice, não como prova.

A conformidade é verificada pelo cliente de outra pessoa

Cada outro teste aqui é nosso harness falando com nosso servidor. Se interpretarmos mal a especificação, nós a interpretamos mal da mesma forma em ambas as metades e tudo passa. Então a CI também dirige o servidor com o SDK MCP oficial — a biblioteca cliente que o ecossistema usa — via stdio e Streamable HTTP: handshake, listagem de ferramentas e esquemas, uma leitura, uma escrita recusada chegando como um erro de execução de ferramenta em vez de um erro de protocolo, listagem e leitura de recursos. Um erro de protocolo aparece como um cliente que não consegue falar conosco. tests/conformance/.

Trabalhando nisso

git config core.hooksPath .githooks   # once, per clone

.githooks/pre-push executa format, clippy, os testes unitários e as verificações de reivindicações de documentação antes que qualquer coisa saia da sua máquina. Ele existe por causa de um erro específico: um commit saiu com um lint de clippy falhando, e a primeira vez que alguém soube foi um e-mail de falha. Observe que cargo test passa nesse código — lints de clippy não são erros de compilador — então "compila localmente" não é a mesma resposta que "a CI ficará verde".

Ele deliberadamente pula a suíte de aceitação e a matriz PostgreSQL: elas precisam de Docker e cerca de vinte minutos, e um hook que as pessoas não podem pagar para executar é um hook que as pessoas ignoram. A CI executa tudo. git push --no-verify quando você quiser ver algo falhar na CI de propósito.

Configurando o papel

DATABASE_URL=postgres://admin@host/mydb postgres-mcp-hardened \
  --print-setup-sql --role mcp_reader --schemas public --redact ssn,email > setup.sql
# read it, then:
psql -v pw="$(openssl rand -base64 24)" -f setup.sql mydb

Execute com uma string de conexão e as listas de tabelas e colunas vêm do catálogo; sem uma, você obtém o mesmo documento com placeholders. A diferença importa mais para a redação: as colunas a conceder de volta têm que ser lidas do banco de dados, porque escrevê-las de memória é como uma coluna destinada a permanecer oculta é devolvida.

A saída termina com verificações que retornam nenhuma linha quando funcionou, e um lembrete de que o próprio servidor dirá a você o que o papel pode fazer no momento em que você o apontar para o banco de dados.

Limitando o que o servidor pode alcançar

MCP_ALLOW_SCHEMAS e MCP_ALLOW_TABLES restringem quais relações uma consulta pode tocar. Qualquer um deles ativa a lista de permissões; schema.* e schema.table ambos funcionam.

MCP_ALLOW_TABLES='public.customers,public.orders,analytics.*'

A verificação lê o plano de consulta, não o SQL. Esse é todo o design: o planejador já aplicou search_path, resolveu cada alias, expandiu visões para tabelas base, e sabe que um CTE chamado customers não é a tabela customers — então WITH customers AS (SELECT 1) SELECT * FROM customers runs and touches nothing, while WITH x AS (SELECT * FROM salaries) SELECT * FROM x é recusado. Ler a instrução em vez disso é o que perdeu três rodadas de revisão adversarial.

Duas consequências que valem a pena saber antes de ativar:

  • Uma partição viaja com seu pai. Você permite events; o PostgreSQL decide quais filhos ler.
  • Uma visão precisa que suas tabelas base também sejam permitidas, porque o plano nomeia essas. Permita ambas, e deixe as permissões do banco de dados manterem a tabela base inacessível diretamente — essa é a fronteira em qualquer caso.

pg_catalog e information_schema estão fora da superfície a menos que MCP_ALLOW_CATALOG=1: um agente que ainda pode ler o catálogo pode enumerar exatamente o que a lista de permissões pretendia esconder. As ferramentas de esquema continuam funcionando, porque executam consultas fixas em vez de SQL do chamador.

Isso cobre três rotas para os mesmos fatos, porque por um tempo cobriu apenas uma. Uma visão de catálogo planeja varreduras sobre relações reais e o plano as nomeia — mas pg_settings planeja para um único Function Scan em pg_show_all_settings, não nomeando nenhuma relação, e current_setting() é uma chamada escalar que nunca aparece como uma varredura. Ambos costumavam retornar a configuração do servidor sob uma lista de permissões ativa. Funções cujo nome começa com pg_, mais current_setting, inet_server_addr e inet_server_port, agora são recusadas junto com as relações de catálogo, e MCP_ALLOW_CATALOG=1 abre todas elas juntas. Funções comuns que retornam conjuntos — generate_series, jsonb_each, unnest, regexp_split_to_table — não carregam tal prefixo e não são afetadas. current_user, session_user, current_database e version permanecem legíveis de propósito: um agente já sabe a que se conectou e como quem.

O corpus de coisas que passaram

Cada forma que derrotou um controle durante a revisão vive em tests/adversarial/, com a rodada em que parou de funcionar. Ele roda em cada build, e — porque os casos são escritos contra placeholders em vez de nosso fixture — você pode apontá-lo para seu próprio banco de dados:

ADV_URL='postgres://…' \
  ADV_TABLE=people ADV_TABLE2=orders ADV_REDACT_COL=ssn \
  ./tests/adversarial/run.sh

ADV_TABLE precisa da coluna sensível, ADV_TABLE2 é qualquer outra tabela legível, ADV_REDACT_COL é a coluna a redigir. Todos os três importam: este exemplo omitiu ADV_TABLE2 até 0.1.7, então manteve seu padrão de film — uma tabela do banco de dados de amostra Pagila — e seguir a instrução exatamente produziu três "incompatibilidades" que eram apenas uma relação ausente. O harness agora verifica os três antecipadamente e diz qual está errado, porque um corpus de segurança que relata um erro de digitação como um controle falho ensina você a ignorar as falhas que importam.

Uma reivindicação de segurança que você só pode verificar lendo nosso código-fonte é uma reivindicação que você tem que aceitar por confiança, e a própria história deste projeto é o argumento contra isso.

Modelo de segurança

A declaração completa do que este servidor garante, o que não garante, e qual controle aplica qual promessa está em THREAT_MODEL.md — incluindo os controles que foram derrotados na revisão e, portanto, são descritos como profundidade em vez de fronteiras.

  • Transporte criptografado: TLS para PostgreSQL via rustls (sem OpenSSL na imagem), verificação de certificado sempre ativa, CAs privadas via MCP_SSLROOTCERT.

  • Somente leitura, de duas maneiras: cada instrução é analisada com sqlparser e rejeitada a menos que seja um SELECT/WITH/EXPLAIN/SHOW; a sessão do banco de dados também é definida como default_transaction_read_only = on.

  • Anti-DoS: statement_timeout aplicado, LIMIT injetado automaticamente, e um guarda de custo baseado em EXPLAIN que rejeita planos caros antes de executá-los.

  • Colunas sensíveis — defesa em profundidade, e honesta sobre isso: MCP_REDACT_COLUMNS mascara valores em cada profundidade e recusa executar uma consulta que referencie essas colunas, incluindo as maneiras de contorná-lo que um painel adversarial realmente encontrou — renomear (SELECT password AS pw), envolver (md5(password)), serializar a linha inteira (row_to_json(t), t::text, json_agg(t)), e nomear a coluna como uma string em vez de um identificador (to_jsonb(t) ->> 'password', #>> '{password}', $.password), curingas de linha inteira (ROW(t.*)::text) e renomeação posicional ((SELECT * FROM staff) AS x(c1, …, c9)). Ainda é filtragem baseada em nome, e filtragem baseada em nome não pode ser uma fronteira contra toda a linguagem SQL — quatro rodadas adversariais cada uma passou por ela através de uma forma que ninguém tinha listado.

    A quarta, em 0.1.7, não encontrou uma nova forma de nome. Ela contornou nomes completamente: SELECT get_raw_page('people', 0) devolve 8192 bytes da tabela como o disco os mantém, e cada valor nessa página está lá, incluindo o redigido. Demonstrado, não teorizado — com MCP_REDACT_COLUMNS=ssn, SELECT ssn FROM people foi recusado enquanto a página bruta voltou com os números de seguro social em ASCII puro. pageinspect e seus parentes agora são recusados como uma categoria própria, porque "isso retorna armazenamento em vez de colunas" é um problema diferente de "isso escreve", e dizer a um operador qual deles ele atingiu vale uma mensagem separada.

    A mesma rodada encontrou a versão mais silenciosa disso. O planejador do PostgreSQL mantém uma amostra de cada valor real de coluna, e pg_stats os publica: com 3.000 linhas, SELECT * FROM pg_stats WHERE tablename='people' returned {123-45-6789,555-00-1111,987-65-4321} while SELECT ssn FROM people foi recusado. Essa consulta nunca nomeia a coluna redigida, então uma regra baseada em nome não tem nada em que agir. As colunas de estatísticas que carregam valores — most_common_vals, histogram_bounds, most_common_elems, stavalues1…stavalues5 — agora se juntam ao que você configurar, sempre que você configurar qualquer coisa. O resto da visão é intocado: n_distinct, null_frac e correlation são do que o conselho de índice abaixo é construído e eles não carregam valores, então remover a relação inteira teria quebrado dez colunas para corrigir quatro.

    Mais duas portas acabaram abrindo para a mesma sala. Um valor longo demais para sua linha é armazenado em uma tabela TOAST, e essa tabela é legível por nome: SELECT chunk_data FROM pg_toast.pg_toast_16384 retornou o texto redigido em claro. pg_largeobject é a mesma coisa para objetos grandes — os bytes sob lo_get. Ambos agora são recusados, como relações em vez de funções, com uma mensagem que diz por quê: eles mantêm armazenamento físico em vez de colunas. O catálogo que meramente descreve o banco de dados é intocado — pg_tables, pg_stat_activity, pg_largeobject_metadata, e as colunas de estatísticas que o conselho de índice precisa.

    Todos os quatro dizem a mesma coisa, e isso descreve o limite deste recurso melhor do que qualquer lista de correções: um filtro de coluna protege colunas, então qualquer coisa que lê abaixo das colunas está fora do que ele pode prometer. Páginas brutas, amostras do planejador, chunks TOAST e bytes de objetos grandes são quatro portas para esse espaço, todas as quatro encontradas em uma única tarde procurando pela forma em vez dos nomes. A suposição honesta é que há mais, que é por que o papel do banco de dados e a transação somente leitura são a fronteira real e isso permanece o que diz ser: defesa em profundidade. Então o servidor para de afirmar e pergunta ao banco de dados: na inicialização, ele relata cada tabela onde a função conectada ainda pode ler uma coluna com redação, com as instruções exatas que corrigem isso, e MCP_REDACT_REQUIRE_REVOKE=1 transforma esse relatório em uma recusa de execução. Observe que a correção é uma REVOKE no nível da tabela seguida por uma GRANT das colunas que permanecem — uma REVOKE SELECT (password) ON staff pura é silenciosamente uma não-operação enquanto a função mantém SELECT na tabela inteira. Com concessões no nível de coluna, o PostgreSQL então recusa SELECT * nessa tabela, então os chamadores nomeiam colunas em vez disso; describe_table as lista e marca a que tem redação.

  • Consciente de injeção de prompt: os dados das linhas são retornados dentro de um bloco de procedência trusted="false" com delimitadores escapados, para que uma célula maliciosa não possa sequestrar o agente.

  • Ele gera a função com a qual você deveria estar executando: --print-setup-sql escreve o DDL para uma função que não herda nada, não contorna nada, não cria nada, lê apenas as relações que você nomeia e — onde você nomeou colunas sensíveis — as tem revogadas na ordem que realmente funciona. Ele imprime; nunca executa. Aplicar isso exige direitos administrativos, e uma ferramenta cuja identidade inteira é "somente leitura" não tem o direito de guardar a senha de um administrador.

  • Ele não exporá uma função que possa escrever: quando o endereço de escuta é alcançável a partir da rede, o servidor pergunta ao PostgreSQL o que a função conectada realmente pode fazer — superusuário, BYPASSRLS, associação a pg_write_all_data e afins, e privilégios de escrita em uma amostra limitada de tabelas — e se recusa a iniciar se a resposta for mais do que "leitor", nomeando cada motivo e apontando para --print-setup-sql. Ele recusa um ouvinte de rede não autenticado pelo mesmo motivo. Loopback e stdio são deixados em paz: lá o chamador é o operador. As substituições (MCP_ALLOW_EXCESSIVE_ROLE, MCP_ALLOW_ANONYMOUS_NETWORK) assumem o valor literal i-accept-the-risk para que não possam ser ativadas por um erro de digitação, e são registradas no log de auditoria. Este servidor impõe somente leitura por conta própria, mas essa imposição é código, e código já esteve errado antes; uma função que não pode escrever é a parte que nenhum bug nosso pode desfazer.

  • Um navegador não pode alcançá-lo: uma requisição carregando um Origin é recusada com 403, a menos que o operador tenha listado essa origem, e em um ouvinte de loopback um Host que não seja localhost também é recusado — a forma que um ataque de rebinding de DNS assume quando mira em um servidor de banco de dados no seu laptop.

  • A auditoria conhece a configuração: a cadeia abre com um registro startup nomeando a versão, o transporte e cada configuração em vigor, com senhas de conexão removidas e segredos reduzidos a impressões digitais, além de um config_fp que um operador pode fixar entre reinicializações. Um log que diz o que aconteceu mas não sob quais configurações não pode responder à primeira pergunta que um incidente faz.

  • A auditoria percebe quando é encurtada: uma cadeia de hash prova que as entradas não foram alteradas, mas um log com a cauda cortada é internamente consistente — recalculá-lo não encontra nada de errado. Junto com MCP_AUDIT_LOG o servidor mantém portanto <log>.hwm, um registro de uma linha do último número de sequência e hash que escreveu, atualizado apenas após a entrada ser anexada de forma durável. Na inicialização, os dois são comparados, e uma divergência é relatada: entradas faltando no final, uma última entrada reescrita, ou um log que desapareceu completamente. Isso não é prova de adulteração — um desligamento sujo parece o mesmo — mas uma trilha evidente de adulteração deve a você a pergunta, não o veredito. Mantenha o sidecar com o log ao arquivar ou movê-lo; excluí-lo apenas perde a verificação de truncamento, nunca uma entrada. O verificador offline não muda e ainda precisa de uma âncora externa: --verify-audit <file> --expect-last <hash>. verificado (aceitação: "um log encurtado é notado na inicialização, sem qualquer âncora externa")

  • Uma configuração errada é fatal, não apenas errada: um endereço de escuta não analisável, um arquivo de auditoria que não pode ser gravado, sslmode=disable para um banco de dados em outra máquina, um token de métricas que também é a credencial do banco de dados, um booleano escrito como yes — cada um costumava ser aceito e silenciosamente fazer algo diferente do que foi pretendido. A inicialização agora para e nomeia a configuração.

  • Uma configuração com erro de digitação é fatal — a configuração de outra pessoa não é: MCP_REDACT_COLUMN (singular) costumava iniciar o servidor com a redação silenciosamente desativada, então um quase erro de uma configuração real ainda para a inicialização e nomeia a grafia pretendida. Um nome que não se assemelha a nada que definimos foi definido por outro programa compartilhando o ambiente: é relatado e ignorado. mcp-proxy, que todo catálogo coloca na frente de um servidor para inspecioná-lo, exporta MCP_PROXY_DEBUG — até 0.1.6 essa única variável fazia este servidor sair antes de ler uma requisição. MCP_X_* permanece reservada para uso do próprio operador. verificado (aceitação: "um erro de digitação ainda é fatal")

  • Sem vazamentos de esquema: erros de banco de dados são mapeados para mensagens estruturadas e acionáveis que nunca ecoam nomes de tabelas/colunas.

  • OAuth 2.1: validação opcional de token portador RS256 (assinatura, exp, aud, iss) com aplicação de escopo; desativada quando não configurada para uso local/auto-hospedado.

  • Auditoria: cada decisão de ferramenta é registrada como uma linha JSON encadeada por hash e à prova de adulteração (sem SQL bruto).

  • Cadeia de suprimentos: licenças de dependências, fontes e avisos aplicados no CI (cargo deny, cargo audit); um SBOM CycloneDX é anexado a cada lançamento.

  • Runtime: distribuído como um contêiner distroless, não-root — 14,8 MB para baixar, 41 MB em disco para linux/amd64 na 0.1.6, construído e testado por fumaça no CI. Ambos os números, porque um único é sempre o lisonjeiro: docker images mostra o segundo, sua largura de banda paga o primeiro.

Pegada

Medido em um VPS comum contra um banco de dados de amostra com 16 mil linhas, para que você possa verificar a afirmação "escrito em Rust" em vez de aceitá-la:

Memória residente, ocioso7,7 MB — mediana de cinco inicializações separadas, todas dentro de 0,1 MB uma da outra
Memória residente, após 200 requisições9,4 MB, e estável depois
Latência mediana de requisição~8 ms — incluindo o processo curl que a medição gera, então a parcela do servidor é menor
Início até a primeira instrução validada5 ms — mediana de cinco execuções de --validate, 5 a 7 ms observados
Binário9,2 MB (linux x86_64, 0.1.7 em diante). Nada para instalar junto — sem Node, sem Python, sem biblioteca compartilhada que enviamos. Ele não é estaticamente vinculado: como qualquer alvo -gnu, usa o libc, libm e libgcc_s do sistema.
Imagem do contêiner12,6 MB compactado, 31,8 MB descompactado (linux/amd64, 0.1.7), distroless, não-root

Duas linhas aqui estavam erradas até 0.1.7. Memória ociosa dizia 5,2 MB e mede 7,7 — cinco inicializações sob condições idênticas caíram dentro de 0,1 MB uma da outra, então o valor antigo não é ruído, é uma medição diferente cujo método não foi documentado. A linha do binário dizia "11 MB, estático", e o arquivo que as pessoas realmente baixaram era 18,9 MB e vinculado dinamicamente. O tamanho nunca foi medido em uma compilação de lançamento, porque esta crate não tinha [profile.release] nenhum, então mais de cinco megabytes de símbolos de depuração foram enviados a cada usuário. Definir strip, lto e codegen-units = 1 o reduziu para 9,2 MB. A palavra "estático" simplesmente não era verdadeira para nenhum dos cinco alvos que publicamos, nenhum dos quais é uma compilação musl. A imagem encolheu com o binário, de 14,8 MB compactado na 0.1.6 para 12,6 MB — ambos os valores lidos do manifesto do registro da imagem publicada em vez de uma compilação local, porque uma compilação local não é o que ninguém puxa.

Uma imersão de doze minutos de tráfego misto (leituras, recusas, erros, requisições abortadas, rotatividade de sessões, requisições não autenticadas) atendeu 51.499 requisições e terminou com os mesmos 15 descritores de arquivo abertos com que começou. A memória residente foi de 8,1 MB para 11,2 MB, e a forma disso é a parte interessante: 8,1 a 10,7 aconteceu dentro das primeiras 400 requisições, e as 51.000 restantes adicionaram 0,5 MB em uma curva que se achatou conforme avançava. Isso é um alocador se estabilizando, não um vazamento. Esta página costumava dizer que a memória permanecia "estável", o que era verdade para tudo após os primeiros segundos e não verdadeiro para o número, então aqui está o número.

Reproduza com tests/soak.sh em vez de acreditar no parágrafo.

Configuração

EnvFinalidade
DATABASE_URLString de conexão PostgreSQL (use um papel somente leitura)
MCP_ADDREndereço de escuta HTTP (padrão 127.0.0.1:8080)
MCP_MAX_COSTrejeitar consultas cujo custo EXPLAIN exceda este valor (padrão 1.000.000)
JWT_PUBKEY_PEM, JWT_AUD, JWT_ISShabilitar validação de token OAuth 2.1 (omitir para desabilitar autenticação); a chave pode ser o texto PEM ou um caminho para um arquivo PEM
MCP_AUDIT_LOGcaminho para o log de auditoria somente anexação (encadeado por hash); verifique com --verify-audit <file> [--expect-last <hash>]. O servidor também grava <log>.hwm ao lado dele — o último número de sequência e hash, usado na inicialização para detectar um log encurtado
MCP_AUDIT_HMAC_KEY / MCP_AUDIT_HMAC_KEY_FILEchave que transforma a cadeia de auditoria em HMAC-SHA256 — mantenha-a fora do host para que o log não possa ser reescrito (uma nova linha no final do arquivo é ignorada)
MCP_AUDIT_HMAC_KEYS_OLDchaves anteriores separadas por vírgula, para que um log que sobreviveu a uma rotação de chave ainda seja verificado. verificado (aceitação: "uma cadeia que abrange uma rotação de chave verifica com ambas as chaves")
MCP_REDACT_COLUMNScolunas a manter fora dos resultados, ex.: password, ssn, card_number — mascaradas em qualquer profundidade e recusadas se referenciadas. Defesa em profundidade, não um limite: combine com REVOKE SELECT (col)
MCP_BEARER_TOKENtoken compartilhado exigido em cada solicitação, para implantações sem um provedor de identidade. Ignorado quando OAuth está configurado — aceitá-lo como alternativa daria ao seu titular escopo total e deixaria a auditoria sem identidade
MCP_STATEMENT_TIMEOUTlimite de tempo de consulta (intervalo PostgreSQL, padrão 30s)
MCP_SEARCH_PATHesquemas a pesquisar quando um nome de tabela não é qualificado, ex.: analytics, public
MCP_PASSWORD_FILEler a senha do banco de dados de um arquivo em vez de colocá-la na string de conexão
MCP_DATABASE_URLSvários bancos de dados de um servidor: prod=postgres://…;dev=postgres://… (as ferramentas então aceitam um argumento database)
MCP_SERVER_LABELnome exibido na interface do cliente, ex.: production → postgres-mcp-hardened (production)
MCP_SHOW_PARTITIONS1 para listar também filhos de partição (ocultos por padrão)
MCP_ALLOW_FUNCTIONSfunções de catálogo separadas por vírgula a permitir que não sabemos ser somente leitura
MCP_SSLROOTCERTcaminho para um pacote de CA PEM para TLS para PostgreSQL (ex.: o pacote AWS RDS); raízes do sistema e Mozilla são confiáveis por padrão
MCP_MAX_INFLIGHT_PER_CLIENTmáximo de solicitações concorrentes de um cliente (padrão 4; 0 desabilita)
MCP_RATE_RPMlimite de taxa de solicitações por cliente (padrão 120/min; 0 desabilita)
MCP_RATE_RPM_STDIOo mesmo limite para stdio (padrão 600/min): um agente explorando um esquema faz legitimamente dezenas de chamadas por minuto, mas um loop descontrolado contra um banco de dados de produção ainda é o que um DBA mais teme
MCP_CLIENT_IDum nome para este cliente no log de auditoria via stdio, ex.: claude-desktop@ada-laptop; sem ele, a identidade recai sobre o usuário e processo do sistema operacional
MCP_RATE_BURSTmargem de rajada para esse limite (padrão MCP_RATE_RPM / 4, mínimo 5)
MCP_METRICS_TOKENtoken exigido em /metrics. Sem ele, /metrics segue o que o próprio servidor exige: aberto quando o servidor não tem autenticação, o token portador quando um está definido, e fechado quando OAuth está configurado (um JWT é a forma errada para um raspador — defina isso em vez disso)
MCP_REDACT_REQUIRE_REVOKE1 para recusar servir enquanto o banco de dados ainda permite que o papel leia uma coluna redigida — transforma a configuração acima de consultiva em garantia
MCP_STRUCTURED_CONTENT1 para também retornar MCP structuredContent; desligado por padrão porque um cliente que o ignora paga por cada resultado duas vezes. O marcador de proveniência viaja dentro do objeto, mas o escape de delimitador que protege o bloco de texto não se aplica — um cliente que cola saída estruturada diretamente em um prompt perde essa camada
MCP_RESERVED_AUTH_SLOTSslots de banco de dados mantidos para tráfego autenticado para que uma inundação anônima não possa tomar o pool (padrão: um quarto)
MCP_PUBLIC_URLURL base pública deste servidor, usada nos metadados de descoberta OAuth
MCP_AUTH_SERVERSURLs do servidor de autorização anunciadas nesses metadados
MCP_PROTOCOL_PREVIEWAposentado. Ele controlava 2026-07-28 enquanto essa revisão era um rascunho; upstream o lançou em 2026-08-03 e o servidor agora o fala por padrão. O nome permanece reconhecido para que uma linha de configuração existente não seja relatada como erro de digitação, e a inicialização diz uma vez que não faz mais nada
MCP_ALLOW_SCHEMASesquemas que uma consulta pode alcançar, ex.: public,analytics; definir este ou o próximo ativa a lista de permissões
MCP_ALLOW_TABLESrelações que uma consulta pode alcançar, ex.: public.orders,analytics.*
MCP_ALLOW_CATALOG1 para manter pg_catalog alcançável enquanto uma lista de permissões está ativa
MCP_ALLOW_PLAINTEXT_DBdefina como i-accept-the-risk para permitir sslmode=disable a um banco de dados que não está nesta máquina
MCP_ALLOW_EXCESSIVE_ROLEdefina como i-accept-the-risk para servir um ouvinte de rede com um papel que pode escrever
MCP_ALLOW_ANONYMOUS_NETWORKdefina como i-accept-the-risk para servir um ouvinte de rede sem autenticação
MCP_ALLOWED_ORIGINSorigens de navegador permitidas a chamar este servidor, ex.: https://my-client.example. Vazio significa que nenhuma página de navegador pode alcançá-lo: uma página que o usuário está apenas visitando pode fazer seu navegador POST para localhost, que é todo o truque do DNS rebinding
MCP_ALLOWED_HOSTSvalores extras de Host aceitos ao escutar em loopback (localhost e 127.0.0.1 sempre são)
MCP_FUZZ_VERBOSEsomente desenvolvimento: faz --fuzz imprimir cada mutação que tentou
MCP_TRUST_PROXYdefina como 1 somente atrás de um proxy reverso — então o limitador de taxa usa X-Forwarded-For em vez do endereço do par

Licença

MIT — veja LICENSE. O núcleo é MIT e permanece assim.

Uso comercial e em equipe

Apontar isso para um banco de dados de produção dentro de uma organização levanta questões que o núcleo MIT não responde: política vinculada a uma identidade em vez de a um escopo, um log de auditoria enviado para algum lugar onde não possa ser editado silenciosamente, evidência que um revisor de conformidade aceitará, uma implantação pela qual alguém é responsável.

Uma edição de equipe cobrindo esses pontos está sendo escopada agora, e o que entra nela não está decidido. Se isso é o que sua organização precisa, escreva para eskulapstudio@gmail.com e diga o que teria que estar nela. Ainda não há nada para comprar — as respostas são o que decide se ela será construída de todo, e em que ordem.