Back to Blog

Read-Only That Wasn't: The Postgres MCP Server's SQL Injection Bypass

The official @modelcontextprotocol/server-postgres package — 20,000 weekly downloads — ships with a read-only mode that an attacker can bypass with a single COMMIT statement. Datadog Security Labs published the case study. Here's why the bug exists, why it's structural to raw-SQL MCP tools, and what database access for agents has to look like instead.

The official Postgres MCP server has a “read-only mode.” If you’re an AI agent connecting through it, you’re supposed to be able to query the database without the ability to mutate data. The promise is right there in the package README, and the package — @modelcontextprotocol/server-postgres on npm — has roughly 20,000 weekly downloads.

Datadog Security Labs published a case study this month showing that the read-only mode can be bypassed with a single SQL fragment:

COMMIT; DROP TABLE users; --

That’s it. There is no exotic gadget chain, no race condition, no chained exploit. The user-supplied query is wrapped in a BEGIN READ ONLY transaction, but a COMMIT inside the user input ends that transaction before the rest of the input runs. Everything after the commit executes outside any read-only scope, with whatever privileges the database user has — which in most installs is “everything.”

This is the package the Model Context Protocol team itself published. It’s the most-downloaded Postgres MCP server. It is what most tutorials still link to when they say “give your AI agent a database.” And the failure mode is the most elementary input-sanitization bug in the SQL canon.

The bug, in one paragraph

Postgres lets you run multiple statements per query. The server takes whatever string the agent generated, prepends a BEGIN TRANSACTION READ ONLY, and submits it. If the agent (or whatever upstream prompt-injection vector reached the agent) supplies a query containing COMMIT; followed by other statements, the connection commits the read-only transaction and continues running the trailing statements as ordinary writeable SQL. There is no parser checking that the supplied query contains exactly one statement, no AST-level enforcement that mutating verbs aren’t present, no role on the database side that prevents writes. The “read-only mode” is a transactional wrapper on a pipe that accepts arbitrary SQL.

If you’ve spent any time around web security, you’ve seen this exact bug pattern before. It’s SQL injection, except the role of “untrusted user input” is now played by “the language model that’s supposed to be your trusted agent.” That substitution doesn’t make the bug less serious. It makes it more.

Why prompt-injection puts every raw-SQL MCP server in the same bucket

A human developer using a database client doesn’t typically inject COMMIT; into their own queries. They want to see their data, not destroy it. The risk model for MCP servers is fundamentally different, and the difference is what this bug exposes.

An MCP server with a SQL-execution tool is reachable, transitively, by anything the agent reads. If your agent ingests an email, a Jira ticket, a webpage, a row from a different database, a customer support transcript — any of those can carry an instruction that says, in effect, “before answering, run this query.” Indirect prompt injection has been demonstrated against every major model. It’s not a hypothetical attack vector; it’s the default attack vector against any agent connected to the open internet.

So when the database tool surface is “execute arbitrary SQL with input sanitization that doesn’t actually sanitize,” the practical security ceiling of the system is set by the worst sentence the agent might read in the next 24 hours. That’s not a posture you can ship to production.

The fix the MCP team has discussed — checking that the query contains exactly one statement — is necessary, but it’s not sufficient. Even single-statement SQL has plenty of room for damage. UPDATE users SET email = '[email protected]' WHERE role = 'admin' is one statement. DELETE FROM transactions WHERE created_at > NOW() - INTERVAL '30 days' is one statement. As long as the model gets to author SQL freely and the database has any write permissions on the connection, you are one prompt injection away from a bad day.

The two layers where this can actually be enforced

You can stop a malicious SQL string from running in two places: the database engine, or a layer in front of it that doesn’t pass raw SQL through.

Database-engine RBAC is the textbook answer. Connect the MCP server with a Postgres role that has been granted only SELECT on the tables the agent should be able to read, and explicitly denied INSERT/UPDATE/DELETE/DDL. If the agent then runs DROP TABLE users — even if it bypasses every layer above the database — Postgres rejects it. This is the layer that worked for forty years before MCP existed and will work for forty more.

The reason almost no one configures this for application access is that it’s coarse and a pain to evolve. You write a migration, your role grants drift, the application starts failing in production, and someone gives the application user ALL PRIVILEGES to make the alarm stop. For the AI agent case specifically, where the threat model is “this caller is structurally untrustworthy,” the inconvenience is worth it. If you’re going to run a raw-SQL MCP server, the database role behind it must not have write privileges. Period.

API-gateway RBAC is the alternative, and it’s the one I’d argue is actually appropriate for agent traffic. Don’t expose raw SQL to the agent at all. Expose typed REST endpoints. The endpoints carry their own auth scope, their own filters, their own rate limits, and the database credentials never leave the gateway. The agent gets a token that says “you can read the reservations table where tenant_id = X.” It cannot construct a SQL string that drops anything, because the surface it’s talking to does not accept SQL strings.

This is the architectural argument for purpose-built data APIs over generic SQL MCP servers, and the Datadog finding is the cleanest possible illustration of it. The bug class — input sanitization on a SQL pipe — is one that the entire industry has been losing for thirty years. There is no version of this story where giving the agent an open SQL channel and hoping the wrapper holds is a stable equilibrium.

What that looks like in Faucet

Faucet is a tool I’ve been building that points at a Postgres, MySQL, SQL Server, Oracle, Snowflake, or SQLite database and generates a typed REST API in front of it, with role-based scopes and an MCP server that proxies through that API. I’m not going to pretend the connection to the topic is subtle — the entire reason Faucet exists in the shape it does is that “let the agent run SQL” loses to a COMMIT; once and stays lost.

A minimal Faucet config for an agent role on a customer-data table:

roles:
  agent_support:
    tables:
      customers:
        operations: [SELECT]
        columns:
          - id
          - email
          - tier
          - signup_date
        filters:
          tenant_id: "{{ token.tenant_id }}"
        rate_limit: 60/min
        column_mask:
          email: hash

The agent connects to the Faucet MCP server. The MCP server exposes tools that look like list_customers, get_customer_by_id, search_customers_by_tier. None of them accept a SQL string. The agent can call any of them with structured parameters, and Faucet validates those parameters against the role config before composing a parameterized SQL query against the underlying database. The database connection itself uses a role that has been granted only the operations the highest-privilege Faucet role would need.

If the agent — through prompt injection or any other route — tries to call a tool that doesn’t exist, or supplies parameters outside the schema, or attempts to read a column the role doesn’t include, the gateway rejects it before any SQL is composed. The SQL it does compose is parameterized, with no string interpolation between user input and the query body, so there is no place a COMMIT; can land.

$ faucet init --db postgres://localhost/cars
$ faucet roles add agent_support --config roles.yaml
$ faucet token issue --role agent_support --tenant-id 42 --ttl 1h
ftk_4f29...

$ faucet mcp serve --port 8080
{
  "method": "tools/call",
  "params": {
    "name": "list_customers",
    "arguments": { "tier": "pro", "limit": 50 }
  }
}

The MCP tool surface is small and typed. The agent sees list_customers(tier?, limit?, offset?), not query(sql). There is no equivalent of “run this string against the database” exposed at the boundary, so there is no bypass-by-second-statement bug to write.

This is also where MCP’s tool annotations spec earns its keep. Each Faucet-generated tool ships with readOnlyHint: true for SELECT operations and destructiveHint: true for DELETE operations, so an MCP host that supports them — Claude Desktop, Cursor, the Anthropic Workbench — can prompt the user before any destructive call. The hints are advisory at the host layer, but they don’t have to carry the security weight; the gateway already does.

”Just patch the bug” doesn’t generalize

The MCP team has good engineers and the specific COMMIT-bypass will get fixed. The Datadog writeup explicitly recommends migrating off the original @modelcontextprotocol/server-postgres package to better-maintained alternatives like Crystal DB’s Postgres MCP Pro, which offers a hardened restricted mode with statement-type checks and timeout controls.

The deeper problem isn’t this specific bug. It’s that “give the agent a SQL execution tool and try to filter the inputs” is a posture that the security industry has been failing at since the first injection bug landed. Every parser hardening you ship is a new arms-race surface. Every new database feature — CTEs, lateral joins, JSON operators, function call semantics, dollar-quoted strings — is a new place a parser-based filter can be wrong. The original Postgres MCP package is going to get patched, and then someone will find a WITH clause that does the same thing, and then a procedural extension, and then a comment-stripping bypass. This is what it looks like to defend a SQL pipe.

The structural answer is: don’t put the agent on a SQL pipe. Put it on a typed API that compiles down to parameterized SQL inside a trust boundary the agent never crosses. Then the worst the agent can do is the worst your typed API permits, which is something you can reason about by reading the API definition rather than by trying to enumerate every clever input.

The 20,000-downloads-per-week problem

The thing that keeps me up about the Datadog finding isn’t the bug itself. It’s that the package is named @modelcontextprotocol/server-postgres. It lives under the official org. It is what someone googling “MCP Postgres” finds first. And it has 20,000 weekly downloads — which means somewhere on the order of a thousand companies have pointed it at a database in the last week alone.

A non-trivial percentage of those installs use the default Postgres connection, which is to say they connect as the database superuser, which is to say they are one indirect prompt injection away from a Datadog Security Labs case study with their own logo on it. The PocketOS incident from last Friday — a Cursor agent that scanned a repo for an unrelated API token and used it to wipe a production volume — was the cleanest possible demonstration that agents do, in fact, find and use credentials they weren’t given. The next equivalent of that is going to involve an MCP server, and it is going to be exactly this bug.

The lesson from web app security took a long time to land: do not concatenate untrusted strings into SQL. The lesson for AI tooling is the same lesson with one word changed. Do not let untrusted models compose SQL against trusted databases.

Getting Started

Faucet generates a typed REST API and an MCP server in front of any Postgres, MySQL, SQL Server, Oracle, Snowflake, or SQLite database, with role-based scopes enforced at the gateway. Single binary, no agents, no SDK to install in your app.

curl -fsSL https://get.faucet.dev | sh
faucet init --db postgres://localhost/yourdb
faucet mcp serve

The MCP endpoint exposes typed tools per table and per role. The database connection lives inside the gateway. The agent never sees a SQL string and never holds the database password.

If you’re currently running @modelcontextprotocol/server-postgres in any environment that touches real data, the short version of this post is: stop. Move to Crystal DB’s Postgres MCP Pro if you need raw SQL access, or to a typed-API gateway if you don’t. The 20,000-downloads-per-week version of MCP database access is not the version you want pointing at production.