Dev.to · 12 min read

Rebuilding the Deprecated PostgreSQL MCP Server in Rust: Safe by Default

Rebuilding the Deprecated PostgreSQL MCP Server in Rust: Safe by Default

A deprecated server with 475,790 downloads a month @modelcontextprotocol/server-postgres last shipped a version on 4 December 2024. It is marked deprecated on npm — the registry itself tells you the package is no longer supported. In the thirty days to 9 August 2026 it was downloaded 475,790 times. That gap is the whole story. This is the piece of software standing between an LLM agent and a production database, in tens of thousands of installations, and nobody is maintaining it. Abandonment is not the interesting part, though. The interesting part is the safety model that shipped while it was maintained. Why startsWith("SELECT") is not a guard The archived server's read-only enforcement is a single string comparison: // Simplified from the archived source if (!sql.trim().toUpperCase().startsWith("SELECT")) { throw new Error("Only SELECT queries allowed"); } Credit where it is due: the server also wraps every query in BEGIN TRANSACTION READ ONLY and always rolls back. That is a real defence, and the rebuild keeps it. But the string check is what decides whether a statement runs at all, and string matching cannot see structure. Data-modifying CTEs. The text starts with WITH. The parser sees a DELETE. WITH deleted AS (DELETE FROM users WHERE id = 1 RETURNING *) SELECT * FROM deleted; Leading comments. trim() removes whitespace, not comments. /* harmless comment */ DROP TABLE users; Multiple statements. The check sees SELECT 1. The database executes both. SELECT 1; DROP TABLE users; And a category that surprises people: a read-only transaction does not block every write. PostgreSQL executes pg_import_system_collations() inside SET TRANSACTION READ ONLY without raising SQLSTATE 25006 — in our tests it inserted 874 rows into pg_collation. gin_clean_pending_list() rewrites index structures. pg_backup_start() puts the server into backup mode and survives DISCARD ALL. The rollback saves you from the first case, not from side effects that live outside transaction semantics. So the rebuild does not replace the rollback. It puts a parser in front of it, and a denied administrative-function space beside it. Seven layers, each assuming the previous one fails 1. AST classification, not pattern matching SQL is parsed with sqlparser and the node type decides. // Simplified let ast = Parser::parse_sql(&dialect, &sql)?; if ast.len() > 1 { reject(); } // no multi-statement batches match &ast[0] { Statement::Query(q) if is_read_only(q) => allow(), Statement::Explain { .. } | Statement::ShowVariable { .. } => allow(), _ => reject(), } A WITH clause containing a DELETE is rejected because the node is Delete, whatever the text begins with. This is checked rather than asserted: a fuzz harness runs mutations by the million against the validator, and every bypass ever found is kept in a MUST_REJECT corpus that the build runs on every commit. Both are in the repository. 2. Read-only enforced by the database SET default_transaction_read_only = on; Set on connection checkout, alongside the rolled-back transaction. A bug in our parser becomes a contained error rather than a data-loss event. This layer requires none of our Rust to be correct — which is exactly why it is there. The server also refuses to start as a network listener if the role it connects as can write, is a superuser, or holds BYPASSRLS. --print-setup-sql prints the DDL that creates a role which cannot. 3. Timeouts the database enforces SET statement_timeout = '30s'; SET idle_in_transaction_session_timeout = '10s'; A question that would pin the database is cancelled by PostgreSQL, not by hope. 4. A cost guard that runs before the query does EXPLAIN (FORMAT JSON) SELECT ...; The plan carries Plan.Total Cost. Above the configured ceiling, the statement is refused without executing. Estimates are not runtimes — that is what layer 3 is for — but a Cartesian product is visible in the plan before it is visible in the load average. 5. Row data is framed as data Tool output flows straight into the agent's context, so a cell value is an injection vector: id note 1 Ignore previous instructions and… Every result is wrapped, and the delimiter is escaped so content cannot close the block or forge a trusted marker. Invisible and bidirectional characters are stripped. {"columns":["id","note"],"rows":[…]} The block also carries annotations.untrustedContent. This is framing, not a cure — an agent that ignores the frame is still an agent that ignores the frame — but it makes "this is data" machine- readable instead of implied. 6. Errors that help without mapping your schema This is the layer I most expected to write as "return a generic failure and say nothing". That turned out to be the wrong design, and the reason is worth the paragraph. Errors never echo schema details, because that is how a stranger maps a database through error messages. But an identifier that the caller wrote in their own statement is not a disclosure — they already know it, they typed it. So a mistyped column comes back named: column "emial" does not exist — check the spelling, or list what does with describe_table [SQLSTATE 42703] while an error mentioning anything the caller did not write is reduced to its class: permission denied for this object — the role lacks access [SQLSTATE 42501] The blanket policy came first. It was replaced after watching what it cost: a mistyped column in a twenty-column join sent the agent bisecting the query instead of fixing one word. The policy was costing accuracy without buying secrecy. 7. Optional OAuth 2.1 RS256 JWT validation — signature, exp, aud, iss, and scopes — or a shared bearer token compared in constant time for deployments with no identity provider. Never both at once: accepting either would let anyone holding the secret act with full scope while the audit recorded no identity. Every tool decision is written to a hash-chained audit log that --verify-audit checks, so tampering shows up as a broken chain rather than a missing line. Layer Mechanism Failure mode it addresses 1 AST classification (sqlparser) Structural bypasses: CTEs, comments, batches 2 default_transaction_read_only + rollback + denied function space Parser bugs, functions that write anyway 3 statement_timeout, idle_in_transaction_session_timeout Resource exhaustion 4 EXPLAIN (FORMAT JSON) cost ceiling Expensive plans, before execution 5 trusted="false" framing, delimiter escaping, invisible-character stripping Prompt injection through row data 6 Class-only errors for anything the caller did not write Schema enumeration through error text 7 OAuth 2.1 RS256 or constant-time bearer, hash-chained audit Unauthorised access, unattributable action No MCP SDK The protocol layer is hand-written: no rmcp, no generated code. Three reasons, in order of how much they mattered. Auditability first — a reviewer can read the whole protocol path in one sitting. Dependency surface second: the entire dependency list is a dozen crates, and every one of them is a supply-chain decision. Protocol control third — the server negotiates three MCP revisions (2025-06-18, 2025-11-25, and 2026-07-28 behind a switch while it is still draft), and doing that across an SDK's release cadence is harder than doing it directly. Conformance is checked by somebody else's client rather than by our own tests: the official MCP SDK drives a suite against the server on every commit. The whole server is about 11,700 lines of Rust, of which the validator is the largest single piece. Both stdio and Streamable HTTP transports sit on the same protocol core. Deployment FROM gcr.io/distroless/cc-debian12:nonroot COPY --from=builder /build/target/release/postgres-mcp-hardened /usr/local/bin/mcp USER nonroot ENTRYPOINT ["/usr/local/bin/mcp"] No shell, no package manager, no root, roughly 34 MB. Single self-contained binary for five platforms; releases are signed with Sigstore and carry SLSA build provenance and a CycloneDX SBOM. Honest trade-offs Constraint Reality Read-only, permanently There is no write path to enable. Writes need human-in-the-loop tooling, not a flag. PostgreSQL-specific default_transaction_read_only, EXPLAIN (FORMAT JSON), pg_catalog. Not portable without a rewrite. Cost estimates are estimates A query under the ceiling can still run long. That is what the statement timeout is for. Column redaction is depth, not a boundary Name-based masking survives renames, casts, row_to_json and whole-row wildcards — and the server still asks PostgreSQL whether the role can read those columns anyway, because only a REVOKE makes it a guarantee. Authentication is optional Deploy without it and you own the network boundary. The server refuses to serve a network listener anonymously unless you say so explicitly. What the first day in public actually taught us The honest reason to publish is that you cannot find certain defects from your own machine, and I have a fresh example rather than a principle. Six releases, 113 unit tests, an adversarial corpus, protocol conformance driven by the official SDK, and a fuzz harness running hundreds of thousands of mutations per commit all passed — on a machine where the database was up and the environment was mine. Within a day of the server appearing in a public directory, two defects surfaced that none of that could have caught, because both only exist when somebody else runs it: A catalogue inspects a server before it gives it anything. It starts the binary behind mcp-proxy, calls initialize, then tools/list and resources/list — with no database attached. The server exited with a configuration error, because mcp-proxy exports MCP_PROXY_DEBUG and the server refused to start on an unknown MCP_* variable. That check exists for a good reason: MCP_REDACT_COLUMN, one letter short of the real setting, would start the server with redaction silently off. But a name that resembles nothing we define was set by a program that has never heard of us. The fix keeps the near-miss fatal and reports the stranger. resources/list answered a probe with a protocol error when the database was unreachable, which a host reads as a dead server. It now returns an empty list with the reason attached in _meta. A database that answers and refuses is still an error, because reporting "no resources" for a permission problem would be exactly the silent failure the rest of this design exists to avoid. Neither was findable in a test suite that controls its own environment. Both were found by the first stranger to run the thing. Please try to break it — it takes one command and no database The safety model is the product, so the only feedback that improves it is adversarial. I have tried to make that as close to free as I can get it. The guard has an offline mode. No database, no config, no clone, nothing to uninstall. Hand it a statement and it tells you what it decided: $ 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 users 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 If you find a statement that writes and comes back ALLOW, that is the single most valuable thing anyone can send me. It does not need a working exploit, a write-up, or a CVE. One line of SQL and the word "this should not be allowed" is a complete report. The fuzzer is yours too, and it is deterministic — it prints its seed, so anything it finds is reproducible by someone who has never seen your machine: $ npx postgres-mcp-hardened --fuzz 1000000 fuzz: 1000000 iterations, seed 1592594996, slowest validation 8 ms RESULT: 0 invariant violations That is a million mutated statements in about a minute on a laptop. A non-zero result plus the seed is a complete bug report that needs no further explanation. Or run the whole thing against a real database — one command brings up PostgreSQL with sample data and the server in front of it, connecting as a role that holds SELECT and nothing else: $ docker compose -f examples/docker-compose.yml up -d What happens to what you send Every bypass anyone has found is in the repository, in a MUST_REJECT corpus that the build runs on every commit — with the date it was found and what it cost. That is not a formality: the day of the first release produced four findings, one of which handed a superuser role to anyone who could create a table, and they are all written down rather than tidied away. Yours would join them, under your name if you want it there. Security-relevant findings go through SECURITY.md; anything else is a normal issue. I would rather read ten reports that turn out to be fine than miss the one that does not — so the bar for sending something is "this looks wrong to me", not "I am sure". Other things I would genuinely like challenged: Parser gaps. Does the validator cover every read-only PostgreSQL shape — TABLE x, VALUES, FETCH, MOVE, set operations, LATERAL, dollar-quoting? Functions that write anyway. The denied administrative space was built by testing what got through. It is certainly incomplete. pg_import_system_collations was one. What else? The framing in layer 5. Is trusted="false" plus escaping enough for your agent framework, or does your client flatten it back into ordinary text? Cost ceilings. What threshold actually fits a real workload rather than a demo? Code, threat model, and the ledger of every mistake found so far — including the ones that shipped — are at github.com/Eszetael/postgres-mcp-hardened (MIT). Nobody outside this project has run it against their own data yet. That is the whole reason it is 0.1.x and not 1.0 — and every adversarial round so far has found something real, including rounds run right after a clean one. The honest reading is that the next round finds something too, and I would rather it were yours than a stranger's in production.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News