---
title: "Migrating off @modelcontextprotocol/server-postgres"
description: "The archived reference Postgres MCP server has a read-only escape and no maintainer. Which replacement to pick, the config for each, and why rotating the credential matters more than the swap."
canonical: "https://pgbeam.com/guides/migrate-off-server-postgres-mcp"
last-updated: "2026-09-07T00:00:00.000Z"
---

# Migrating off @modelcontextprotocol/server-postgres

> The archived reference Postgres MCP server has a read-only escape and no maintainer. Which replacement to pick, the config for each, and why rotating the credential matters more than the swap.

Canonical: https://pgbeam.com/guides/migrate-off-server-postgres-mcp  
Published: 2026-09-07  
Updated: 2026-09-07  
Package and repository facts on this page were read from the npm, PyPI and GitHub APIs on 2026-09-07. Configuration blocks were taken from each project's own documentation on the same day.

Two questions decide this, and neither is about features. Do you want to run the server yourself, and does more than one agent share the database? Run it yourself with one agent and one trusted operator: take `crystaldba/postgres-mcp` in restricted mode. Several agents, production data, or columns some callers should not read: you need a layer that can refuse a statement and mask a column, because Postgres roles cannot express that per agent without a role per agent. A local database with nothing real in it: swap the package and stop reading.

Do the credential first, whichever you pick. The archived server is not what has your password. Your MCP config is, and so is the shell history where it was pasted, the container image it was baked into, and whatever the agent wrote it into while working. Changing which server runs leaves that credential valid. Rotating it is the step that actually revokes anything.

Then set your expectations for what the swap buys. It closes the write path: the escape in the archived server let `COMMIT; DROP SCHEMA public CASCADE;` out of its read-only transaction, and that is reproduced with transcripts in the advisory. It does not narrow what the agent may read, because that was never the archived server's decision to make. The last section is about that half.

## At a glance

| Field | Value |
| --- | --- |
| What you are leaving | `@modelcontextprotocol/server-postgres` 0.6.2. |
| Why | Archived 2025-05-28, every npm version deprecated, no CVE to pin. |
| Self-hosted destination | `crystaldba/postgres-mcp`, MIT, 3,272 stars. |
| Its safety setting | `--access-mode=restricted`. Not the README default. |
| Hosted destination | A policy-enforcing proxy with an MCP endpoint. |
| The step people skip | Rotating the credential the old config held. |

## Pick a destination

Four honest answers, including the one where you do the least.

| Your situation | Take | Why |
| --- | --- | --- |
| A local database seeded with fake data | Any maintained server. `crystaldba/postgres-mcp` is the obvious one. | There is nothing to protect. Spending an afternoon on policy here is the wrong afternoon. |
| One engineer, one agent, reading a database they may already read entirely | `crystaldba/postgres-mcp` with `--access-mode=restricted`, plus a dedicated role. | The agent's blast radius is already bounded by the human's own access. Restricted mode plus a role that owns nothing closes the write path. |
| Several agents, or production data, or columns some callers must not see | A layer that parses the statement: allowlists, masking, budgets, per-credential audit. | Roles cannot express per-agent column rules without a role per agent, and nobody maintains that across migrations. |
| You have to answer what an agent read, months later | Per-statement recording with a per-agent identity attached. | `pgaudit` records statements against a role. If three agents share one role, the log cannot name which one. |

> **One name to check before you install it**
>
> 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. We do not know what happened to it and are not recommending it. Check any package name a guide gives you against the registry before installing it, including the names on this page.

## Rotate the credential before you change any config

The old config holds a working connection string. Editing the config does not change that, and the string has been readable by every process the agent ran. Rotate it, then create the role you actually meant to give it.

```sql title="psql, as an admin"
-- 1. Kill the old password.
ALTER ROLE old_agent_role WITH PASSWORD 'a-new-value-you-will-not-reuse';

-- 2. A role that owns nothing, reads named tables, and cannot write
--    even if something escapes a transaction.
CREATE ROLE agent_ro LOGIN PASSWORD 'another-new-value';
ALTER ROLE agent_ro SET default_transaction_read_only = on;
GRANT CONNECT ON DATABASE mydb TO agent_ro;
GRANT USAGE ON SCHEMA public TO agent_ro;
GRANT SELECT ON public.orders, public.order_items TO agent_ro;
```

Two deliberate choices in there. `default_transaction_read_only` is set on the role rather than passed in the connection string, because it is the one control the escape in the archived server could not clear and a client that can set a GUC can unset one. And the `GRANT` names tables rather than saying `ALL TABLES IN SCHEMA`, because the schema-wide form is a snapshot of the tables that existed when you ran it, so the next migration's table is either outside it or, if a stale `ALTER DEFAULT PRIVILEGES` line is lying around, silently inside it.

```sql title="Check for the line that would undo this"
SELECT defaclrole::regrole, defaclobjtype, defaclacl
FROM pg_default_acl;
```

## Option one: a maintained server you run

`crystaldba/postgres-mcp` is MIT licensed, had 3,272 stars on 2026-09-07, and publishes at version 0.3.0 on PyPI. Its documentation describes two access modes: unrestricted, for development, and restricted, which it says limits operations to read-only transactions, constrains execution time, and parses SQL to reject statements containing `commit` or `rollback`. That last rule is a direct answer to the escape in the archived server, which is worth knowing when you are comparing them.

> **The copy-paste blocks in its README use unrestricted mode**
>
> Every configuration example published in that project's README passes --access-mode=unrestricted, which is the development setting. Copying one and pointing it at production gives an agent full read and write, including schema changes. Change the flag as you paste. The block below has it set to restricted.

```json title=".mcp.json"
{
  "mcpServers": {
    "postgres": {
      "command": "uvx",
      "args": ["postgres-mcp", "--access-mode=restricted"],
      "env": {
        "DATABASE_URI": "postgresql://agent_ro:another-new-value@localhost:5432/mydb"
      }
    }
  }
}
```

What this gets you: a maintained project, a mode designed for untrusted callers, and query analysis tools the archived server never had. What it does not get you is anything about which tables and columns the agent may read. That is still entirely the job of the grants you wrote in the previous step, and the fifth section is about why that is usually the harder half.

## Option two: a policy layer in front of Postgres

Take this route when the answer to what may this agent read is different per agent, or when you will be asked to prove what one of them did. PgBeam 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.

```bash title="Terminal"
pgbeam policies create \
  --name agent-readonly \
  --mode read_only \
  --allow public.orders --allow public.order_items \
  --mask customers.email=redact \
  --max-rows 500 \
  --budget-queries-per-hour 300 \
  --statement-timeout-ms 8000

pgbeam agents create --name research-agent --policy pol_xxx --expires 30d
```

```json title=".mcp.json"
{
  "mcpServers": {
    "pgbeam": {
      "type": "http",
      "url": "https://<project>.proxy.pgbeam.app/mcp",
      "headers": { "Authorization": "Bearer pba_..." }
    }
  }
}
```

The same credential works as a connection string, which matters more than it sounds: the agent that has an MCP endpoint today opens a psql shell or an ORM tomorrow, and a server-shaped control is not in that path. Policy, audit trail and kill-switch apply to both doors.

Where this is the wrong choice: a local database with nothing real in it, a single trusted operator whose own access is already the bound, or a requirement for one policy plane across every tool an agent holds rather than the database specifically. The third one is an MCP gateway's job and PgBeam is not a substitute for it.

## What a swap does not fix

Every option above closes the write path. None of them, on its own, narrows the read path, and by volume the read is the more likely incident. Four things survive the migration:

- 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 IN SCHEMA public` covers the tables that existed when it ran. The next migration's table is outside it unless a default-privileges line puts it back inside.
- Attribution: the server log and `pg_stat_activity` record a role. Three agents sharing `agent_ro` are one row, and which one ran the statement is the first question an incident asks.
- Volume: `SELECT * FROM customers` with no `LIMIT` is a legitimate read-only query. `statement_timeout` bounds how long it runs, not how much data leaves.

You can close the first two with column-level grants and a view layer, and it is real work rather than a paragraph of advice: the SQL, the grants that make it hold, and the leak that survives both are written out in the PII guide linked at the bottom. Close the third with a role per agent, which works and costs a migration each time somebody hires an agent. Close all four with a layer that decides per credential.

## Verify the swap, do not assume it

Run the payload that worked against the old server, through whatever you just installed, against a database you do not mind losing. A migration you did not test is a belief.

```sql title="The payload, against a scratch database"
BEGIN TRANSACTION READ ONLY;
COMMIT; CREATE TABLE public.canary (x int);
```

With `default_transaction_read_only` set on the role, PostgreSQL 17.11 answers `ERROR: cannot execute CREATE TABLE in a read-only transaction`. If you get `CREATE TABLE` instead, the setting is not on the role you are connecting as.

Then check the half nobody tests: pick a table the agent should not be able to read and try to read it. If it comes back, the swap fixed the write path and left the read path exactly where it was. On PgBeam the same check runs without touching the database at all:

```bash title="Terminal"
pgbeam policies dry-eval --policy pol_xxx --sql "SELECT ssn FROM public.customers"
pgbeam policies dry-eval --policy pol_xxx --sql "COMMIT; DROP SCHEMA public CASCADE"
pgbeam audit list --event blocked --limit 10
```

## What we could not verify

- We did not run `crystaldba/postgres-mcp` ourselves. What restricted mode enforces on this page is what its README states, including the rule that rejects statements containing commit or rollback, and we have not independently reproduced it.
- `mcp-server-pg`, named as a drop-in replacement in several write-ups, resolved to a security holding package on npm on 2026-09-07 at version 0.0.1-security with 4 downloads in the week. We cannot say what happened to it, so it is not recommended here.
- The Zed fork, `@zeddotdev/postgres-context-server`, was published at 0.1.7 and had 2,901 downloads in the week ending 2026-09-06. We did not evaluate it and are not characterising what it fixed.
- postgres-mcp.dev, which publishes its own migration page, did not resolve from our network on 2026-09-07, so we could not read what it currently recommends.
- No CVE has been issued against the archived package, so there is no identifier to hand a scanner or an SBOM policy to catch the next installation of it.

## Sources

- [crystaldba/postgres-mcp](https://github.com/crystaldba/postgres-mcp): Access modes, the configuration blocks, and what restricted mode enforces.
- [postgres-mcp on PyPI](https://pypi.org/project/postgres-mcp/): Version 0.3.0 as of 2026-09-07.
- [@modelcontextprotocol/server-postgres on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres): The deprecation notice on all eight versions, and the download count.
- [modelcontextprotocol/servers-archived](https://github.com/modelcontextprotocol/servers-archived): Where the reference servers were moved. Archived, last push 2025-05-28.
- [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.
- [PostgreSQL: GRANT](https://www.postgresql.org/docs/17/sql-grant.html): Why ALL TABLES IN SCHEMA is a snapshot and not a rule.

## Related

- [The read-only escape in @modelcontextprotocol/server-postgres](https://pgbeam.com/guides/server-postgres-mcp-sql-injection): The advisory: the reproduction, and what each mitigation covers.
- [How to mask PII before it reaches an LLM](https://pgbeam.com/guides/mask-pii-before-it-reaches-an-llm): The view layer and grants that close the read path, and the leak that survives them.
- [Giving Claude Code read-only Postgres access](https://pgbeam.com/guides/claude-code-read-only-postgres): The role and the grants, written out.
- [PgBeam vs a DIY MCP server](https://pgbeam.com/compare/diy-mcp-server): The buyer-shaped version of the same decision.
