postgres-mcp-hardened
Description
Maintained Rust replacement for the archived @modelcontextprotocol/server-postgres. Writes are refused twice: sqlparser AST validation before execution, plus database-level default_transaction_read_only and a per-session statement_timeout. Single binary, stdio and Streamable…
About
Maintained Rust replacement for the archived @modelcontextprotocol/server-postgres. Writes are refused twice: sqlparser AST validation before execution, plus database-level default_transaction_read_only and a per-session statement_timeout. Single binary, stdio and Streamable HTTP, schema inspection, column redaction…
Details
- Author
- eszetael
- Categories
- Database, Security
Jump to
Setup
Install postgres-mcp-hardened in your MCP client (Claude Desktop, Cursor, Windsurf, and others).
Repository: https://github.com/eszetael/postgres-mcp-hardened
Follow the installation instructions in the repository README, then restart your MCP client.
postgres-mcp-hardenedvs the archived original
Five ways in, in the order most people want them.
One click, for a client that accepts.mcpbbundles: downloadpostgres-mcp-hardened-<your-platform>.mcpbfrom the](https://github.com/Eszetael/postgres-mcp-hardened/blob/HEAD/SECURITY.md)latest releaseand open it. The bundle asks for the connection string and stores it in the OS keychain rather than in a plain-text config file. Nothing to install, nothing to edit.
Through npm— shortest, and the one your MCP client config can point at directly. There is no Node runtime involved at run time: the package is a launcher that fetches the native binary for your platform and verifies its checksum before running it.
{ "mcpServers": { "postgres": { "command": "npx", "args": ["-y", "postgres-mcp-hardened", "--stdio"], "env": { "DATABASE_URL": "postgres://readonly_user:PASSWORD@localhost:5432/mydb" } } } }
The connection string goes inenv, not inargs, on purpose: arguments show up inpsoutput and in shell history on a shared machine, and a database password does not belong there.
A binary from the releases page— one file, nothing to keep up to date, and the option to take if your machine has no Node at all. (Not astaticbinary, as this page claimed until 0.1.7: the-gnuand macOS targets link the system C library like any other native program. There is simply nothing to install alongside it.) Every release carries builds for Linux, macOS and Windows on x86-64 and arm64, each with a checksum and a signature; verifying them is the next section.
As a container, if that is how you run things. The image is distroless and runs as a non-root user, and the same signatures cover it as cover the binaries.
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
--memoryis not decoration. The server idles at 7.7 MB and a normal request costs single-digit megabytes, but a caller can writeSELECT repeat('x', 100000000)and drive peak memory to 400 MB — not through the result, which stays bounded at 300 bytes, but through the cost guard's ownEXPLAIN, which PostgreSQL fills with the constant it folded while planning. That is a named residual risk inTHREAT_MODEL.md, with the three repairs that were tried and what each one broke. Until it is closed, the memory limit is the thing that holds, so set one:--memoryhere,MemoryMax=under systemd.
MCP_ADDRmust bind0.0.0.0and not127.0.0.1, or the server listens on an interface that only exists inside the container and the published port answers nothing. The other easy one:localhostinDATABASE_URLmeansthe container, not your machine, so a PostgreSQL running on the host needshost.docker.internal(Docker Desktop) or the host's address on the bridge (172.17.0.1by default on Linux). Both of these were walked end to end against the published image before being written here, including that a read returns rows andDROP TABLEcomes back as-32602 non-read-only statement: Drop.
From source—cargo build --releasein a clone. Notcargo install: this crate is not on crates.io, and an instruction that fails is worse than one that is missing.
Every released binary is signed withSigstorekeyless signing — there is no private key for us to lose, and the certificate names the workflow, repository and tag that produced the file. Each artefact ships with a.sigand a.pembeside it:
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
Pin the identity, not just the signature. Without--certificate-identity-regexpand--certificate-oidc-issuerthe check answers "somebody signed this", which is not the question. A verified certificate names the workflow, the repository and the tag that built the file — you can read it withbase64 -d "$F.pem" | openssl x509 -noout -text(cosign writes the certificate base64-encoded, which surprises people who tryopensslon it directly).
Older cosign builds predate--bundle; separate.sigand.pemfiles are published alongside for them, used as--signature "$F.sig" --certificate "$F.pem". Current cosign marks those flags deprecated, so prefer the bundle.
Public releases additionally carry SLSA build provenance, verifiable withgh attestation verify <file> --repo Eszetael/postgres-mcp-hardened.
Use it in Claude Desktop / Cursor (stdio)
{ "mcpServers": { "postgres": { "command": "postgres-mcp-hardened", "args": ["--stdio"], "env": { "DATABASE_URL": "postgres://readonly_user:YOUR_PASSWORD@localhost:5432/mydb" } } } }
Or run it as a remote server (Streamable HTTP)
DATABASE_URL="postgres://readonly_user:YOUR_PASSWORD@host:5432/mydb" \ MCP_ADDR="0.0.0.0:8080" \ postgres-mcp-hardened # POST /mcp · GET /health · GET /ready · GET /metrics
TLS:connections to PostgreSQL are encrypted whenever the server supports it, andsslmode=require,verify-caandverify-fullare all accepted (the certificate chainandthe hostname are always verified, sorequirebehaves likeverify-full) — so managed Postgres (RDS, Supabase, Neon, Render) works out of the box. Certificates and host names arealways verified—verified(acceptance: "a certificate naming another host is refused, by name"); for a private CA, pointMCP_SSLROOTCERTat the PEM bundle. There is no "trust anything" switch.
Tip:pointDATABASE_URLat aleast-privilege read-only role. The server enforces read-only itself, but a scoped DB role is defense-in-depth.
Or run it on a container platform (Apify Standby)
The server needs no code changes to run as an Apify Actor in Standby mode. It reads the port the platform assigns fromACTOR_WEB_SERVER_PORTand binds0.0.0.0there — that port wins overMCP_ADDR, loudly, on stderr, because binding anywhere else means the run is never marked ready and the failure looks like a mysterious timeout.GET /answers the platform's readiness probe (x-apify-container-server-readiness-probe) without touching the database: container readiness is not database readiness, and a probe that waits on a busy pool turns a slow database into a container that never starts.
Inputis a JSON-RPC request in the POST body —initialize,tools/list,tools/call,resources/list,resources/read,server/discover.Outputis a JSON-RPC response; from2025-11-25a refused statement comes back as a tool execution error (isError: true) with the reason in the content, so the model can rewrite the query.tools/listis the authoritative description of every argument.
Authentication there is the platform's, not ours.Apify checks the caller's token before routing to the container, so the server does not additionally demandMCP_BEARER_TOKEN— requiring a second secret would mean an agent that finds this server cannot call it. That exemption is narrow: it needsbothAPIFY_IS_AT_HOMEandACTOR_WEB_SERVER_PORT, one alone changes nothing, and the server card then reports"type": "apify-platform"rather than claiming a lock we do not hold. Everywhere else the server still refuses to start on a network address with no authentication. SetMCP_BEARER_TOKENas well if you want a second lock on the same door.
The other start gate is unchanged and matters more here: a role that can write is refused a network listener. PointDATABASE_URLat a read-only role —--print-setup-sqlwrites the statements.
The most-discussed problems reported against@modelcontextprotocol/server-postgreswere reproduced against this server; here is how each behaves:
Beyond unit tests, the repository carries two harnesses that run in CI on every change:
- --fuzz— a deterministic fuzzer that mutates a corpus of known writes with transformations that do not change SQL meaning (comments, case, dollar-quoting, invisible Unicode, parentheses) and asserts that none of them ever becomes an allowed statement.
- tests/acceptance.sh— an end-to-end suite that starts its own PostgreSQL and checks 312 behaviours: every write-bypass reported against the deprecated server (including theCOMMIT/ENDinjection), truthful results, schema introspection, protocol conformance, configuration mistakes failing loudly, audit tamper detection, fair use under load, and multi-database deployments.
Every server in this space has an issue tracker, and those trackers are a map of what goes wrong. The ones we deliberately built against:
- A published image that lags the code.The most-supported open complaint against the leading alternative. Our container is built and pushed from the same tag that produces the binaries, so it cannot drift.
- A hardcoded query timeout.Also among their most requested settings.MCP_STATEMENT_TIMEOUTis configurable and validated at startup.
- Unrestricted access by default.Some servers default to read/write and rely on the operator to restrict it. This one has no write path at all.
- Credentials in the client configuration.MCP_PASSWORD_FILEkeeps the password out of it.
- Tables in a non-default schema silently not found.MCP_SEARCH_PATHfixes the lookup, and the tools take an explicitschemaanyway.
- Deprecated transport.HTTP+SSE was replaced by Streamable HTTP in2025-03-26, three revisions ago (this page said 2025-06-18 until 0.1.7, which was wrong by one revision; the specification's own changelog for 2025-03-26 records the replacement). We speak the current transport.
Answers to the questions people actually asked about the deprecated server, so nobody has to open an issue to find them.
spawn npx ENOENT/ "which Node version do I need?"— none. This is a single native binary, with no runtime to install beside it. Download it from the releases page and point your client at the file. There is nonode_modules, nonpx, nothing to keep up to date.
"The server starts but nothing is listening on a port."— that is stdio mode, which is correct for Claude Desktop and Cursor: the client talks to the process over its standard input and output, not over a socket. If you want a network endpoint, start it without--stdio; it then printsMCP HTTP listening on http://…and speaks Streamable HTTP.
"Can my client on another machine reach the database?"— yes: run the server next to the database in HTTP mode, expose it, and enable OAuth (JWT_PUBKEY_PEM,JWT_AUD,JWT_ISS). The database credentials then never leave the host the server runs on.
"Could not attach to MCP server."— the process exited before the handshake. Run the same command in a terminal: a configuration mistake prints its reason and exits with status 2 rather than dying quietly, and a connection problem is reported on the first query with the cause.
self-signed certificate in certificate chain/unable to verify the first certificate— your provider uses a private CA (Supabase, GCP and RDS all do). Download their CA bundle and setMCP_SSLROOTCERTto it. The error message names the step for your provider. We do not offer a "trust anything" switch.
This serveralways verifies the database certificate, including withsslmode=require. That is a deliberate deviation from libpq, whererequireencrypts without verifying and a machine in the middle can therefore read and rewrite every query and result without anyone noticing. The cost of being strict is that a provider with a private CA needs one extra step; the cost of being lax is that you never find out. If you disagree with the trade-off,verify-fullwith the bundle below is the same amount of work and leaves no doubt either way.
Not sure which case you are in? Ask the server itself, before configuring anything:
echo | openssl s_client -starttls postgres -connect YOUR_HOST:5432 2>/dev/null \ | openssl x509 -noout -issuer
A well-known issuer (DigiCert, ISRG, Google Trust Services) means it will just work; anything naming your provider means you need their bundle.
With a connection pooler (Supavisor, PgBouncer) in transaction mode, note that this server setsstatement_timeoutandidle_in_transaction_session_timeoutper session and runs every query in an explicit read-only transaction. Both are compatible with transaction pooling; session-levelSEToutside a transaction is not, which is why we do neither.
no pg_hba.conf entry … no encryption— the server accepts only TLS connections for that host and user. Add?sslmode=requireto the connection string.
INVALID_URL/invalid connection string— a password containing@,:,/,#or?must be percent-encoded (@→%40,:→%3A,/→%2F,#→%23).
"My table has hundreds of partitions and the list is unusable."— partition children are hidden by default; the parent is listed. SetMCP_SHOW_PARTITIONS=1if you need them.
"I need production and staging at the same time."— either run one server per database (they are distinguishable: setMCP_SERVER_LABEL), or configure both in one server withMCP_DATABASE_URLSand passdatabasein the tool arguments.
Every table and view is exposed as an MCPresource(postgres:///<schema>/<table>/schema), so a client can browse the schema without issuing a query — the same capability the deprecated server offered, plus column comments, primary keys andforeign keysin the payload.
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.


