Build1 publisher2 min readPublished
COMMIT; DROP TABLE slips past the reference Postgres MCP server's read-only guard
Model Context Protocol's reference Postgres MCP server guards writes with a read-only transaction. One COMMIT; DROP TABLE query drops the table anyway. Only database permissions sit where the agent's SQL cannot reach.
The Engineer · Build desk

What happened
- The archived handler runs each request as three separate calls, opening the transaction, passing the agent's text verbatim, then issuing a ROLLBACK.
- Because the text is sent with no parameters bound, node-postgres uses the simple query protocol, which accepts several statements in one string, so two commands arrive as a single query.
- Logged in instead as a role granted pg_read_all_data that owns no tables, the same COMMIT; DROP TABLE failed with 'must be owner of table customers' and the table stayed.
- Datadog Security Labs documented the identical escape in a case study titled 'SQL injection in the Postgres MCP server' on August 21, 2025.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Whatever a read tool returns joins the agent's context, so against a cloud-hosted model the customer emails and card digits a SELECT pulls back are now held by the model provider too.
- constraint A statement_timeout set on the role is only a default; SET LOCAL statement_timeout = 0 overrides it, so a hard limit has to live in the MCP server or the connection pooler, out of the SQL's reach.
- decision An agent that only needs structure can run as a role with CONNECT, USAGE and no table grants, which reads the whole schema from pg_catalog and is refused every row.
A transaction mode belongs to the session, and the agent's SQL controls the session. Postgres, meanwhile, checks privileges on every statement, whatever transaction it runs in [12]. The agent's query can flip a read-only transaction. It cannot change the grants on a login role.
An honest write still fails inside it: INSERT INTO customers came back with "cannot execute INSERT in a read-only transaction" [6]. The mode catches the write that follows its rules. It misses the one that commits the transaction first.
The tests ran against PostgreSQL 18.3 in a throwaway container, with the reference server's handler reproduced on the same node-postgres driver, version 8.23.0, against a customers table holding emails and card digits [7]. The findings are from the blog of Schemity, a desktop ERD tool whose author discloses building it [19].
Fixing the write does not fix the read. SELECT email, card_last4 FROM customers returned every customer's email and card digits inside the read-only transaction [5], and returned them again under the role that owns nothing [15]. A SELECT-only role stops the DROP but still returns every row the SELECT asked for [17].
Other Postgres MCP servers are separate code, so the escape depends on how yours hands the SQL to the driver: a prepared statement accepts only one statement, a raw string passed through as it arrives accepts several [10]. The reference repository was archived on May 29, 2025, and its README now reads "No security updates or bug fixes will be provided" for these servers [9].
One grant most people forget: COMMIT; CREATE TEMP TABLE t (x int) still succeeds, because PUBLIC may create temporary tables by default. It touches none of your data, and you can revoke TEMP if it bothers you [18].
What to watch
- Whether Postgres MCP servers in wide use pass the agent's SQL as prepared statements or as raw strings.
- Whether server authors move the statement timeout into the MCP layer or a pooler instead of the role.
- Whether the Model Context Protocol project replaces the archived example after Datadog's August report.