The Postgres MCP server read-only bypass: reproduced, and what actually holds

Datadog Security Labs showed that one stacked COMMIT turns the archived Postgres MCP server's read-only mode into full write access. We reran the mechanism and tested five defences against it.

The short answer

The archived reference Postgres MCP server, @modelcontextprotocol/server-postgres, enforced “read-only” by wrapping each query in a read-only transaction. A query that starts with COMMIT; ends that transaction, and everything after it runs with the database role’s full rights.1 The fix that holds is a role that can’t write. A transaction wrapper is a second fence, not the boundary.

Anthropic archived the server on 2025-05-28.3 It was still downloaded 75,785 times on npm in the week of 16 to 22 October 2026.2 So this is not a historical curiosity: if an MCP client config somewhere points at that package, read on.

Every MCP tool call goes through your rules first. Here a delete that doesn’t say which rows is stopped before it reaches the database. These are the rules deedglass init starts with; the call is made up.

What Datadog found

Santiago Mola of Datadog Security Labs published the analysis on 21 August 2025, against version 0.6.2 of the package.1 The server’s read-only mode did three things for every query: open a transaction with BEGIN TRANSACTION READ ONLY, run the SQL it was given, and ROLLBACK in a finally block.1

The weak point is the middle step. The Node.js Postgres client accepts a string holding several statements separated by semicolons. Send COMMIT; DROP SCHEMA public CASCADE; and the COMMIT closes the read-only transaction; the DROP runs outside it; the final ROLLBACK has nothing left to undo.1 Datadog’s demonstration dropped every table this way.1

Two more findings matter more than the headline:

  • Session state leaks across calls. The server reused pooled connections, so COMMIT; SET statement_timeout TO 1; made later queries on that connection fail, for whoever used it next, until the connection was recycled. Datadog notes that a least-privilege user doesn’t stop this part.1
  • The patch was small. Send the query as a single prepared statement, which refuses stacked queries, and destroy the session after each call. The fix shipped in Zed Industries’ fork, @zeddotdev/postgres-context-server 0.1.4, but not in a released version of the archived package.1

Datadog’s advice: don’t use the deprecated server where writes must be prevented, especially in production, and connect with a least-privilege user.1 None of this is a criticism of the people who wrote a small reference server. It’s a lesson about where a boundary should live.

Reproducing it with psql

You don’t need the MCP server to see the mechanism; it’s plain Postgres behaviour. When one simple-protocol message holds a COMMIT that closes an open transaction block, any statements after it run in a new implicit transaction.4 We set up a throwaway PostgreSQL 18.6 with a 500-row orders table owned by the role app, and sent the same three messages the server sends, as app. Each -c below is one message on one connection:

Code What the screen or file shows, copied as is.

$ psql -U app -d shop -e -c "BEGIN TRANSACTION READ ONLY" -c "DELETE FROM orders" -c "ROLLBACK"
BEGIN TRANSACTION READ ONLY
BEGIN
DELETE FROM orders
ERROR:  cannot execute DELETE in a read-only transaction
ROLLBACK
ROLLBACK

$ psql -U app -d shop -e -c "BEGIN TRANSACTION READ ONLY" -c "COMMIT; DELETE FROM orders" -c "ROLLBACK"
BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; DELETE FROM orders
COMMIT
DELETE 500
ROLLBACK
WARNING:  there is no transaction in progress
ROLLBACK

$ psql -U app -d shop -c "SELECT count(*) AS orders_left FROM orders"
 orders_left
-------------
           0

The read-only transaction did its job for the first query. The second added six characters and emptied the table. The WARNING: there is no transaction in progress is the server’s ROLLBACK arriving too late.

Five defences, tested

We then reran the attack against five setups on the same cluster. Two refused the write whatever the query said. One refused it only until the query asked nicely.

Setup COMMIT; DELETE FROM orders Session change (SET statement_timeout) Holds?
Owner role, read-only transaction wrapper DELETE 500 Sticks No
Owner role + default_transaction_read_only = on Refused, but BEGIN READ WRITE; DELETE … gives DELETE 1 Sticks No
Role with SELECT only ERROR: permission denied for table orders Sticks: the next query was cancelled by the 1 ms timeout Writes: yes. Sessions: no
Hot standby (even as superuser) ERROR: cannot execute DELETE in a read-only transaction SET is allowed6 Writes: yes
One statement per message (extended protocol) ERROR: cannot insert multiple commands into a prepared statement Refused in the same way The stacking, yes

The standby is the strongest of these for writes. During hot standby transaction_read_only is always true and can’t be changed, so BEGIN READ WRITE fails with cannot set transaction read-write mode during recovery.6 The cost is a replica and some replication lag in what the agent reads.

The extended-protocol row is Postgres’s own rule: the query string in a Parse message can hold only one SQL statement.4 That’s the property Datadog’s patch relies on. It removes stacking, not writes: a single DELETE FROM orders still runs if the role may delete.

Why default_transaction_read_only isn’t enough

default_transaction_read_only sets the read-only status of each new transaction.10 Attached to a role with ALTER ROLE … SET, it looks like a fix. It’s a default, and the session can change its own defaults:

Code What the screen or file shows, copied as is.

$ psql -U app -d shop -e -c "BEGIN READ WRITE; DELETE FROM orders WHERE id = 1; COMMIT"
BEGIN READ WRITE; DELETE FROM orders WHERE id = 1; COMMIT
BEGIN
DELETE 1
COMMIT

$ psql -U app -d shop -e -c "SET default_transaction_read_only = off; COMMIT; DELETE FROM orders WHERE id = 2"
WARNING:  there is no transaction in progress
SET
COMMIT
DELETE 1

PostgreSQL’s own documentation calls transaction read-only mode “a high-level notion of read-only that does not prevent all writes to disk”.5 Treat it as a guard against accidents, not against a model that has read a crafted support ticket. The longer story of settings a session can undo, including row-level security keyed on a session variable, is in safe Postgres access for AI agents.

Where a SQL parser helps, and where it doesn’t

A gateway that parses each statement before it reaches Postgres can refuse the whole class of stacked queries. We ran the payloads through the postgres-check starter policy with the v0.9.0 deedglass CLI:

Code What the screen or file shows, copied as is.

$ deedglass policy evaluate --policy postgres-check.yaml --tool postgres.execute \
    --sql "COMMIT; DROP SCHEMA public CASCADE" --explain
✕ deny · one-statement-only · 121 µs
  Blocked by policy "one-statement-only": Send one SQL statement per action. This request had several.

  sql     commit · ddl · 2 statements

$ deedglass policy evaluate --policy postgres-check.yaml --tool postgres.execute \
    --sql "COMMIT; SET statement_timeout TO 1"
✕ deny · one-statement-only · 85 µs

$ deedglass policy evaluate --policy postgres-check.yaml --tool postgres.query \
    --sql "SELECT public.cleanup_orders()"
✕ deny · default · 75 µs

The starter refuses more than one statement per action, fails closed on SQL it can’t read with confidence, and won’t allow a read that calls a function it doesn’t know.7 Underneath, the connector runs agent SQL as a least-privilege role, one statement per action, in a transaction Deedglass controls.8

A parser has real limits, and they’re the same for any product that does this:

  • It decides on text. If the parser reads a string differently from Postgres, that difference is a bypass. That’s why failing closed on anything ambiguous matters more than any list of blocked keywords.
  • It can’t see inside functions. SELECT f() reads as a read. Whether f writes is in the database, not the query. Refusing unknown functions is the safe default; allowing one means trusting its body.
  • It judges the request, not the effect. A single, correct UPDATE … WHERE id = 42 can fire a trigger that changes 300 more rows. Read Your AI agent said 1 row. Postgres changed 301 for that failure.

So the order is: the role first, then the parser, then a check of what committed. A parser in front of an owner role is better than nothing and worse than a SELECT-only role with no parser at all.

Check your own server

  1. Search every MCP client config (Claude Desktop, Claude Code, Cursor, VS Code, Windsurf) for server-postgres and mcp/postgres. Replace it.
  2. Look at the connection string’s user. If it owns tables or has INSERT, UPDATE, DELETE or TRUNCATE anywhere, the server’s read-only flag is the only thing between a prompt and your data.
  3. Create a role with SELECT on the tables the agent needs and nothing else. Connect as that role. See the read-only role comparison.
  4. Better, point read-only agents at a hot standby or read replica.
  5. Send COMMIT; SELECT 1 through your server. If it answers instead of refusing, it accepts stacked queries.
  6. Send COMMIT; SET statement_timeout TO 1, then a normal query. If the second fails, sessions leak between calls.
  7. Set statement_timeout, lock_timeout and a CONNECTION LIMIT on the agent’s role, knowing a session can change the first two unless something in front refuses SET.

To find the archived server on your machine, run npx deedglass scan. It reports the package in any MCP config it finds, at any version, with the two fixes above.9 It grades the setup, and nothing leaves your machine.9 Here is what the grade means. The playground shows, in a browser tab, why a check of what committed matters even after the role is right.

Questions people ask

Is the official Postgres MCP server safe to use?

The reference server was archived on 2025-05-28 and its last npm release, 0.6.2, has the stacked-query bypass Datadog described.31 Datadog recommends not using it where writes must be prevented.1 If you keep it for a local toy database, connect it with a SELECT-only role.

Does a read-only Postgres user stop SQL injection through an MCP server?

It stops writes: in our test the stacked DELETE got permission denied. It doesn’t stop the session changes, like a 1 ms statement_timeout that breaks the next caller on a pooled connection, and it doesn’t stop reads of anything the role can see.1

Is default_transaction_read_only a security setting?

No. It sets a default for new transactions, and the same session can run BEGIN READ WRITE or turn it off, as the run above shows. On a hot standby, by contrast, read-write can’t be turned on at all.6

Which Postgres MCP server should I use instead?

Datadog names Zed Industries’ fork as patched.1 Whatever you pick, check how it sends queries (one statement per message or not), whether it resets sessions between calls, and what role it connects as. The MCP security checklist covers the rest of a server’s surface.

Written with AI assistance and checked against our claims register.

The takeaway

One stacked COMMIT turned the archived Postgres MCP server’s read-only mode into writes; default_transaction_read_only is a default, not a security setting.

Read nextMCP security checklist: what to check before an agent gets a tool Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1Datadog Security Labs: SQL injection in the PostgreSQL MCP server (21 August 2025) · securitylabs.datadoghq.com · checked 25 October 2026
  2. 2npm downloads API: @modelcontextprotocol/server-postgres, last week · api.npmjs.org · checked 25 October 2026
  3. 3modelcontextprotocol/servers-archived on GitHub · github.com · checked 25 October 2026
  4. 4PostgreSQL 18 docs: Message flow (multiple statements in a simple query; extended query) · postgresql.org · checked 25 October 2026
  5. 5PostgreSQL 18 docs: SET TRANSACTION · postgresql.org · checked 25 October 2026
  6. 6PostgreSQL 18 docs: Hot standby · postgresql.org · checked 25 October 2026
  7. 7deedglass/deedglass: the postgres-check starter policy · github.com · checked 25 October 2026
  8. 8Deedglass docs: Block destructive SQL · docs.deedglass.com · checked 25 October 2026
  9. 9Deedglass docs: Scan your agent setup · docs.deedglass.com · checked 25 October 2026
  10. 10PostgreSQL 18 docs: Client connection defaults · postgresql.org · checked 25 October 2026

Published 25 October 2026, sources last checked 25 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing