---
title: "The read-only escape in @modelcontextprotocol/server-postgres"
description: "The archived reference Postgres MCP server can be escaped out of its read-only transaction with one semicolon. The reproduction, what a read-only role does and does not stop, and what to run instead."
canonical: "https://pgbeam.com/guides/server-postgres-mcp-sql-injection"
last-updated: "2026-09-07T00:00:00.000Z"
---

# The read-only escape in @modelcontextprotocol/server-postgres

> The archived reference Postgres MCP server can be escaped out of its read-only transaction with one semicolon. The reproduction, what a read-only role does and does not stop, and what to run instead.

Canonical: https://pgbeam.com/guides/server-postgres-mcp-sql-injection  
Published: 2026-09-07  
Updated: 2026-09-07  
Every transcript on this page was executed against PostgreSQL 17.11 on 2026-09-07. Package facts were read from the npm registry API and the GitHub API the same day.

No. `@modelcontextprotocol/server-postgres` wraps your query in `BEGIN TRANSACTION READ ONLY` and then hands the whole string to the driver in one simple-protocol call, and the simple protocol accepts several statements separated by semicolons. A query that starts with `COMMIT;` ends the read-only transaction and everything after the semicolon runs outside it. The package has been deprecated on npm since before the archival, every one of its eight published versions carries a deprecation notice, the repository has been archived since 2025-05-28, and it was still downloaded 95,430 times in the week ending 2026-09-06.

The escape is one line and it reproduces on a current Postgres. On 17.11, `COMMIT; DROP SCHEMA public CASCADE;` sent through that read-only transaction dropped the schema and then the server's own `ROLLBACK` came back with `WARNING: there is no transaction in progress`, because there was nothing left to roll back. The full transcript is two sections down.

The mitigation everybody names does work, and it does less than people think. A role with only `SELECT` stops the `DROP`: on 17.11 it fails with `must be owner of schema public`. It does not stop the statement after the semicolon from reading a table you never meant that agent to see, which is the same transcript with the payload changed and is also below. If you are running this server today, the ordered fix is in the last section, and the shortest version is that the read side needs a different control from the write side.

## At a glance

| Field | Value |
| --- | --- |
| Package | `@modelcontextprotocol/server-postgres`, latest 0.6.2. |
| Last published | 2024-12-04. No release since. |
| npm status | All 8 versions deprecated: `Package no longer supported`. |
| Repository | `modelcontextprotocol/servers-archived`, archived 2025-05-28. |
| Still in use | 95,430 downloads in the week ending 2026-09-06. |
| CVE | None issued. There is no advisory to pin a version against. |
| Reproduced on | PostgreSQL 17.11, driver pg 8.23.0, on 2026-09-07. |

## What to do now, in order

If this server is pointed at anything you would mind losing, do these in this order. Each one is independently useful, so stopping after the first is better than doing none of them.

1. Take the credential away from the agent, not just the server. Rotate the password in the connection string the MCP config holds. A config change alone leaves a working credential in a shell history, a container image, and whatever the agent wrote it into.
2. Set `default_transaction_read_only` on the role rather than trusting the server's `BEGIN TRANSACTION READ ONLY`. It is the one setting the escape does not clear, and it is one `ALTER ROLE`. Verified below.
3. Give the agent a role that owns nothing and can `SELECT` on named tables only, rather than on the schema. `GRANT SELECT ON ALL TABLES` is a snapshot, not a rule, and the next migration lands outside it.
4. Replace the server. The maintained options and what each one is good at are in the migration guide linked at the bottom.
5. Decide separately what the agent is allowed to read. Nothing in the first four steps limits that, and by download count the read is the more likely incident.

## The escape, reproduced

The server's model is that a read-only transaction makes an arbitrary query string safe. The call shape is the problem, not the transaction: the query goes to the driver as one string over the simple query protocol, and the simple protocol runs every statement in it.

```javascript title="The call shape, from the archived server"
await client.query("BEGIN TRANSACTION READ ONLY");
const result = await client.query(sql);   // sql is whatever the model sent
// ... later, in a finally block:
await client.query("ROLLBACK");
```

A model that has read a planted instruction, or that has simply been asked to clean something up, sends a string whose first token closes the transaction. Here is the whole thing on PostgreSQL 17.11, as a role that owns the schema, which is what an agent gets when somebody pastes their application's own connection string into an MCP config.

```sql title="psql, PostgreSQL 17.11"
BEGIN TRANSACTION READ ONLY;
COMMIT; DROP SCHEMA public CASCADE;
ROLLBACK;
```

```text title="Output, verbatim"
BEGIN
COMMIT
NOTICE:  drop cascades to table orders
DROP SCHEMA
ROLLBACK
WARNING:  there is no transaction in progress
```

Read the last two lines. The cleanup the server relies on ran, found no transaction, and warned. The schema was already gone. A `try/finally` around a rollback protects you from a statement that failed inside the transaction, and this statement did not fail and was not inside it.

> **This is not a parameterisation bug**
>
> There is no user input being concatenated into SQL here. The SQL is the input. Nothing in the ordinary advice about prepared statements and placeholders applies, because the tool's entire purpose is to run a statement the model wrote. That is why the fix is a policy layer or a different call shape, and not an escaping function.

## What each mitigation covers, and what it leaves

Four controls get recommended for this. Two of them hold, one of them holds against writes only, and one is the fix the code needed. All four were run against 17.11 on 2026-09-07.

| Control | Stops the DROP | Stops an unintended read | Verified result |
| --- | --- | --- | --- |
| The server's `BEGIN TRANSACTION READ ONLY` | No | No | `DROP SCHEMA` succeeded |
| A role with `SELECT` only | Yes | No | `ERROR: must be owner of schema public` |
| `default_transaction_read_only` on the role | Yes | No | `ERROR: cannot execute CREATE TABLE in a read-only transaction` |
| A named prepared statement instead of a raw string | Yes | No | `ERROR: cannot insert multiple commands into a prepared statement` |

### The read-only role

This is the right first move and it does what people say it does. The same payload, as a role granted `SELECT` and nothing else:

```text title="As a SELECT-only role"
BEGIN
COMMIT
ERROR:  must be owner of schema public
```

Note the wording. On PostgreSQL 17 a `DROP SCHEMA` the role does not own fails on ownership rather than on a privilege check, so if you are grepping logs for `permission denied` you will miss it.

### The session setting, which is the one the escape cannot clear

`BEGIN TRANSACTION READ ONLY` is a property of one transaction, and `COMMIT` ends it. `default_transaction_read_only` is a property of the session, so the implicit transaction that the injected statement runs in is read-only too. It survives the escape, and it costs one statement:

```sql title="Set it on the role, not in the server"
ALTER ROLE agent_ro SET default_transaction_read_only = on;
```

```text title="Same payload, with the setting in place"
BEGIN
COMMIT
ERROR:  cannot execute CREATE TABLE in a read-only transaction
```

Set it on the role rather than in the connection string, because a client that can set a GUC can unset one. It is not a substitute for the grants: a read-only transaction still permits every read the role is entitled to, and it does not stop `SELECT ... FOR UPDATE` from taking locks or a function from being called.

### The call shape, which is what the code got wrong

The stacking is only available over the simple query protocol. Ask the driver for a named prepared statement and the server refuses the string before any of it executes. Both probes below were run against pg 8.23.0, the driver the archived server uses.

```javascript title="node-postgres, two call shapes, same payload"
await c.query("BEGIN TRANSACTION READ ONLY");

// The archived server's shape: simple protocol, statements stack.
await c.query("COMMIT; SELECT ssn FROM public.customers");
// -> ["COMMIT", "SELECT"]

// A named prepared statement: the server rejects the string.
await c.query({ name: "sandboxed", text: "COMMIT; SELECT ssn FROM public.customers", values: [] });
// -> error: cannot insert multiple commands into a prepared statement
```

That refusal comes from Postgres, not from the driver, which is what makes it worth relying on. It is also why this class of bug is worth checking for in any MCP server or agent shim you did not write: find the line that runs the model's SQL, and see whether it passes a bare string.

## What none of them cover

Every control above is about writes. Change the payload to a read and all four let it through, because a read-only role reading a table it was granted is the system working as configured.

```sql title="SELECT-only role, default_transaction_read_only on, same escape"
BEGIN TRANSACTION READ ONLY;
COMMIT; SELECT email, ssn FROM public.customers;
```

```text title="Output, verbatim"
BEGIN
COMMIT
      email      |     ssn
-----------------+-------------
 ada@example.com | 111-22-3333
(1 row)
```

Nothing was bypassed there. `GRANT SELECT ON ALL TABLES IN SCHEMA public` included that table, so the role may read it, and the escape was not even necessary for this one. That is the point: after you have fixed the write path, the agent can still read every column of every table its role was granted, and the grant was almost certainly written as a schema-wide snapshot rather than a list.

- Column granularity: a role that may read `customers` may read `customers.ssn`. Postgres has column-level grants and almost nobody maintains them by hand across migrations.
- New tables: `GRANT SELECT ON ALL TABLES` applies to the tables that existed when you ran it. The next migration's table is outside it, and a leftover `ALTER DEFAULT PRIVILEGES` line silently puts it back inside.
- Attribution: `pg_stat_activity` and the server log record the role. If three agents share `agent_ro`, the log cannot tell you which one ran the statement, and that is the question an incident starts with.
- Volume: a single `SELECT *` with no `LIMIT` against a large table is a legitimate read-only query. `statement_timeout` bounds how long it runs, not how much leaves.

## Where PgBeam fits, and where it does not

PgBeam is a proxy that speaks the Postgres wire protocol, so it parses the statement before your database sees it and decides on the parse tree: statement kind, every relation named, every column projected. Statement stacking is visible to it for the same reason a second table in a join is, and a credential's policy carries table and column allowlists, masking, row caps, query and egress budgets, and a kill-switch. The audit trail records the statement, the decision, the rule that fired, and which credential sent it.

That covers the read side of this page, which the four controls above do not. It is also more moving parts than a lot of people need, so here is the honest split.

| Situation | What we would do |
| --- | --- |
| A local dev database with no real data | Nothing on this page. Replace the archived server and move on. |
| One engineer, read-only, a database whose every column they may already read | A dedicated role plus `default_transaction_read_only` plus a maintained server. No proxy. |
| Several agents, production data, columns that some of them should not see | A layer that can refuse a statement and mask a column, because roles cannot express that per agent without a role per agent. |
| You need to answer what an agent read, months later | Something that records per-statement with the credential attached. `pgaudit` does the recording; the per-agent identity has to come from somewhere. |

> **What PgBeam does not do here**
>
> It does not make an unmaintained MCP server safe to run: an agent that holds your database's own connection string is outside any proxy, so the credential still has to be rotated. It governs Postgres and nothing else, so if the requirement is one policy plane across every tool an agent holds, an MCP gateway is the product that meets it. And a policy is only as narrow as what you wrote, which is why the CLI has a dry-eval command that prints the verdict for a statement without touching your database.

## What we could not verify

- No CVE has been issued, so there is no identifier to pin a scanner or an SBOM policy against. We could not find one on 2026-09-07.
- We did not verify the Docker image download figure that circulates alongside the npm one. The npm count on this page came from the registry API; the image count did not, so it is not quoted here.
- Several write-ups name `mcp-server-pg` as the drop-in replacement. On 2026-09-07 that npm name resolved to a security holding package at version `0.0.1-security` with 4 downloads in the week, so we cannot recommend it and cannot say what happened to it. Check the name yourself before installing anything on that advice, including ours.
- postgres-mcp.dev, which publishes its own advisory and migration pages on this topic, did not resolve from our network on 2026-09-07, so we could not read what it currently says and are not characterising it.
- The archived repository's last push was 2025-05-28 and GitHub reports it as archived, which matches the 2025-05-29 date the Datadog write-up gives. We did not find a first-party announcement stating the date.

## Sources

- [MCP vulnerability case study: SQL injection in the Postgres MCP server](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/): Datadog Security Labs. The original write-up of the escape and the patched call shape.
- [@modelcontextprotocol/server-postgres on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres): Version, publish date, the deprecation notice on all eight versions, and the download count.
- [modelcontextprotocol/servers-archived](https://github.com/modelcontextprotocol/servers-archived): The archived repository the reference servers moved to.
- [PostgreSQL: SET TRANSACTION and default_transaction_read_only](https://www.postgresql.org/docs/17/sql-set-transaction.html): Why the session setting outlives the transaction the escape commits.
- [PostgreSQL: the simple and extended query protocols](https://www.postgresql.org/docs/17/protocol-flow.html): Why a bare string can carry several statements and a prepared statement cannot.
- [crystaldba/postgres-mcp](https://github.com/crystaldba/postgres-mcp): A maintained alternative whose restricted mode rejects statements containing commit or rollback.

## Related

- [Migrating off @modelcontextprotocol/server-postgres](https://pgbeam.com/guides/migrate-off-server-postgres-mcp): The three destinations, what each is good at, and the config for each.
- [Giving Claude Code read-only Postgres access](https://pgbeam.com/guides/claude-code-read-only-postgres): The role, the grants, and the limits of doing it with grants alone.
- [Auditing what an agent ran against your database](https://pgbeam.com/guides/audit-what-an-agent-ran-against-your-database): What pgaudit records, and the per-agent identity it cannot supply.
- [PgBeam vs a DIY MCP server](https://pgbeam.com/compare/diy-mcp-server): The buyer-shaped version of the same decision.
