postgres-mcp-hardened

Reemplazo en Rust mantenido para el archivado @modelcontextprotocol/server-postgres. Las escrituras se rechazan dos veces: validación AST de sqlparser antes de la ejecución, más default_transaction_read_only a nivel de base de datos y un statement_timeout por sesión. Binario único, stdio y Streamable HTTP, inspección de esquemas, redacción de columnas, guardia de costo EXPLAIN, registro de auditoría encadenado por hash, OAuth 2.1 opcional, imagen distroless. MIT.

Documentación

postgres-mcp-hardened

🚧 Versión 0.1.10 — el resto de una revisión externa, y una corrección que fue demasiado contundente

Publicado: binarios para cinco plataformas con sumas de verificación, firmas Sigstore y procedencia de compilación; .mcpb paquetes para instalación con un clic; una imagen en ghcr.io para amd64 y arm64; un paquete en npm; y una entrada en el registro oficial de MCP.

0.1.8 y 0.1.9 cerraron seis evasiones que cuatro revisores independientes encontraron en 0.1.7, ninguna de ellas encontrada por nosotros. Dos días de nuestro propio trabajo adversarial habían vuelto mayormente limpios el día anterior. Pasar las pruebas que pensaste en escribir no es lo mismo que mirar. La que más importaba no necesita privilegios en absoluto: con una columna redactada, una unión sobre ella a través de USING respondía si un valor dado estaba presente, lo cual es un oráculo de igualdad completo contra el lector de menor privilegio que este proyecto te dice que configures.

0.1.10 cierra los tres hallazgos que quedaron abiertos. Con MCP_ALLOW_SCHEMAS=public, SELECT * FROM secret.salaries fue rechazado mientras describe_table entregaba cada columna, tipo y valor predeterminado de esa misma tabla. Una cadena de conexión ordinaria sin sslmode usaba el prefer del controlador, que envía todo en claro siempre que el servidor rechace TLS, y cualquiera en el cable puede hacer que lo rechace. Y MCP_SSLROOTCERT añadió una autoridad de certificación privada a 242 públicas en lugar de reemplazarlas, por lo que un operador que creía haber fijado la confianza a su propio emisor no lo había hecho.

La primera versión de esa corrección de TLS estaba equivocada de una manera que vale la pena leer. Preguntaba "¿es esto loopback" y exigía TLS a todo lo demás, lo que derribó seis trabajos de versiones de PostgreSQL, la ejecución de conformidad, la verificación del contenedor y el corpus adversarial en un solo push. Todos ellos llegan a la base de datos de la manera en que lo hacen los despliegues ordinarios: postgres://user:pass@postgres:5432/db, un nombre de servicio en una red privada. Docker Compose y Kubernetes no son la internet pública, y un servidor que exige TLS a un contenedor en una red puente es un servidor que la gente apaga por completo. La pregunta ahora es si alguien no confiable puede sentarse en el cable, no si la dirección es loopback, y un socket real a un PostgreSQL alcanzable solo por nombre de servicio está en la suite de pruebas, porque una regla sobre redes debería probarse a través de una red.

Un límite de recursos está documentado y no resuelto, en THREAT_MODEL.md: 49 bytes de SQL hacen que PostgreSQL pliegue una constante en 5.9 GB de memoria del backend durante el planeamiento, y un statement_timeout de cinco segundos no lo detiene. Las formas obvias son rechazadas; el problema general está aguas arriba de cualquier cosa que este servidor pueda hacer.

Cada cambio aquí fue reproducido contra un servidor en ejecución antes de ser corregido, y cada uno está en CHANGELOG.md con la consulta.

Todo aquí es 0.1.x porque nadie fuera de este proyecto lo ha ejecutado contra sus propios datos.

El servidor MCP oficial de Postgres fue desaprobado en 2024 y aún recibe 391k descargas al mes. Su única defensa es una transacción de solo lectura a nivel de base de datos — y eso solo no detiene cada escritura. Este es un reemplazo mantenido en Rust con defensa en profundidad.

Un servidor Model Context Protocol de reemplazo directo que permite a un agente de IA consultar PostgreSQL — solo lectura, aplicado a nivel de base de datos, con validación real de SQL, tiempos de espera, límites de costo, OAuth 2.1 y un rastro de auditoría. Habla Streamable HTTP y stdio, y negocia la revisión de MCP: 2026-07-28 (actual, y el predeterminado desde que el upstream lo lanzó el 2026-08-03), 2025-11-25 y 2025-06-18 — lo que la mayoría de los clientes en uso aún hablan hoy. Un cliente pide lo que conoce; no se negocia hacia abajo.

Intenta romperlo — un comando, sin base de datos

El guardián de solo lectura tiene un modo fuera de línea. Entrégale una declaración y dice lo que decidió: sin base de datos, sin configuración, 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

Si algo que escribe regresa como ALLOW, eso es lo más valioso que cualquiera puede enviarnos. No necesita un exploit funcional ni un informe — una línea de SQL y "esto no debería estar permitido" es un informe completo. Cualquier cosa que pase el guardián va a través de SECURITY.md; todo lo demás es un problema ordinario, y el estándar para abrir uno es esto me parece incorrecto, no estoy seguro.

El fuzzer es determinista e imprime su semilla, así que lo que encuentre se reproduce en una máquina que nunca ha visto la tuya — un millón de mutaciones toman alrededor de un minuto:

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

Para todo el conjunto contra una base de datos real, docker compose -f examples/docker-compose.yml up -d levanta PostgreSQL con datos de muestra y el servidor al frente, conectándose como un rol que tiene SELECT y nada más.

Cada evasión encontrada hasta ahora vive en el corpus MUST_REJECT en src/validate.rs y se ejecuta en cada commit, registrada con lo que costó en lugar de ser escondida. La tuya se uniría a ellas.

Por qué

@modelcontextprotocol/server-postgres está desaprobado en npm (última publicación diciembre 2024) y aún ve 475,790 descargas en los 30 días hasta el 9 de agosto de 2026. Crédito donde corresponde: su enfoque no es ingenuo — envuelve cada consulta en BEGIN TRANSACTION READ ONLY y siempre hace ROLLBACK, que es una defensa real y una que este servidor ahora también adopta.

El problema es que es la única defensa, y no es completa:

  • Una transacción de solo lectura no bloquea cada escritura, y un rollback no deshace todo lo que deja pasar. Dos hechos separados, y el segundo es el que importa.

    gin_clean_pending_list() se ejecuta dentro de SET TRANSACTION READ ONLY y su trabajo sobrevive al rollback: un índice con 25 páginas pendientes tiene 0 después de que la transacción se revierte. pg_backup_start() pone la sesión en estado de respaldo, sobrevive a DISCARD ALL, y con el fast => false predeterminado espera un checkpoint distribuido mientras fuerza full_page_writes, lo cual es un costo real en un servidor ocupado. pg_import_system_collations() también se ejecuta sin lanzar SQLSTATE 25006, pero ten cuidado con cuánto peso le das: ese SÍ es deshecho por un rollback, así que contra un servidor que siempre revierte es una curiosidad más que una evasión.

    Reprodúcelo, pero lee las dos condiciones previas primero, porque sin ellas verás un cero o un error y concluirás que inventamos esto. Las tres necesitan superusuario o propiedad del objeto. Y la importación solo restaura collations que están faltantes, así que algo tiene que ser eliminado primero:

    -- 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 son escrituras, ambos están dentro de una transacción de solo lectura, y uno es rechazado mientras el otro no lo es. Esa asimetría es por qué este servidor no trata la transacción como su única defensa. También es por qué el rol importa más que cualquiera de esto: cada ejemplo arriba necesita privilegios que un lector de menor privilegio no tiene, y este servidor se niega a iniciar como oyente de red cuando el rol que se le dio puede escribir. Lo que no puede controlar es qué cadena de conexión alguien pega en una configuración de cliente, y la respuesta habitual es la que ya tenían.

  • Sin tiempo de espera de declaración, sin guardián de costo, sin límite de filas — una consulta puede ejecutarse hasta que el servidor se rinda.

  • Sin autenticación, sin rastro de auditoría, sin manejo de inyección de prompts a través de los datos de fila devueltos.

  • Un archivo fuente de 143 líneas, sin mantenimiento desde diciembre de 2024, sin suite de pruebas.

Este servidor mantiene el rollback, añade validación de AST delante de él y añade las capas operativas que el original nunca tuvo.

postgres-mcp-hardened vs el original archivado

server-postgres archivadopostgres-mcp-hardened
Aplicación de solo lecturaBEGIN TRANSACTION READ ONLY + ROLLBACK — una capa, y PostgreSQL deja pasar algunas escrituras a través de ellaValidación de AST (sqlparser) más la misma transacción de solo lectura y rollback, más una lista de denegación para funciones que escriben a pesar de ello
Multi-declaración / DROP vía CTEllega a la base de datos y es detenido solo por la transacciónrechazado por el analizador, antes de llegar a la base de datos
Tiempo de espera de declaraciónningunostatement_timeout + idle_in_transaction_session_timeout aplicados
Consultas descontroladas / costosasse ejecutan sin límiteguardián de costo EXPLAIN las rechaza antes de la ejecución
Inyección de prompts vía datos de filasalida crudatrusted="false" envuelto + escape de delimitadores
Mensajes de errorfiltran esquema (relation X does not exist)estructurados, sin filtraciones, accionables
AutenticaciónningunaOAuth 2.1 (RS256 JWT, alcance + audiencia + emisor)
Auditoríaningunaregistro encadenado por hash a prueba de manipulación
Esquema como recursos MCP✅✅ — más comentarios, claves primarias y foráneas
Pruebas / CIningunasuites unitarias y de extremo a extremo contra PostgreSQL en vivo, un arnés de fuzzing determinista, conformidad impulsada por el SDK oficial de MCP, clippy + cargo audit + compilación de contenedor en cada push
Transportestdio / SSE desaprobadoStreamable HTTP + stdio
Mantenido❌ desaprobado desde 2024✅

Instalación

Cinco formas de entrar, en el orden que la mayoría de la gente las quiere.

Un clic, para un cliente que acepta paquetes .mcpb: descarga postgres-mcp-hardened-<your-platform>.mcpb desde el último lanzamiento y ábrelo. El paquete pide la cadena de conexión y la almacena en el llavero del sistema operativo en lugar de en un archivo de configuración de texto plano. Nada que instalar, nada que editar.

A través de npm — el más corto, y el que tu configuración de cliente MCP puede apuntar directamente. No hay runtime de Node involucrado en tiempo de ejecución: el paquete es un lanzador que obtiene el binario nativo para tu plataforma y verifica su suma de verificación antes de ejecutarlo.

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

La cadena de conexión va en env, no en args, a propósito: los argumentos aparecen en la salida de ps y en el historial del shell en una máquina compartida, y una contraseña de base de datos no pertenece allí.

Un binario desde la página de lanzamientos — un archivo, nada que mantener actualizado, y la opción de tomarlo si tu máquina no tiene Node en absoluto. (No es un binario estático, como esta página afirmó hasta 0.1.7: los objetivos -gnu y macOS enlazan la biblioteca C del sistema como cualquier otro programa nativo. Simplemente no hay nada que instalar junto a él.) Cada lanzamiento lleva compilaciones para Linux, macOS y Windows en x86-64 y arm64, cada una con una suma de verificación y una firma; verificarlas es la siguiente sección.

Como contenedor, si así es como ejecutas las cosas. La imagen es distroless y se ejecuta como un usuario no root, y las mismas firmas lo cubren a él como cubren los binarios.

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 no es decoración. El servidor está inactivo en 7.7 MB y una solicitud normal cuesta megabytes de un solo dígito, pero un llamador puede escribir SELECT repeat('x', 100000000) y llevar la memoria máxima a 400 MB — no a través del resultado, que permanece limitado a 300 bytes, sino a través del propio EXPLAIN del guardián de costo, que PostgreSQL llena con la constante que plegó durante el planeamiento. Ese es un riesgo residual nombrado en THREAT_MODEL.md, con las tres reparaciones que se intentaron y lo que cada una rompió. Hasta que se cierre, el límite de memoria es lo que sostiene, así que establece uno: --memory aquí, MemoryMax= bajo systemd.

MCP_ADDR debe enlazar 0.0.0.0 y no 127.0.0.1, o el servidor escucha en una interfaz que solo existe dentro del contenedor y el puerto publicado no responde nada. La otra fácil: localhost en DATABASE_URL significa el contenedor, no tu máquina, así que un PostgreSQL que se ejecuta en el host necesita host.docker.internal (Docker Desktop) o la dirección del host en el puente (172.17.0.1 por defecto en Linux). Ambos fueron recorridos de extremo a extremo contra la imagen publicada antes de ser escritos aquí, incluyendo que una lectura devuelve filas y DROP TABLE regresa como -32602 non-read-only statement: Drop.

Desde el código fuente — cargo build --release en un clon. No cargo install: este crate no está en crates.io, y una instrucción que falla es peor que una que falta.

Verificando lo que descargaste

Cada binario publicado está firmado con firma sin clave de Sigstore — no hay clave privada que podamos perder, y el certificado nombra el flujo de trabajo, el repositorio y la etiqueta que produjeron el archivo. Cada artefacto incluye un .sig y un .pem a su 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

Fija la identidad, no solo la firma. Sin --certificate-identity-regexp y --certificate-oidc-issuer la verificación responde "alguien firmó esto", que no es la pregunta. Un certificado verificado nombra el flujo de trabajo, el repositorio y la etiqueta que compilaron el archivo — puedes leerlo con base64 -d "$F.pem" | openssl x509 -noout -text (cosign escribe el certificado codificado en base64, lo que sorprende a quienes intentan openssl directamente sobre él).

Las compilaciones antiguas de cosign son anteriores a --bundle; se publican archivos separados .sig y .pem junto a ellos, utilizados como --signature "$F.sig" --certificate "$F.pem". El cosign actual marca esas banderas como obsoletas, así que prefiere el paquete.

Las versiones públicas además incluyen procedencia de compilación SLSA, verificable con gh attestation verify <file> --repo Eszetael/postgres-mcp-hardened.

Úsalo en Claude Desktop / Cursor (stdio)

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

O ejecútalo como servidor remoto (HTTP Streamable)

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: las conexiones a PostgreSQL se cifran siempre que el servidor lo soporte, y sslmode=require, verify-ca y verify-full son todos aceptados (la cadena de certificados y el nombre de host siempre se verifican, por lo que require se comporta como verify-full) — así que Postgres administrado (RDS, Supabase, Neon, Render) funciona sin configuración adicional. Los certificados y nombres de host siempre se verifican — verificados (aceptación: "un certificado que nombra otro host es rechazado, por nombre"); para una CA privada, apunta MCP_SSLROOTCERT al paquete PEM. No existe un interruptor de "confiar en todo".

Consejo: apunta DATABASE_URL a un rol de solo lectura con privilegios mínimos. El servidor aplica la solo lectura por sí mismo, pero un rol de base de datos con alcance limitado es defensa en profundidad.

O ejecútalo en una plataforma de contenedores (Apify Standby)

El servidor no necesita cambios de código para ejecutarse como un Actor de Apify en modo Standby. Lee el puerto que la plataforma asigna desde ACTOR_WEB_SERVER_PORT y enlaza 0.0.0.0 allí — ese puerto tiene prioridad sobre MCP_ADDR, de forma audible, en stderr, porque enlazar en cualquier otro lugar significa que la ejecución nunca se marca como lista y el fallo parece un tiempo de espera misterioso. GET / responde a la sonda de preparación de la plataforma (x-apify-container-server-readiness-probe) sin tocar la base de datos: la preparación del contenedor no es la preparación de la base de datos, y una sonda que espera en un grupo ocupado convierte una base de datos lenta en un contenedor que nunca se inicia.

EndpointMétodoPropósito
/mcpPOSTel endpoint MCP (HTTP Streamable). DELETE finaliza una sesión.
/GETsonda de preparación; de lo contrario, una señal que nombra el endpoint real
/healthGETel proceso está vivo
/readyGETel proceso y una conexión a la base de datos están disponibles
/metricsGETcontadores (necesita MCP_METRICS_TOKEN)
/.well-known/mcp/server-card.jsonGETlo que lee un registro: revisiones, transportes, herramientas

Entrada es una solicitud JSON-RPC en el cuerpo del POST — initialize, tools/list, tools/call, resources/list, resources/read, server/discover. Salida es una respuesta JSON-RPC; desde 2025-11-25 una declaración rechazada regresa como un error de ejecución de herramienta (isError: true) con el motivo en el contenido, para que el modelo pueda reescribir la consulta. tools/list es la descripción autoritativa de cada argumento.

La autenticación allí es de la plataforma, no nuestra. Apify verifica el token del llamante antes de enrutar al contenedor, por lo que el servidor no exige adicionalmente MCP_BEARER_TOKEN — exigir un segundo secreto significaría que un agente que encuentre este servidor no pueda llamarlo. Esa exención es estrecha: necesita ambos APIFY_IS_AT_HOME y ACTOR_WEB_SERVER_PORT, uno solo no cambia nada, y la tarjeta del servidor entonces informa "type": "apify-platform" en lugar de reclamar un bloqueo que no tenemos. En cualquier otro lugar, el servidor aún se niega a iniciarse en una dirección de red sin autenticación. Establece MCP_BEARER_TOKEN también si quieres un segundo bloqueo en la misma puerta.

La otra puerta de inicio no cambia y aquí importa más: un rol que puede escribir se le niega un listener de red. Apunta DATABASE_URL a un rol de solo lectura — --print-setup-sql escribe las declaraciones.

Migración desde el servidor obsoleto

Los problemas más discutidos reportados contra @modelcontextprotocol/server-postgres fueron reproducidos contra este servidor; aquí está cómo se comporta cada uno:

Lo que la gente reportóAquí
Dos instancias (prod + dev) son indistinguibles, el cliente elige unaLos URI de recursos llevan el nombre de la base de datos (postgres:///mydb/public/orders/schema) y MCP_SERVER_LABEL nombra la instancia en la interfaz del cliente
Una base de datos por instancia, porque la cadena de conexión es un argumento de línea de comandosMCP_DATABASE_URLS="prod=…;dev=…" sirve varias bases de datos desde un servidor; cada herramienta acepta un database opcional, y los recursos abarcan todas ellas
no pg_hba.conf entry … SSL offEl error dice que el servidor requiere TLS y nombra la solución (?sslmode=require)
Solo lectura eludida inyectando COMMIT / ENDRechazado — la puerta de múltiples declaraciones funciona en tokens, antes del analizador, y COMMIT solo se rechaza como escritura
spawn npx ENOENT, problemas de versión de NodeUn único binario nativo; sin Node, sin npx, sin node_modules
Se cuelga indefinidamente contra RDS sin salida ni errorAcotado: un host inalcanzable responde en ~8 s con el motivo, nunca en silencio
self-signed certificate in certificate chainApunta MCP_SSLROOTCERT al paquete de CA; el error nombra esa variable
Cadena de conexión solo como argumento de línea de comandosDATABASE_URL o el argumento posicional — la invocación original sigue funcionando
INVALID_URL con caracteres especiales en la contraseñaEl error dice qué caracteres codificar en porcentaje, y cómo
Los hijos de partición inundan las listas de tablas y recursosOcultos por defecto; MCP_SHOW_PARTITIONS=1 los trae de vuelta
-32601 Method not found, Unexpected end of JSON inputping y recursos implementados; JSON multilínea se almacena en búfer hasta completarse; los lotes se rechazan con un error claro en lugar de silencio
Sin límite de filas — una consulta inunda el contextoAuto-LIMIT, un límite de bytes de 8 MB, y una bandera explícita truncated

Pruebas

Más allá de las pruebas unitarias, el repositorio incluye dos arneses que se ejecutan en CI en cada cambio:

  • --fuzz — un fuzzer determinista que muta un corpus de escrituras conocidas con transformaciones que no cambian el significado de SQL (comentarios, mayúsculas/minúsculas, comillas con dólar, Unicode invisible, paréntesis) y afirma que ninguna de ellas se convierte jamás en una declaración permitida.
  • tests/acceptance.sh — una suite de extremo a extremo que inicia su propio PostgreSQL y verifica 317 comportamientos: cada elusión de escritura reportada contra el servidor obsoleto (incluyendo la inyección COMMIT/END), resultados veraces, introspección de esquema, conformidad de protocolo, errores de configuración que fallan de forma audible, detección de manipulación de auditoría, uso justo bajo carga, y despliegues de múltiples bases de datos.

Cada problema reportado, respondido

docs/COMMUNITY_ISSUES.md es el registro completo: cada problema reportado contra el servidor obsoleto y cada problema abierto contra las alternativas mantenidas, cada uno con lo que ocurre aquí — incluyendo los pocos que no pudimos corregir en código, dicho claramente.

Lo que aprendimos de las alternativas

Cada servidor en este espacio tiene un rastreador de problemas, y esos rastreadores son un mapa de lo que sale mal. Aquellos contra los que deliberadamente construimos:

  • Una imagen publicada que se queda atrás del código. La queja abierta más apoyada contra la alternativa líder. Nuestro contenedor se compila y publica desde la misma etiqueta que produce los binarios, por lo que no puede desviarse.
  • Un tiempo de espera de consulta codificado. También entre sus ajustes más solicitados. MCP_STATEMENT_TIMEOUT es configurable y validado al inicio.
  • Acceso sin restricciones por defecto. Algunos servidores predeterminan lectura/escritura y dependen del operador para restringirlo. Este no tiene ninguna ruta de escritura.
  • Credenciales en la configuración del cliente. MCP_PASSWORD_FILE mantiene la contraseña fuera de ella.
  • Tablas en un esquema no predeterminado silenciosamente no encontradas. MCP_SEARCH_PATH corrige la búsqueda, y las herramientas aceptan un schema explícito de todos modos.
  • Transporte obsoleto. HTTP+SSE fue reemplazado por HTTP Streamable en 2025-03-26, hace tres revisiones (esta página decía 2025-06-18 hasta 0.1.7, que estaba equivocada por una revisión; el registro de cambios de la especificación para 2025-03-26 registra el reemplazo). Hablamos el transporte actual.

Solución de problemas

Respuestas a las preguntas que la gente realmente hizo sobre el servidor obsoleto, para que nadie tenga que abrir un problema para encontrarlas.

spawn npx ENOENT / "¿qué versión de Node necesito?" — ninguna. Este es un único binario nativo, sin tiempo de ejecución que instalar junto a él. Descárgalo de la página de versiones y apunta tu cliente al archivo. No hay node_modules, no hay npx, nada que mantener actualizado.

"El servidor se inicia pero nada escucha en un puerto." — eso es modo stdio, que es correcto para Claude Desktop y Cursor: el cliente habla con el proceso a través de su entrada y salida estándar, no a través de un socket. Si quieres un endpoint de red, inícialo sin --stdio; entonces imprime MCP HTTP listening on http://… y habla HTTP Streamable.

"¿Puede mi cliente en otra máquina alcanzar la base de datos?" — sí: ejecuta el servidor junto a la base de datos en modo HTTP, exponlo, y habilita OAuth (JWT_PUBKEY_PEM, JWT_AUD, JWT_ISS). Las credenciales de la base de datos entonces nunca salen del host donde se ejecuta el servidor.

"No se pudo adjuntar al servidor MCP." — el proceso salió antes del apretón de manos. Ejecuta el mismo comando en una terminal: un error de configuración imprime su motivo y sale con estado 2 en lugar de morir en silencio, y un problema de conexión se reporta en la primera consulta con la causa.

self-signed certificate in certificate chain / unable to verify the first certificate — tu proveedor usa una CA privada (Supabase, GCP y RDS todos lo hacen). Descarga su paquete de CA y establece MCP_SSLROOTCERT a él. El mensaje de error nombra el paso para tu proveedor. No ofrecemos un interruptor de "confiar en todo".

Proveedores administrados

Este servidor siempre verifica el certificado de la base de datos, incluso con sslmode=require. Eso es una desviación deliberada de libpq, donde require cifra sin verificar y una máquina en el medio puede por lo tanto leer y reescribir cada consulta y resultado sin que nadie lo note. El costo de ser estricto es que un proveedor con una CA privada necesita un paso extra; el costo de ser laxo es que nunca te enteras. Si no estás de acuerdo con el equilibrio, verify-full con el paquete a continuación es la misma cantidad de trabajo y no deja dudas de ninguna manera.

ProveedorQué esperar
SupabaseCA privada. Panel → Configuración del proyecto → Base de datos → Configuración SSL → descarga el certificado, luego establece MCP_SSLROOTCERT a él. El host directo (db.<ref>.supabase.co) es solo IPv6 — en una red IPv4 usa la cadena del pooler Supavisor (puerto 6543), que también se adapta a conexiones serverless y de corta duración.
Amazon RDS / AuroraCA privada: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem. La autenticación IAM funciona — coloca el token generado en el campo de contraseña, y recuerda que expira en 15 minutos.
Google Cloud SQLCA privada: Conexiones → Seguridad → server-ca.pem. A través del Proxy de Auth de Cloud SQL, conéctate al proxy en localhost y el TLS es asunto del proxy.
Azure Database for PostgreSQLCA pública — nada que descargar. Azure rotó su raíz a DigiCert Global Root G2 durante el Q1 2026; incluimos el almacén de raíces de Mozilla, por lo que la rotación no requiere nada de ti.
NeonCA pública (ISRG Root X1, Let's Encrypt) — nada que descargar. Los endpoints agrupados y directos funcionan ambos.
DigitalOceanCA privada: descarga el certificado de la página de Descripción general del clúster.
¿No estás seguro de en qué caso te encuentras? Pregúntale al propio servidor, antes de configurar nada:
echo | openssl s_client -starttls postgres -connect YOUR_HOST:5432 2>/dev/null \
  | openssl x509 -noout -issuer

Un emisor conocido (DigiCert, ISRG, Google Trust Services) significa que funcionará sin más; cualquier cosa que nombre a tu proveedor significa que necesitas su paquete de certificados.

Con un pooler de conexiones (Supavisor, PgBouncer) en modo transaccional, ten en cuenta que este servidor establece statement_timeout y idle_in_transaction_session_timeout por sesión y ejecuta cada consulta en una transacción explícita de solo lectura. Ambos son compatibles con el pooling transaccional; el ajuste a nivel de sesión SET fuera de una transacción no lo es, por eso no hacemos ni lo uno ni lo otro.

no pg_hba.conf entry … no encryption — el servidor solo acepta conexiones TLS para ese host y usuario. Añade ?sslmode=require a la cadena de conexión.

INVALID_URL / invalid connection string — una contraseña que contenga @, :, /, # o ? debe estar codificada en porcentaje (@ → %40, : → %3A, / → %2F, # → %23).

"Mi tabla tiene cientos de particiones y la lista es inmanejable." — las particiones hijas están ocultas por defecto; se lista la partición padre. Establece MCP_SHOW_PARTITIONS=1 si las necesitas.

"Necesito producción y staging al mismo tiempo." — o ejecutas un servidor por base de datos (son distinguibles: establece MCP_SERVER_LABEL), o configuras ambos en un solo servidor con MCP_DATABASE_URLS y pasas database en los argumentos de la herramienta.

Recursos

Cada tabla y vista se expone como un recurso MCP (postgres:///<schema>/<table>/schema), de modo que un cliente puede explorar el esquema sin emitir una consulta — la misma capacidad que ofrecía el servidor obsoleto, más los comentarios de columna, claves primarias y claves foráneas en la carga útil.

Cuando la base de datos no ha respondido, resources/list devuelve una lista vacía con el motivo en _meta en lugar de un error de protocolo. Un catálogo que inspecciona este servidor lo inicia sin base de datos alguna y llama a resources/list justo después de initialize; responder a eso con un error se lee como un servidor que no funciona. La lista vacía no es una afirmación de que no haya tablas — initialize dice que la base de datos no ha respondido, security_posture da el detalle, y el motivo viaja con la propia lista. Una base de datos que sí responde y se niega sigue siendo un error, porque informar de "sin recursos" para un privilegio ausente es el fallo silencioso que este servidor existe para evitar. verificado (aceptación: "un host puede inspeccionar el servidor sin base de datos y con mcp-proxy delante")

Herramientas

  • explain_query — el plan de ejecución; con analyze ejecuta la sentencia e informa de tiempos reales y uso de buffers, lo cual es seguro aquí porque la sentencia se valida como de solo lectura y se ejecuta dentro de una transacción que siempre se revierte. El plan viene con un summary: qué nodo consumió el tiempo (tiempo propio, no inclusivo), y dónde la estimación de filas del planificador estuvo más lejos de la realidad — porque una mala estimación suele ser la razón de que el plan sea malo.
  • database_health — ratio de aciertos de caché, conexiones (esta base de datos y el clúster), la sentencia de mayor duración y la transacción abandonada más larga como cifras separadas, backlog de vacuum, índices inválidos, secuencias cerca de su techo, retraso de replicación, tablas que nunca se han analizado (sin estadísticas de planificador — la razón habitual de que una base de datos parezca sana y vaya lenta), y la ventana que cubren los contadores. Cualquier cosa que el rol no pueda ver se declara en lugar de devolverse como un cero con seguridad.
  • analyze_indexes — índices sin usar, duplicados, y tablas escaneadas secuencialmente con la frecuencia suficiente como para que un índice merezca la pena.
  • top_queries — las sentencias más pesadas, desde pg_stat_statements.
  • security_posture — lo que este despliegue puede hacer realmente a tu base de datos, preguntado a PostgreSQL en lugar de asumido: si el rol puede escribir, omitir la seguridad a nivel de fila o alcanzar archivos del servidor; si el transporte está autenticado; si la cadena de auditoría está cifrada; si la conexión está cifrada. Devuelve una calificación — el peor hallazgo, nunca un promedio — y, para cualquier cosa incorrecta, el comando que la corrige. El mismo resumen llega al modelo a través de initialize, porque bajo stdio nadie ve stderr y el agente es el único mensajero que tiene el operador.
  • query — ejecuta una consulta SQL de solo lectura (validada, con LIMIT automático, con límite de coste). La respuesta indica lo que hizo: returnedRows, appliedLimit, truncated, más requestedLimit cuando una solicitud mayor se limitó al máximo de 10000 filas, offset al paginar, y redactedColumns cuando el enmascaramiento está configurado — de modo que un agente nunca tenga que adivinar si recibió la respuesta completa.
  • list_schemas, list_tables, describe_table — descubrimiento progresivo de esquema (parametrizado, seguro contra inyección). describe_table devuelve los comentarios de esquema (COMMENT ON TABLE/COLUMN), claves primarias, claves foráneas y valores por defecto, de modo que el agente lee lo que una columna significa y a qué apunta en lugar de adivinarlo por su nombre — y una tabla ausente es un error, no una lista de columnas vacía.

Revisiones de protocolo

El servidor responde a initialize con la revisión que el cliente pidió cuando la implementa, y con su más reciente en caso contrario. Por HTTP la revisión proviene de la cabecera MCP-Protocol-Version, por solicitud — la negociación de un cliente no puede cambiar el contrato bajo el que se atiende a otro cliente.

Si una solicitud no lleva cabecera, el servidor no cae directamente al contrato más antiguo. Lee la revisión que esta sesión acordó en initialize, que es lo que pide la especificación del transporte: el valor por defecto se aplica solo "si el servidor no recibe una cabecera MCP-Protocol-Version, y no tiene otra forma de identificar la versión — por ejemplo, mediante la versión de protocolo negociada durante la inicialización". Una sesión es esa otra forma, así que un cliente que negoció 2025-11-25 y luego omitió la cabecera mantiene el contrato que acordó en lugar de ser degradado silenciosamente.

Una cabecera que no podemos analizar es algo distinto de una cabecera ausente, y la especificación es explícita al respecto: "Si el servidor recibe una solicitud con una MCP-Protocol-Version inválida o no soportada, DEBE responder con 400 Bad Request." Una versión que no implementamos — 2025-03-26 o not-a-date — se rechaza con 400 y la lista de revisiones que sí hablamos, en lugar de servirse bajo un contrato que el cliente nunca aceptó. La degradación es para el silencio, no para el desacuerdo.

Solo una solicitud sin cabecera ni sesión cae en la degradación, y cae a 2025-06-18 — la revisión más antigua que implementa este servidor — en lugar de la 2025-03-26 que nombra la especificación. Esa revisión no está implementada aquí, y responder bajo un contrato que el servidor no puede honrar sería peor que responder bajo la más antigua que sí puede.

La diferencia que importa es adónde va un rechazo. Bajo 2025-06-18 "esta sentencia no es de solo lectura" era un error JSON-RPC: el cliente veía una llamada rota y el modelo a menudo nunca veía el motivo. Desde 2025-11-25 (SEP-1303) llega como un error de ejecución de herramienta — isError: true con el motivo en el contenido — de modo que el modelo reescribe la consulta en lugar de entregar al usuario un fallo. Lo que no cambia es la auditoría: el rechazo lo registra el código que rechaza, y la suite de aceptación afirma ambas mitades juntas, de modo que errores más amables nunca pueden significar silenciosamente un registro más silencioso.

Mcp-Method y Mcp-Name se mantienen en acuerdo, no presencia. El borrador los exige; las revisiones anteriores no, y exigirlos rompería a todos los clientes actuales. Pero una pasarela que enruta o autoriza según Mcp-Method mientras el servidor ejecuta el cuerpo ha decidido sobre una solicitud distinta de la que se ejecuta — y eso es cierto sea cual sea la revisión vigente. Así que una cabecera presente debe coincidir con el cuerpo bajo toda revisión, mientras que un cliente que no envía ninguna no se toca.

Los fallos de protocolo siguen siendo fallos de protocolo. Un sobre mal formado, un método desconocido o un token ausente no es algo que un modelo pueda arreglar reescribiendo SQL, y el manejo de errores de un cliente espera esos casos donde siempre han estado.

La próxima revisión, antes de que llegue

2026-07-28 es la mayor ruptura que ha tenido MCP: sin initialize, sin cabecera de sesión, sin ping. Ese identificador proviene de LATEST_PROTOCOL_VERSION en el esquema del borrador, y no es una fecha de lanzamiento — MCP nombra una revisión por la última fecha en que se hizo un cambio incompatible hacia atrás, así que describe la historia del borrador más que un calendario. Cada solicitud lleva su propia versión de protocolo en _meta, y un nuevo server/discover reemplaza el apretón de manos. Lo implementamos pronto detrás de un interruptor, porque un borrador se mueve y un servidor que anuncia soporte para un objetivo en movimiento estará equivocado en público. Upstream cortó schema/2026-07-28 el 2026-08-03 — el esquema publicado difiere del borrador que habíamos verificado en cuatro URLs de documentación y nada más — así que el interruptor ha desaparecido y esto es lo que el servidor habla por defecto. Los clientes en 2025-11-25 y 2025-06-18 reciben respuesta como antes.

server/discover responde bajo toda revisión, porque la especificación espera que los clientes lo usen como una sonda de compatibilidad hacia atrás — lo que solo funciona si los servidores más antiguos responden. El nuestro responde con las revisiones que hablamos y, en _meta, la postura de seguridad completa. Eso es deliberado: un cliente puede saber que está hablando con un servidor conectado como superusuario antes de enviar una consulta, como datos estructurados en lugar de prosa que un modelo tenga que notar.

Dos de las reglas del borrador son controles de seguridad aquí, no formalidades. Mcp-Method y Mcp-Name deben coincidir con el cuerpo de la solicitud, y rechazamos el desajuste (-32020) — las cabeceras existen para que una pasarela pueda enrutar y autorizar sin analizar el cuerpo, y si cabecera y cuerpo pueden discrepar, entonces lo que autorizó y lo que ejecuta vieron dos solicitudes distintas. Y un cliente que declara una versión que no implementamos recibe esa información (-32022) en lugar de ser atendido silenciosamente bajo un contrato que nunca aceptó.

Lo que cuesta la seguridad

Medido, no afirmado: tests/bench/, frente al controlador pg ejecutando la misma consulta en la misma máquina (PostgreSQL 18.6 en Docker, tabla de 50k filas, 300 solicitudes secuenciales, límite de tasa desactivado). Re-medido el 2026-08-17 en un VPS compartido con carga media de 2.6 — mediana de dos ejecuciones:

consultacontroladoreste servidordiferencia
búsqueda puntual0.43 ms5.9 ms+5.5 ms
escaneo pequeño1.0 ms8.3 ms+7.3 ms
agregado4.7 ms11.3 ms+6.6 ms

Una tabla anterior aquí decía +3.6/+5.2/+3.7 y "unos 4 ms". Esos provenían de una máquina más silenciosa, y el suelo del controlador se movió con ellos — 0.28 ms frente a los 0.43 de hoy para la misma búsqueda — así que era el hardware hablando, no el código. Se comprobaron dos cosas antes de cambiar el número, porque el sospechoso obvio era nuestra propia compilación: el binario 0.1.6, compilado antes de que se activara lto, mide +5.4/+7.8/+5.9 en esta máquina dentro de la misma hora. Idéntico. El perfil de lanzamiento redujo a la mitad el binario y no costó nada aquí.

Espera de 5 a 8 ms por consulta, y trata cualquier cifra individual de esta página como una lectura de una máquina en un día. La forma importa más que el tamaño: la sobrecarga es casi constante. Si la validación del AST fuera el coste, crecería con la consulta. No lo hace. El tiempo se va en idas y vueltas — la sesión se restablece, se establecen los tiempos de espera y el indicador de solo lectura, se abre una transacción de solo lectura, el guardián de costes planifica la sentencia, luego la consulta se ejecuta y la transacción se revierte. Cinco o seis intercambios donde el controlador tiene uno. Eso es un intercambio deliberado y puedes ver exactamente qué compra. Para un agente que hace decenas de llamadas es invisible; si lo estás poniendo frente a una ruta de servicio crítica en latencia, estás usando la herramienta equivocada, y no es una.

Bajo concurrencia, el número interesante no es el rendimiento sino lo que sucede más allá de los límites: con 8 clientes concurrentes sirvió 373 solicitudes/segundo y rechazó 192 más con "demasiadas solicitudes en vuelo", que es el límite de en vuelo haciendo su trabajo en lugar de una cola creciendo hasta que algo se cae.

¿Ayudaría este índice? — respondido sin crear uno

La única capacidad por la que la alternativa líder es genuinamente conocida es el ajuste de índices: puede decirte que un índice valdría la pena antes de que lo construyas. Llega ahí por defecto a una conexión que puede crear índices reales — seguro solo si recordaste restringirla.

simulate_index responde la misma pregunta desde una conexión que no puede escribir nada. hypopg registra un índice hipotético en la memoria del backend: el planificador lo ve, el almacenamiento nunca lo hace, y desaparece cuando la llamada regresa. Obtienes el plan y el costo con y sin, y — por separado — si el planificador realmente lo usó, porque un costo que apenas se mueve y un índice que el planificador ignoró son respuestas diferentes.

La herramienta toma una tabla y una lista de columnas. No es una declaración CREATE INDEX. La definición se ensambla en el lado del servidor a partir de identificadores que el catálogo confirmó que existen, citados por PostgreSQL mismo, por lo que no hay camino desde un argumento de herramienta a DDL arbitrario — un nombre de columna que lleva SQL muere en la búsqueda del catálogo, y hay una prueba que dispara exactamente eso. Los números son estimaciones del planificador: trata una gran mejora como una razón para probar el índice, no como prueba.

La conformidad se verifica con el cliente de otra persona

Cada otra prueba aquí es nuestro arnés hablando con nuestro servidor. Si malinterpretamos la especificación, la malinterpretamos de la misma manera en ambas mitades y todo pasa. Así que CI también maneja el servidor con el SDK oficial de MCP — la biblioteca cliente que usa el ecosistema — sobre stdio y HTTP Streamable: apretón de manos, listado de herramientas y esquemas, una lectura, una escritura rechazada que llega como un error de ejecución de herramienta en lugar de uno de protocolo, listado y lectura de recursos. Un error de protocolo aparece como un cliente que no puede hablarnos. tests/conformance/.

Trabajando en esto

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

.githooks/pre-push ejecuta formato, clippy, las pruebas unitarias y las verificaciones de afirmaciones de documentación antes de que algo salga de tu máquina. Existe debido a un error específico: un commit salió con un lint de clippy fallido, y lo primero que alguien supo fue un correo de fallo. Nota que cargo test pasa en ese código — los lints de clippy no son errores de compilador — así que "compila localmente" no es la misma respuesta que "CI estará verde".

Deliberadamente omite la suite de aceptación y la matriz de PostgreSQL: esas necesitan Docker y unos veinte minutos, y un hook que la gente no puede permitirse ejecutar es un hook que la gente evita. CI ejecuta todo. git push --no-verify cuando quieras ver algo fallar en CI a propósito.

Configurando el rol

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

Ejecuta con una cadena de conexión y las listas de tablas y columnas vienen del catálogo; sin una obtienes el mismo documento con marcadores de posición. La diferencia importa más para la redacción: las columnas a otorgar de vuelta tienen que leerse de la base de datos, porque escribirlas de memoria es cómo una columna destinada a permanecer oculta se devuelve.

La salida termina con verificaciones que no devuelven filas cuando funcionó, y un recordatorio de que el servidor mismo te dirá qué puede hacer el rol en el momento en que lo apuntes a la base de datos.

Limitando lo que el servidor puede alcanzar

MCP_ALLOW_SCHEMAS y MCP_ALLOW_TABLES restringen qué relaciones puede tocar una consulta. Cualquiera de los dos activa la lista de permitidos; schema.* y schema.table ambos funcionan.

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

La verificación lee el plan de consulta, no el SQL. Ese es todo el diseño: el planificador ya ha aplicado search_path, resuelto cada alias, expandido vistas a tablas base, y sabe que un CTE llamado customers no es la tabla customers — así que WITH customers AS (SELECT 1) SELECT * FROM customers runs and touches nothing, while WITH x AS (SELECT * FROM salaries) SELECT * FROM x es rechazado. Leer la declaración en su lugar es lo que perdió tres rondas de revisión adversarial.

Dos consecuencias que vale la pena conocer antes de activarlo:

  • Una partición viaja con su padre. Permites events; PostgreSQL decide qué hijos leer.
  • Una vista necesita que sus tablas base también estén permitidas, porque el plan nombra esas. Permite ambas, y deja que los privilegios de la base de datos mantengan la tabla base inalcanzable directamente — ese es el límite en cualquier caso.

pg_catalog y information_schema están fuera de la superficie a menos que MCP_ALLOW_CATALOG=1: un agente que aún puede leer el catálogo puede enumerar exactamente lo que la lista de permitidos pretendía ocultar. Las herramientas de esquema siguen funcionando, porque ejecutan consultas fijas en lugar de SQL del llamador.

Eso cubre tres rutas a los mismos hechos, porque por un tiempo cubrió solo una. Una vista de catálogo planea escaneos sobre relaciones reales y el plan las nombra — pero pg_settings planea a un solo Function Scan en pg_show_all_settings, sin nombrar ninguna relación, y current_setting() es una llamada escalar que nunca aparece como un escaneo. Ambos solían devolver la configuración del servidor bajo una lista de permitidos activa. Funciones cuyo nombre comienza con pg_, más current_setting, inet_server_addr y inet_server_port, ahora son rechazadas junto con las relaciones del catálogo, y MCP_ALLOW_CATALOG=1 abre todas juntas. Funciones ordinarias que devuelven conjuntos — generate_series, jsonb_each, unnest, regexp_split_to_table — no llevan tal prefijo y no se ven afectadas. current_user, session_user, current_database y version permanecen legibles a propósito: un agente ya sabe a qué se conectó y como quién.

El corpus de cosas que se colaron

Cada forma que derrotó un control durante la revisión vive en tests/adversarial/, con la ronda en la que dejó de funcionar. Se ejecuta en cada compilación, y — porque los casos están escritos contra marcadores de posición en lugar de nuestro fixture — puedes apuntarlo a tu propia base de datos:

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

ADV_TABLE necesita la columna sensible, ADV_TABLE2 es cualquier otra tabla legible, ADV_REDACT_COL es la columna a redactar. Las tres importan: este ejemplo omitió ADV_TABLE2 hasta 0.1.7, así que mantuvo su valor predeterminado de film — una tabla de la base de datos de muestra Pagila — y seguir la instrucción exactamente produjo tres "desajustes" que eran solo una relación faltante. El arnés ahora verifica los tres de antemano y dice cuál está mal, porque un corpus de seguridad que reporta un error tipográfico como un control fallido te enseña a ignorar los fallos que importan.

Una afirmación de seguridad que solo puedes verificar leyendo nuestro código fuente es una afirmación que tienes que tomar con confianza, y la propia historia de este proyecto es el argumento en contra de eso.

Modelo de seguridad

La declaración completa de lo que este servidor garantiza, lo que no, y qué control hace cumplir qué promesa está en THREAT_MODEL.md — incluyendo los controles que han sido derrotados en revisión y por lo tanto se describen como profundidad en lugar de como límites.

  • Transporte cifrado: TLS a PostgreSQL vía rustls (sin OpenSSL en la imagen), verificación de certificados siempre activa, CAs privadas vía MCP_SSLROOTCERT.

  • Solo lectura, de dos maneras: cada declaración se analiza con sqlparser y se rechaza a menos que sea un SELECT/WITH/EXPLAIN/SHOW; la sesión de BD se establece además en default_transaction_read_only = on.

  • Anti-DoS: statement_timeout aplicado, LIMIT auto-inyectado, y un guardián de costo basado en EXPLAIN que rechaza planes costosos antes de que se ejecuten.

  • Columnas sensibles — defensa en profundidad, y honesta al respecto: MCP_REDACT_COLUMNS enmascara valores en cada profundidad y se niega a ejecutar una consulta que haga referencia a esas columnas, incluyendo las formas de evitarlo que un panel adversarial realmente encontró — renombrar (SELECT password AS pw), envolver (md5(password)), serializar toda la fila (row_to_json(t), t::text, json_agg(t)), y nombrar la columna como una cadena en lugar de un identificador (to_jsonb(t) ->> 'password', #>> '{password}', $.password), comodines de fila completa (ROW(t.*)::text) y renombrado posicional ((SELECT * FROM staff) AS x(c1, …, c9)). Sigue siendo filtrado basado en nombres, y el filtrado basado en nombres no puede ser un límite contra todo el lenguaje SQL — cuatro rondas adversariales cada una lo superó a través de una forma que nadie había listado.

    La cuarta, en 0.1.7, no encontró una nueva forma de nombre. Fue alrededor de los nombres por completo: SELECT get_raw_page('people', 0) devuelve 8192 bytes de la tabla tal como el disco los tiene, y cada valor en esa página está ahí, incluido el redactado. Demostrado, no teorizado — con MCP_REDACT_COLUMNS=ssn, SELECT ssn FROM people fue rechazado mientras la página cruda volvía con los números de seguro social en ASCII plano. pageinspect y sus parientes ahora son rechazados como una categoría propia, porque "esto devuelve almacenamiento en lugar de columnas" es un problema diferente de "esto escribe", y decirle a un operador cuál golpeó vale un mensaje separado.

    La misma ronda encontró la versión más silenciosa. El planificador de PostgreSQL mantiene una muestra de los valores reales de cada columna, y pg_stats los publica: con 3,000 filas, SELECT * FROM pg_stats WHERE tablename='people' returned {123-45-6789,555-00-1111,987-65-4321} while SELECT ssn FROM people fue rechazado. Esa consulta nunca nombra la columna redactada, así que una regla basada en nombres no tiene nada sobre lo que actuar. Las columnas de estadísticas que contienen valores — most_common_vals, histogram_bounds, most_common_elems, stavalues1…stavalues5 — ahora se unen a lo que configures, cuando configures algo. El resto de la vista no se toca: n_distinct, null_frac y correlation son de lo que se construye el consejo de índices a continuación y no llevan valores, así que eliminar toda la relación habría roto diez columnas para arreglar cuatro.

    Dos puertas más resultaron abrir a la misma habitación. Un valor demasiado largo para su fila se almacena en una tabla TOAST, y esa tabla es legible por nombre: SELECT chunk_data FROM pg_toast.pg_toast_16384 devolvió el texto redactado en claro. pg_largeobject es lo mismo para objetos grandes — los bytes debajo de lo_get. Ambos son rechazados ahora, como relaciones en lugar de funciones, con un mensaje que dice por qué: contienen almacenamiento físico en lugar de columnas. El catálogo que meramente describe la base de datos no se toca — pg_tables, pg_stat_activity, pg_largeobject_metadata, y las columnas de estadísticas que el consejo de índices necesita.

    Los cuatro dicen lo mismo, y describe el límite de esta característica mejor que cualquier lista de parches: un filtro de columnas protege columnas, así que cualquier cosa que lea debajo de las columnas está fuera de lo que puede prometer. Páginas crudas, muestras del planificador, fragmentos TOAST y bytes de objetos grandes son cuatro puertas a ese espacio, las cuatro encontradas en una sola tarde buscando la forma en lugar de los nombres. La suposición honesta es que hay más, por lo que el rol de la base de datos y la transacción de solo lectura son el límite real y esto permanece lo que dice ser: defensa en profundidad. Así que el servidor deja de asumir y le pregunta a la base de datos: al iniciar, informa de cada tabla donde el rol conectado aún puede leer una columna redactada, con las sentencias exactas que lo corrigen, y MCP_REDACT_REQUIRE_REVOKE=1 convierte ese informe en una negativa a ejecutar. Ten en cuenta que la corrección es un REVOKE a nivel de tabla seguido de un GRANT de las columnas que permanecen — un REVOKE SELECT (password) ON staff desnudo es silenciosamente una no-operación mientras el rol tenga SELECT sobre toda la tabla. Con concesiones a nivel de columna, PostgreSQL entonces rechaza SELECT * en esa tabla, así que los llamantes nombran columnas en su lugar; describe_table las lista y marca la redactada.

  • Consciente de inyección de prompts: los datos de filas se devuelven dentro de un bloque de procedencia trusted="false" con delimitadores escapados, de modo que una celda maliciosa no puede secuestrar al agente.

  • Genera el rol con el que deberías ejecutar: --print-setup-sql escribe el DDL para un rol que no hereda nada, no omite nada, no crea nada, lee solo las relaciones que nombres y — donde hayas nombrado columnas sensibles — las tiene revocadas en el orden que realmente funciona. Lo imprime; nunca lo ejecuta. Aplicar esto requiere derechos administrativos, y una herramienta cuya identidad completa es "solo lectura" no tiene por qué tener la contraseña de un administrador.

  • No expondrá un rol que pueda escribir: cuando la dirección de escucha es alcanzable desde la red, el servidor pregunta a PostgreSQL qué se le permite realmente hacer al rol conectado — superusuario, BYPASSRLS, membresía de pg_write_all_data y similares, y privilegios de escritura sobre una muestra acotada de tablas — y se niega a iniciar si la respuesta es más que "lector", nombrando cada razón y señalando a --print-setup-sql. Rechaza un listener de red no autenticado por la misma razón. Loopback y stdio se dejan en paz: allí el llamante es el operador. Las anulaciones (MCP_ALLOW_EXCESSIVE_ROLE, MCP_ALLOW_ANONYMOUS_NETWORK) toman el valor literal i-accept-the-risk para que no puedan activarse por un error tipográfico, y se registran en el registro de auditoría. Este servidor impone solo lectura por sí mismo, pero esa imposición es código, y el código se ha equivocado antes; un rol que no puede escribir es la parte que ningún error nuestro puede deshacer.

  • Un navegador no puede alcanzarlo: una solicitud que lleva un Origin se rechaza con 403 a menos que el operador haya listado ese origen, y en un listener de loopback un Host que no sea localhost se rechaza también — la forma que toma un ataque de rebinding DNS cuando apunta a un servidor de base de datos en tu portátil.

  • La auditoría conoce la configuración: la cadena se abre con un registro startup que nombra la versión, el transporte y cada ajuste en vigor, con contraseñas de conexión eliminadas y secretos reducidos a huellas digitales, más un config_fp que un operador puede fijar entre reinicios. Un registro que dice qué pasó pero no bajo qué ajustes no puede responder la primera pregunta que hace un incidente.

  • La auditoría nota que se acorta: una cadena de hash prueba que las entradas no fueron alteradas, pero un registro con su cola cortada es internamente consistente — recalcularlo no encuentra nada malo. Junto a MCP_AUDIT_LOG el servidor por lo tanto mantiene <log>.hwm, un registro de una línea del último número de secuencia y hash que escribió, actualizado solo después de que la entrada se añade de forma duradera. Al inicio los dos se comparan, y se informa de un desacuerdo: entradas faltantes al final, una última entrada reescrita, o un registro que ha desaparecido por completo. Esto no es prueba de manipulación — un apagado sucio se ve igual — pero un rastro evidente de manipulación te debe la pregunta, no el veredicto. Mantén el sidecar con el registro cuando lo archives o lo muevas; eliminarlo solo pierde la comprobación de truncamiento, nunca una entrada. El verificador fuera de línea no cambia y aún necesita un ancla externa: --verify-audit <file> --expect-last <hash>. verificado (aceptación: "un registro acortado se nota al inicio, sin ningún ancla externa")

  • Un ajuste incorrecto es fatal, no solo incorrecto: una dirección de escucha no analizable, un archivo de auditoría que no se puede escribir, sslmode=disable a una base de datos en otra máquina, un token de métricas que también es la credencial de la base de datos, un booleano escrito como yes — cada uno solía aceptarse y hacer silenciosamente algo distinto de lo que se pretendía. El inicio ahora se detiene y nombra el ajuste.

  • Un ajuste mal escrito es fatal — el ajuste de otra persona no lo es: MCP_REDACT_COLUMN (singular) solía iniciar el servidor con la redacción silenciosamente desactivada, así que un casi fallo de un ajuste real aún detiene el inicio y nombra la ortografía prevista. Un nombre que no se parece a nada que definimos fue establecido por otro programa que comparte el entorno: se informa e ignora. mcp-proxy, que cada catálogo pone delante de un servidor para inspeccionarlo, exporta MCP_PROXY_DEBUG — hasta 0.1.6 esa única variable hacía que este servidor saliera antes de leer una solicitud. MCP_X_* permanece reservado para el uso propio del operador. verificado (aceptación: "un error tipográfico sigue siendo fatal")

  • Sin fugas de esquema: los errores de base de datos se mapean a mensajes estructurados y accionables que nunca repiten nombres de tablas/columnas.

  • OAuth 2.1: validación opcional de token portador RS256 (firma, exp, aud, iss) con aplicación de alcance; deshabilitado cuando no está configurado para uso local/autoalojado.

  • Auditoría: cada decisión de herramienta se registra como una línea JSON encadenada por hash y evidente de manipulación (sin SQL crudo).

  • Cadena de suministro: licencias de dependencias, fuentes y avisos aplicados en CI (cargo deny, cargo audit); un SBOM CycloneDX se adjunta a cada lanzamiento.

  • Tiempo de ejecución: se distribuye como un contenedor distroless, no root — 14.8 MB para descargar, 41 MB en disco para linux/amd64 en 0.1.6, construido y probado con humo en CI. Ambos números, porque uno solo siempre es el halagador: docker images muestra el segundo, tu ancho de banda paga el primero.

Huella

Medido en un VPS ordinario contra una base de datos de muestra de 16k filas, para que puedas comprobar la afirmación de "escrito en Rust" en lugar de tomarla:

Memoria residente, inactivo7.7 MB — mediana de cinco inicios separados, todos dentro de 0.1 MB entre sí
Memoria residente, después de 200 solicitudes9.4 MB, y plana después
Latencia mediana de solicitud~8 ms — incluyendo el proceso curl que la medición genera, así que la parte del servidor es menor
Inicio a primera sentencia validada5 ms — mediana de cinco ejecuciones de --validate, 5 a 7 ms observados
Binario9.2 MB (linux x86_64, 0.1.7 en adelante). Nada que instalar junto a él — sin Node, sin Python, sin biblioteca compartida que enviemos. No está estáticamente enlazado: como cualquier objetivo -gnu usa el libc, libm y libgcc_s del sistema.
Imagen de contenedor12.6 MB comprimido, 31.8 MB descomprimido (linux/amd64, 0.1.7), distroless, no root

Dos líneas aquí estaban mal hasta 0.1.7. La memoria inactiva decía 5.2 MB y mide 7.7 — cinco inicios bajo condiciones idénticas cayeron dentro de 0.1 MB entre sí, así que la cifra antigua no es ruido, es una medición diferente cuyo método no se anotó. La línea del binario decía "11 MB, estático", y el archivo que la gente realmente descargó era 18.9 MB y enlazado dinámicamente. El tamaño nunca se midió en una compilación de lanzamiento, porque esta crate no tenía [profile.release] en absoluto, así que más de cinco megabytes de símbolos de depuración se enviaron a cada usuario. Establecer strip, lto y codegen-units = 1 lo llevó a 9.2 MB. La palabra "estático" simplemente no era cierta de ninguno de los cinco objetivos que publicamos, ninguno de los cuales es una compilación musl. La imagen se redujo con el binario, de 14.8 MB comprimido en 0.1.6 a 12.6 MB — ambas cifras leídas del manifiesto del registro de la imagen publicada en lugar de una compilación local, porque una compilación local no es lo que nadie descarga.

Una prueba de doce minutos de tráfico mixto (lecturas, rechazos, errores, solicitudes abortadas, rotación de sesiones, solicitudes no autenticadas) sirvió 51,499 solicitudes y terminó con los mismos 15 descriptores de archivo abiertos con los que comenzó. La memoria residente pasó de 8.1 MB a 11.2 MB, y la forma de eso es la parte interesante: 8.1 a 10.7 ocurrió dentro de las primeras 400 solicitudes, y las 51,000 restantes añadieron 0.5 MB en una curva que se aplanó a medida que avanzaba. Eso es un asignador asentándose, no una fuga. Esta página solía decir que la memoria permanecía "plana", lo cual era cierto de todo después de los primeros segundos y no cierto del número, así que aquí está el número.

Reprodúcelo con tests/soak.sh en lugar de creer el párrafo.

Configuración

EnvPropósito
DATABASE_URLCadena de conexión de PostgreSQL (usar un rol de solo lectura)
MCP_ADDRDirección de escucha HTTP (por defecto 127.0.0.1:8080)
MCP_MAX_COSTRechazar consultas cuyo costo de EXPLAIN supere este valor (por defecto 1,000,000)
JWT_PUBKEY_PEM, JWT_AUD, JWT_ISSHabilitar validación de token OAuth 2.1 (omitir para desactivar autenticación); la clave puede ser el texto PEM o una ruta a un archivo PEM
MCP_AUDIT_LOGRuta al registro de auditoría de solo anexión (encadenado por hash); verificar con --verify-audit <file> [--expect-last <hash>]. El servidor también escribe <log>.hwm junto a él: el último número de secuencia y hash, usados al inicio para detectar un registro truncado
MCP_AUDIT_HMAC_KEY / MCP_AUDIT_HMAC_KEY_FILEClave que convierte la cadena de auditoría en HMAC-SHA256 — mantenla fuera del host para que el registro no pueda reescribirse (una nueva línea final en el archivo se ignora)
MCP_AUDIT_HMAC_KEYS_OLDClaves anteriores separadas por comas, para que un registro que sobrevivió a una rotación de claves aún se verifique. verificado (aceptación: "una cadena que abarca una rotación de claves se verifica con ambas claves")
MCP_REDACT_COLUMNSColumnas a excluir de los resultados, p. ej. password, ssn, card_number — enmascaradas a cualquier profundidad y rechazadas si se referencian. Defensa en profundidad, no un límite: combínalo con REVOKE SELECT (col)
MCP_BEARER_TOKENToken compartido requerido en cada solicitud, para despliegues sin proveedor de identidad. Se ignora cuando OAuth está configurado — aceptarlo como alternativa daría a su titular alcance completo y dejaría la auditoría sin identidad
MCP_STATEMENT_TIMEOUTLímite de tiempo de consulta (intervalo de PostgreSQL, por defecto 30s)
MCP_SEARCH_PATHEsquemas a buscar cuando un nombre de tabla no está calificado, p. ej. analytics, public
MCP_PASSWORD_FILELeer la contraseña de la base de datos desde un archivo en lugar de ponerla en la cadena de conexión
MCP_DATABASE_URLSVarias bases de datos desde un servidor: prod=postgres://…;dev=postgres://… (las herramientas entonces toman un argumento database)
MCP_SERVER_LABELNombre mostrado en la interfaz de cliente, p. ej. production → postgres-mcp-hardened (production)
MCP_SHOW_PARTITIONS1 para listar también los hijos de particiones (ocultos por defecto)
MCP_ALLOW_FUNCTIONSFunciones de catálogo separadas por comas a permitir que no sabemos que sean de solo lectura
MCP_SSLROOTCERTRuta a un paquete de CA PEM para TLS hacia PostgreSQL (p. ej. el paquete de AWS RDS); las raíces del sistema y de Mozilla se confían por defecto
MCP_MAX_INFLIGHT_PER_CLIENTMáximo de solicitudes concurrentes de un cliente (por defecto 4; 0 desactiva)
MCP_RATE_RPMLímite de tasa de solicitudes por cliente (por defecto 120/min; 0 desactiva)
MCP_RATE_RPM_STDIOEl mismo límite para stdio (por defecto 600/min): un agente explorando un esquema legítimamente hace docenas de llamadas por minuto, pero un bucle descontrolado contra una base de datos de producción sigue siendo lo que más teme un DBA
MCP_CLIENT_IDUn nombre para este cliente en el registro de auditoría a través de stdio, p. ej. claude-desktop@ada-laptop; sin él, la identidad cae al usuario y proceso del sistema operativo
MCP_RATE_BURSTMargen de ráfaga para ese límite (por defecto MCP_RATE_RPM / 4, mínimo 5)
MCP_METRICS_TOKENToken requerido en /metrics. Sin él, /metrics sigue lo que el propio servidor requiera: abierto cuando el servidor no tiene autenticación, el token portador cuando se establece uno, y cerrado cuando OAuth está configurado (un JWT tiene la forma incorrecta para un raspador — establece esto en su lugar)
MCP_REDACT_REQUIRE_REVOKE1 para negarse a servir mientras la base de datos aún permita al rol leer una columna redactada — convierte la configuración anterior de recomendación a garantía
MCP_STRUCTURED_CONTENT1 para también devolver MCP structuredContent; desactivado por defecto porque un cliente que lo ignora paga por cada resultado dos veces. El marcador de procedencia viaja dentro del objeto, pero el escape de delimitadores que protege el bloque de texto no aplica — un cliente que pega salida estructurada directamente en un prompt pierde esa capa
MCP_RESERVED_AUTH_SLOTSRanuras de base de datos reservadas para tráfico autenticado para que una inundación anónima no pueda tomar el grupo (por defecto: un cuarto)
MCP_PUBLIC_URLURL base pública de este servidor, usada en los metadatos de descubrimiento OAuth
MCP_AUTH_SERVERSURLs del servidor de autorización anunciadas en esos metadatos
MCP_PROTOCOL_PREVIEWRetirado. Controlaba 2026-07-28 mientras esa revisión era un borrador; upstream lo publicó el 2026-08-03 y el servidor ahora lo habla por defecto. El nombre sigue reconocido para que una línea de configuración existente no se reporte como error ortográfico, y el inicio dice una vez que ya no hace nada
MCP_ALLOW_SCHEMASEsquemas a los que una consulta puede llegar, p. ej. public,analytics; establecer esto o lo siguiente activa la lista de permitidos
MCP_ALLOW_TABLESRelaciones a las que una consulta puede llegar, p. ej. public.orders,analytics.*
MCP_ALLOW_CATALOG1 para mantener pg_catalog alcanzable mientras una lista de permitidos está activa
MCP_ALLOW_PLAINTEXT_DBEstablecer a i-accept-the-risk para permitir sslmode=disable a una base de datos que no está en esta máquina
MCP_ALLOW_EXCESSIVE_ROLEEstablecer a i-accept-the-risk para servir un listener de red con un rol que pueda escribir
MCP_ALLOW_ANONYMOUS_NETWORKEstablecer a i-accept-the-risk para servir un listener de red sin autenticación
MCP_ALLOWED_ORIGINSOrígenes de navegador permitidos para llamar a este servidor, p. ej. https://my-client.example. Vacío significa que ninguna página de navegador puede alcanzarlo: una página que el usuario solo visita puede hacer que su navegador haga POST a localhost, que es todo el truco del DNS rebinding
MCP_ALLOWED_HOSTSValores extra de Host aceptados al escuchar en loopback (localhost y 127.0.0.1 siempre lo están)
MCP_FUZZ_VERBOSESolo desarrollo: hace que --fuzz imprima cada mutación que intentó
MCP_TRUST_PROXYEstablecer a 1 solo detrás de un proxy inverso — entonces el limitador de tasa usa X-Forwarded-For en lugar de la dirección del par

Licencia

MIT — ver LICENSE. El núcleo es MIT y seguirá siéndolo.

Uso comercial y en equipo

Apuntar esto a una base de datos de producción dentro de una organización plantea preguntas que el núcleo MIT no responde: política vinculada a una identidad en lugar de a un alcance, un registro de auditoría enviado a algún lugar donde no pueda editarse silenciosamente, evidencia que un revisor de cumplimiento aceptará, un despliegue del que alguien es responsable.

Una edición para equipos que cubra esos puntos se está definiendo ahora mismo, y lo que incluirá no está decidido. Si eso es lo que tu organización necesita, escribe a eskulapstudio@gmail.com y di qué tendría que incluir. Aún no hay nada que comprar — las respuestas son lo que decide si se construye en absoluto, y en qué orden.