The Postgres MCP server control model, explained
This page covers a pair, because the story is the pair. Anthropic’s reference server, @modelcontextprotocol/server-postgres, was deprecated on July 10, 2025 and archived on GitHub, npm, and Docker Hub — and was still measured at 312k npm downloads a month more than a year later, a number that tripled in the five months after November 2025. Its de-facto successor is Postgres MCP Pro (crystaldba/postgres-mcp, PyPI postgres-mcp), which fixed the reference server’s central defect and added real controls. One server is a zombie with a known-unpatched injection in its published artifact; the other is maintained and defaults to full read/write. Between them they define what “native controls” means for the most-connected database in the MCP ecosystem.
This page covers the servers’ own controls. For the cross-server picture, see the MCP server controls comparison; for what a control layer adds on top, the coverage matrix.
What’s at stake on this surface
Both servers reduce to the same primitive: a tool that accepts an arbitrary SQL string (query in the archived server, execute_sql in Pro) and runs it against whatever the connection string reaches. Arbitrary SQL means UPDATE, DELETE, DROP, DDL, permission grants — the full authority of the connected role, exercised by whatever the model decides SQL should say next. There is no toolset to trim, no resource scoping, no per-table anything: the entire control question on this surface is what can the SQL do, and the two servers answer it very differently.
The archived server: a read-only wrapper that lied
The reference server shipped one control: the query tool wrapped each submitted string in a read-only transaction — BEGIN TRANSACTION READ ONLY, run the input, ROLLBACK — relying on Postgres to reject writes inside it. Datadog Security Labs showed the wrapper was bypassable: stacked statements beginning with COMMIT; terminate the read-only transaction from inside the query text, and everything after runs read-write. The read-only guarantee was a preamble the payload could end.
The timeline is the part to be precise about. Anthropic fixed it in the git repository on May 29, 2025 — prepared statements, which disallow stacked statements, plus destroying the connection after each call. That fix was never published. v0.6.2, the last release on npm and Docker Hub, predates it and remains unpatched; it’s the version every fresh install gets. (The fix did ship somewhere: Zed’s fork, @zeddotdev/postgres-context-server, released a Datadog-supplied patch in April 2025.) So the deployed state of the ecosystem’s most-downloaded Postgres server is: one control, known-bypassed, in the artifact still being served — at roughly 21k npm downloads and 1k Docker pulls a week even by the disclosure’s count. If it’s in your config with read-only expectations, the correct mental model is read-write.
Registry gravity: why deprecation didn’t uninstall anything
None of this is a maintenance failure. Anthropic deprecated responsibly — archived the repo with an explicit “no security guarantees” warning, marked every npm version deprecated. The dev.to analysis of why installs kept climbing anyway is a study in where warnings don’t reach: npx runs a deprecated package without complaint; the archive notice lives at a different GitHub URL than the one old tutorials link; the npm deprecation message says only “Package no longer supported,” naming no successor; and the package remains the top search result and the thing LLMs recommend, because the training data and the blog posts were written before July 2025. (That analysis has a disclosed interest — its author sells a competing product — but the download counts are npm’s own.) Configs are copy-pasted, not resolved: deprecation changes what maintainers ship, and nothing about what agents are already wired to. A control model you’re evaluating from current docs can be running from a two-year-old artifact.
Postgres MCP Pro: restricted mode, defaulted off
Postgres MCP Pro is the maintained successor, and its --access-mode=restricted is a direct answer to the archived server’s failure. It does three things:
- Read-only transactions — the same wrapper, kept.
- SQL parsing before execution, using the pglast library, rejecting any statement containing
COMMITorROLLBACK— closing the exact smuggling path that broke the reference server. This is the meaningful upgrade: the transaction wrapper trusts Postgres to interpret the string; the parser inspects the string itself before Postgres ever sees it. - Execution-time caps — restricted mode limits query runtime (“presently only execution time” among resource constraints, per the README).
Two qualifiers, both from the project’s own documentation. First, the default is unrestricted — full read/write; the source sets default=AccessMode.UNRESTRICTED.value, and the README’s config examples pass --access-mode=unrestricted explicitly. A fresh install that never chose a mode has chosen one. Second, the README states plainly that “if you have unsafe stored procedure languages enabled on your database, then our read-only protections could be circumvented” — client-side parsing can’t see what a function body will do. Honest scoping, and a pointer at where the real boundary has to live.
The durable control: the role, not the wrapper
Both servers’ read-only stories are attempts to enforce, from the client side of the wire, something Postgres enforces natively: GRANT SELECT to a role with nothing else, connect as that role, and read-only holds against stacked statements, stored procedures, parser gaps, and every future bug in either server — because the database refuses the write regardless of what the tool layer was talked into. That’s the same layer-below logic that makes Supabase’s read-only mode the strongest single control in this series; Supabase ships it as a flag because it controls the vendor side, while for a bare Postgres connection you build the role yourself. Postgres MCP Pro’s community guidance points the same direction: restricted mode plus a SELECT-only role, the parser as a fast-fail in front of an enforcement the agent can’t reach. On this surface, the connection string is the credential floor — spend your effort there before trusting anything above it.
What doesn’t exist
Same list as every server in this series, worth stating for the record: no audit — neither server writes any trail of what SQL an agent ran, under which mode, against which database (74.9% of MCP servers document no audit mechanism at all; the database servers are the sharpest case, because “what ran” is exactly the question you’ll be asked later). No approvals — no call can be held for a human; whatever confirmation your client shows is a client courtesy, gone in yolo mode. No per-statement policy — access mode is one decision at launch; “SELECT freely, UPDATE with approval, never DROP” is not expressible. No scoping below the connection string — no per-table, per-schema, or per-database narrowing in either server beyond what the role already grants.
Where the native model ends
The pair, graded together: the archived server’s one control is broken in the shipped artifact and installs keep climbing anyway; the successor’s real control is client-side parsing, defaulted off, with a documented bypass class; and the only enforcement that survives every failure above it belongs to Postgres, not to either server. The general lesson is the registry-gravity one — deprecation doesn’t uninstall anything, and the control model in your config is whatever was true when the blog post you copied was written.
The practical posture: if @modelcontextprotocol/server-postgres is in any config you own, replace it — treat its read-only mode as absent. On Postgres MCP Pro, set --access-mode=restricted explicitly and don’t rely on the default. Either way, connect as a role with only the grants the task needs — GRANT SELECT and stop — because the role is the one control the agent can’t be talked out of. And for the per-statement policy, the approval that survives autonomy, and the record of what SQL actually ran, the native model has nothing to offer at any setting — that takes a layer standing between the agent and the connection.