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;
.mcpbpaquetes para instalación con un clic; una imagen enghcr.iopara 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
USINGrespondí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.salariesfue rechazado mientrasdescribe_tableentregaba cada columna, tipo y valor predeterminado de esa misma tabla. Una cadena de conexión ordinaria sinsslmodeusaba elpreferdel controlador, que envía todo en claro siempre que el servidor rechace TLS, y cualquiera en el cable puede hacer que lo rechace. YMCP_SSLROOTCERTañ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 unstatement_timeoutde 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.mdcon 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 deSET TRANSACTION READ ONLYy 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 aDISCARD ALL, y con elfast => falsepredeterminado espera un checkpoint distribuido mientras fuerzafull_page_writes, lo cual es un costo real en un servidor ocupado.pg_import_system_collations()también se ejecuta sin lanzarSQLSTATE 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 thereAmbos 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 archivado | postgres-mcp-hardened | |
|---|---|---|
| Aplicación de solo lectura | BEGIN TRANSACTION READ ONLY + ROLLBACK — una capa, y PostgreSQL deja pasar algunas escrituras a través de ella | Validació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 CTE | llega a la base de datos y es detenido solo por la transacción | rechazado por el analizador, antes de llegar a la base de datos |
| Tiempo de espera de declaración | ninguno | statement_timeout + idle_in_transaction_session_timeout aplicados |
| Consultas descontroladas / costosas | se ejecutan sin límite | guardián de costo EXPLAIN las rechaza antes de la ejecución |
| Inyección de prompts vía datos de fila | salida cruda | trusted="false" envuelto + escape de delimitadores |
| Mensajes de error | filtran esquema (relation X does not exist) | estructurados, sin filtraciones, accionables |
| Autenticación | ninguna | OAuth 2.1 (RS256 JWT, alcance + audiencia + emisor) |
| Auditoría | ninguna | registro encadenado por hash a prueba de manipulación |
| Esquema como recursos MCP | ✅ | ✅ — más comentarios, claves primarias y foráneas |
| Pruebas / CI | ninguna | suites 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 |
| Transporte | stdio / SSE desaprobado | Streamable 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-cayverify-fullson todos aceptados (la cadena de certificados y el nombre de host siempre se verifican, por lo querequirese comporta comoverify-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, apuntaMCP_SSLROOTCERTal paquete PEM. No existe un interruptor de "confiar en todo".Consejo: apunta
DATABASE_URLa 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.
| Endpoint | Método | Propósito |
|---|---|---|
/mcp | POST | el endpoint MCP (HTTP Streamable). DELETE finaliza una sesión. |
/ | GET | sonda de preparación; de lo contrario, una señal que nombra el endpoint real |
/health | GET | el proceso está vivo |
/ready | GET | el proceso y una conexión a la base de datos están disponibles |
/metrics | GET | contadores (necesita MCP_METRICS_TOKEN) |
/.well-known/mcp/server-card.json | GET | lo 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 una | Los 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 comandos | MCP_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 off | El error dice que el servidor requiere TLS y nombra la solución (?sslmode=require) |
Solo lectura eludida inyectando COMMIT / END | Rechazado — 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 Node | Un único binario nativo; sin Node, sin npx, sin node_modules |
| Se cuelga indefinidamente contra RDS sin salida ni error | Acotado: un host inalcanzable responde en ~8 s con el motivo, nunca en silencio |
self-signed certificate in certificate chain | Apunta MCP_SSLROOTCERT al paquete de CA; el error nombra esa variable |
| Cadena de conexión solo como argumento de línea de comandos | DATABASE_URL o el argumento posicional — la invocación original sigue funcionando |
INVALID_URL con caracteres especiales en la contraseña | El error dice qué caracteres codificar en porcentaje, y cómo |
| Los hijos de partición inundan las listas de tablas y recursos | Ocultos por defecto; MCP_SHOW_PARTITIONS=1 los trae de vuelta |
-32601 Method not found, Unexpected end of JSON input | ping 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 contexto | Auto-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ónCOMMIT/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_TIMEOUTes 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_FILEmantiene la contraseña fuera de ella. - Tablas en un esquema no predeterminado silenciosamente no encontradas.
MCP_SEARCH_PATHcorrige la búsqueda, y las herramientas aceptan unschemaexplí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.
| Proveedor | Qué esperar |
|---|---|
| Supabase | CA 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 / Aurora | CA 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 SQL | CA 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 PostgreSQL | CA 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. |
| Neon | CA pública (ISRG Root X1, Let's Encrypt) — nada que descargar. Los endpoints agrupados y directos funcionan ambos. |
| DigitalOcean | CA 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; conanalyzeejecuta 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 unsummary: 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, desdepg_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 deinitialize, 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, conLIMITautomático, con límite de coste). La respuesta indica lo que hizo:returnedRows,appliedLimit,truncated, másrequestedLimitcuando una solicitud mayor se limitó al máximo de 10000 filas,offsetal paginar, yredactedColumnscuando 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_tabledevuelve 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:
| consulta | controlador | este servidor | diferencia |
|---|---|---|---|
| búsqueda puntual | 0.43 ms | 5.9 ms | +5.5 ms |
| escaneo pequeño | 1.0 ms | 8.3 ms | +7.3 ms |
| agregado | 4.7 ms | 11.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
sqlparsery se rechaza a menos que sea unSELECT/WITH/EXPLAIN/SHOW; la sesión de BD se establece además endefault_transaction_read_only = on. -
Anti-DoS:
statement_timeoutaplicado,LIMITauto-inyectado, y un guardián de costo basado enEXPLAINque rechaza planes costosos antes de que se ejecuten. -
Columnas sensibles — defensa en profundidad, y honesta al respecto:
MCP_REDACT_COLUMNSenmascara 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 — conMCP_REDACT_COLUMNS=ssn,SELECT ssn FROM peoplefue rechazado mientras la página cruda volvía con los números de seguro social en ASCII plano.pageinspecty 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_statslos publica: con 3,000 filas,SELECT * FROM pg_stats WHERE tablename='people'returned{123-45-6789,555-00-1111,987-65-4321}whileSELECT ssn FROM peoplefue 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_fracycorrelationson 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_16384devolvió el texto redactado en claro.pg_largeobjectes lo mismo para objetos grandes — los bytes debajo delo_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=1convierte ese informe en una negativa a ejecutar. Ten en cuenta que la corrección es unREVOKEa nivel de tabla seguido de unGRANTde las columnas que permanecen — unREVOKE SELECT (password) ON staffdesnudo es silenciosamente una no-operación mientras el rol tenga SELECT sobre toda la tabla. Con concesiones a nivel de columna, PostgreSQL entonces rechazaSELECT *en esa tabla, así que los llamantes nombran columnas en su lugar;describe_tablelas 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-sqlescribe 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 depg_write_all_datay 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 literali-accept-the-riskpara 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
Originse rechaza con 403 a menos que el operador haya listado ese origen, y en un listener de loopback unHostque 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
startupque 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 unconfig_fpque 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_LOGel 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=disablea 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 comoyes— 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, exportaMCP_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/amd64en 0.1.6, construido y probado con humo en CI. Ambos números, porque uno solo siempre es el halagador:docker imagesmuestra 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, inactivo | 7.7 MB — mediana de cinco inicios separados, todos dentro de 0.1 MB entre sí |
| Memoria residente, después de 200 solicitudes | 9.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 validada | 5 ms — mediana de cinco ejecuciones de --validate, 5 a 7 ms observados |
| Binario | 9.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 contenedor | 12.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
| Env | Propósito |
|---|---|
DATABASE_URL | Cadena de conexión de PostgreSQL (usar un rol de solo lectura) |
MCP_ADDR | Dirección de escucha HTTP (por defecto 127.0.0.1:8080) |
MCP_MAX_COST | Rechazar consultas cuyo costo de EXPLAIN supere este valor (por defecto 1,000,000) |
JWT_PUBKEY_PEM, JWT_AUD, JWT_ISS | Habilitar 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_LOG | Ruta 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_FILE | Clave 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_OLD | Claves 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_COLUMNS | Columnas 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_TOKEN | Token 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_TIMEOUT | Límite de tiempo de consulta (intervalo de PostgreSQL, por defecto 30s) |
MCP_SEARCH_PATH | Esquemas a buscar cuando un nombre de tabla no está calificado, p. ej. analytics, public |
MCP_PASSWORD_FILE | Leer la contraseña de la base de datos desde un archivo en lugar de ponerla en la cadena de conexión |
MCP_DATABASE_URLS | Varias bases de datos desde un servidor: prod=postgres://…;dev=postgres://… (las herramientas entonces toman un argumento database) |
MCP_SERVER_LABEL | Nombre mostrado en la interfaz de cliente, p. ej. production → postgres-mcp-hardened (production) |
MCP_SHOW_PARTITIONS | 1 para listar también los hijos de particiones (ocultos por defecto) |
MCP_ALLOW_FUNCTIONS | Funciones de catálogo separadas por comas a permitir que no sabemos que sean de solo lectura |
MCP_SSLROOTCERT | Ruta 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_CLIENT | Máximo de solicitudes concurrentes de un cliente (por defecto 4; 0 desactiva) |
MCP_RATE_RPM | Límite de tasa de solicitudes por cliente (por defecto 120/min; 0 desactiva) |
MCP_RATE_RPM_STDIO | El 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_ID | Un 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_BURST | Margen de ráfaga para ese límite (por defecto MCP_RATE_RPM / 4, mínimo 5) |
MCP_METRICS_TOKEN | Token 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_REVOKE | 1 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_CONTENT | 1 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_SLOTS | Ranuras 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_URL | URL base pública de este servidor, usada en los metadatos de descubrimiento OAuth |
MCP_AUTH_SERVERS | URLs del servidor de autorización anunciadas en esos metadatos |
MCP_PROTOCOL_PREVIEW | Retirado. 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_SCHEMAS | Esquemas a los que una consulta puede llegar, p. ej. public,analytics; establecer esto o lo siguiente activa la lista de permitidos |
MCP_ALLOW_TABLES | Relaciones a las que una consulta puede llegar, p. ej. public.orders,analytics.* |
MCP_ALLOW_CATALOG | 1 para mantener pg_catalog alcanzable mientras una lista de permitidos está activa |
MCP_ALLOW_PLAINTEXT_DB | Establecer a i-accept-the-risk para permitir sslmode=disable a una base de datos que no está en esta máquina |
MCP_ALLOW_EXCESSIVE_ROLE | Establecer a i-accept-the-risk para servir un listener de red con un rol que pueda escribir |
MCP_ALLOW_ANONYMOUS_NETWORK | Establecer a i-accept-the-risk para servir un listener de red sin autenticación |
MCP_ALLOWED_ORIGINS | Orí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_HOSTS | Valores extra de Host aceptados al escuchar en loopback (localhost y 127.0.0.1 siempre lo están) |
MCP_FUZZ_VERBOSE | Solo desarrollo: hace que --fuzz imprima cada mutación que intentó |
MCP_TRUST_PROXY | Establecer 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.