Postgres MCP server: which one to run, and how to make it read-only

By Arshad Ansari

A Postgres MCP server is a small program that lets an AI client such as Claude Code or Cursor list your tables and run SQL against your database. Search for "postgres mcp" and you will find a dozen of them. The one most tutorials still point at is archived, and its read-only mode could be escaped with one word: COMMIT.

So the useful question is not "which Postgres MCP server?" It is where does the read-only guarantee actually live? My answer, after building an MCP server of my own: in the database role. The server is a convenience on top.

Is there an official Postgres MCP server?

There used to be. The Model Context Protocol project shipped a reference Postgres server as @modelcontextprotocol/server-postgres. It is now in modelcontextprotocol/servers-archived, a repository whose own description is "Reference MCP servers that are no longer maintained". The active modelcontextprotocol/servers repository keeps seven reference servers today (everything, fetch, filesystem, git, memory, sequentialthinking, time). Postgres is not one of them.

Its read-only mode worked like this, from its source:

await client.query("BEGIN TRANSACTION READ ONLY");
const result = await client.query(sql);   // the model's SQL, as one string
// ...
await client.query("ROLLBACK");

The model's SQL went in as a single string, and the Node client accepts several statements separated by semicolons. Datadog Security Labs showed in August 2025 that COMMIT; DROP SCHEMA public CASCADE; ends the read-only transaction and then runs the drop with whatever rights the connection has. The same write-up says the server was deprecated on 10 July 2025 and archived on GitHub, npm and Docker Hub, and that the npm package still had about 21,000 weekly downloads.

That is the whole lesson in one bug. A read-only flag in the server is a promise the server makes. A role without write grants is a fact the database enforces.

Which Postgres MCP server to run instead

The maintained option I would start with is Postgres MCP Pro by Crystal DBA. It is MIT-licensed. I checked the repository on 1 October 2026: the last tagged release is v0.3.0 (May 2025), and the last commit to main was on 16 August 2026.

It gives the model nine tools, among them list_schemas, list_objects, get_object_details, execute_sql, explain_query, get_top_queries (from pg_stat_statements) and analyze_db_health. Two details matter more than the tool list:

  • It has two access modes, and the default is unrestricted, which allows writes and schema changes. You have to pass --access-mode=restricted yourself.
  • Restricted mode parses each statement with pglast and allows only safe statement types, and wraps execution in a 30-second timeout (in server.py, SafeSqlDriver(sql_driver=base_driver, timeout=30)). That timeout runs in the client. I would still set one in Postgres, below, so the database stops the query even if the client goes away.

The README is candid about the limits. It says restricted mode is "conservative" and that "we may add measures in the future to make sure that restricted mode is safe to use with production databases". Read that as: use it with a read-only role, not instead of one.

Other servers exist, including Supabase's own and several "read-only by default" projects. I have not run them, so I won't rank them. The test I would apply to any of them is the same: does it still hold if the server's own check is bypassed?

How to add it to Claude Code or Cursor

Claude Code adds a server from the command line. The Claude Code MCP docs give the shape: options first, then --, then the server's command. Everything after -- goes to the server untouched.

claude mcp add --transport stdio \
  --env DATABASE_URI=postgresql://mcp_reader:PASSWORD@db.internal:5432/app \
  postgres -- uvx postgres-mcp --access-mode=restricted

The default scope is local: only you, only this project. --scope project writes it to .mcp.json at the repository root so the team shares it. --scope user makes it available in every project. If you use project scope, keep the password out of the file you commit; the server reads DATABASE_URI from the environment.

Cursor reads the same mcpServers JSON. Its MCP docs name two places: .cursor/mcp.json in the project, or ~/.cursor/mcp.json for every project.

{
  "mcpServers": {
    "postgres": {
      "command": "uvx",
      "args": ["postgres-mcp", "--access-mode=restricted"],
      "env": {
        "DATABASE_URI": "postgresql://mcp_reader:PASSWORD@db.internal:5432/app"
      }
    }
  }
}

Running a Postgres MCP server in Docker

If you would rather not install Python tooling, Postgres MCP Pro publishes an image. Swap the command:

{
  "mcpServers": {
    "postgres": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "-e", "DATABASE_URI",
               "crystaldba/postgres-mcp", "--access-mode=restricted"],
      "env": {
        "DATABASE_URI": "postgresql://mcp_reader:PASSWORD@localhost:5432/app"
      }
    }
  }
}

The README says the image rewrites localhost to the host's address (host.docker.internal on macOS and Windows, 172.17.0.1 or similar on Linux). So a database on your laptop still works from inside the container.

For one server shared by several people or agents, run it as a long-lived container with --transport=sse or --transport=streamable-http and point clients at its URL. Then the server is a network service with database credentials inside it. It needs the same care as any other: not on a public port, and behind authentication.

How to make it read-only where it counts

This is the part to do before anything else. Four layers, in order of how much they protect you.

1. A role that cannot write. Grant only what the model needs to read. Better still, grant a schema of views that leave out the sensitive columns.

CREATE ROLE mcp_reader LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE app TO mcp_reader;
GRANT USAGE ON SCHEMA reporting TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO mcp_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA reporting
  GRANT SELECT ON TABLES TO mcp_reader;

If you really want everything readable, Postgres 14 and later have a predefined role, pg_read_all_data, which acts "as if having SELECT rights on those objects and USAGE rights on all schemas". It reads everything, including the tables you forgot were sensitive. I prefer explicit grants.

2. Limits on the role itself. These follow the role into every session, whatever the client does:

ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '15s';
ALTER ROLE mcp_reader SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE mcp_reader CONNECTION LIMIT 3;

The Postgres docs define them. statement_timeout aborts any statement that runs longer than the limit; zero, the default, means never. Since Postgres 13 it applies to each statement in a multi-statement string separately. idle_in_transaction_session_timeout ends a session that sits inside an open transaction, which otherwise holds locks and blocks vacuum. default_transaction_read_only makes each new transaction read-only by default.

Be clear about that last one. It is a default, not a lock: a session can run SET default_transaction_read_only = off. It catches mistakes. The grants in step 1 are what stop an attacker.

3. A replica, if you have one. Point the role at a read replica and a runaway query cannot slow down the primary that serves your users.

4. The server's own mode. --access-mode=restricted. Useful, and last.

How I built my own: named tools, not a SQL box

My own system, AEGIS, runs on Postgres, and it serves its tools over MCP to Claude Code runs. I never gave the model a raw SQL tool. Here is the design, from the code in api/routes/mcp_server.py and services/tools/ledger.py:

  • Off by default. The MCP endpoint answers 403 until a setting turns it on.
  • Named tools with fixed queries. A tool like query_activities runs one parameterised SELECT with a LIMIT $1. The model chooses the arguments, never the SQL.
  • Per-agent tool sets. The URL names an agent, and the server offers exactly that agent's tools. A coding run cannot reach the ledger writers at all; the read tool, ledger_query, stays.
  • The ledger read tool is allowlisted twice. ledger_query runs reports over a plain-text accounting journal (hledger, not Postgres). It accepts 18 subcommand names (aliases included) and an exact-match list of options. It refuses @file arguments, because hledger would open that file and splice its contents in as arguments. The comment in the code says why it is an allowlist: hledger "cannot be sandboxed with a deny list", since it accepts bundled short flags and abbreviated long flags.
  • Timeouts and caps everywhere. Every tool call has a 30-second default timeout. The hledger subprocess has its own 30-second limit, and its output is cut at 12,000 characters. Any tool result sent over MCP is capped at 65,536 bytes, so one call cannot flood the caller's context.
  • Writes need a person. On the gated endpoint, any tool not on an explicit read-only list waits for an operator to approve it (a card in Slack or the admin panel) before it runs, whatever the client's own permission settings say. That rule exists because I watched a CLI run execute a write tool through MCP with no approval prompt at all.

The ledger design post covers why the books are a text file. The point here is narrower. When the data matters, a handful of named tools beats a general SQL tool. You lose some flexibility. You gain a list of everything the model can do that fits on one screen.

What can go wrong

The failure modes I would plan for, most likely first:

  1. The server's check is bypassed. It has already happened once. A role without write grants makes it a non-event.
  2. A query that never ends. A cross join on two big tables. Without statement_timeout it holds a connection and a core until someone notices.
  3. The model reads what it should not. Emails, tokens, salary columns. Whatever it reads lands in a chat transcript, and maybe in a vendor's logs. Grant views, not tables.
  4. The password leaks. It sits in a committed .mcp.json, or someone pastes it into the chat. The Postgres MCP Pro README makes the same point: few clients store config securely, and credentials passed through tools end up in chat history.
  5. Confident wrong answers. The model writes valid SQL that answers a different question. That is not a security problem, but it is the one you will meet most often. Chat with your database covers it.

A short checklist before you connect it

  1. The connection uses a role with SELECT grants only, ideally on a schema of views.
  2. That role has statement_timeout, idle_in_transaction_session_timeout and default_transaction_read_only set on it.
  3. It points at a replica or a non-production copy if one exists.
  4. The server runs in its read-only mode (--access-mode=restricted for Postgres MCP Pro).
  5. The password lives in an environment variable, not in a committed file.
  6. You have run COMMIT; CREATE TABLE mcp_test (x int); through it yourself and watched it fail.

So: which Postgres MCP server?

Postgres MCP Pro in restricted mode, against a role that could not write if it tried. The server choice matters less than people think. The role matters more than they think.

If you are wiring an assistant into a database people depend on, the wider guardrails are in how to connect an LLM to your database safely. The first walkthrough is connecting an AI assistant to your database with MCP. If your team lives in dbt, the dbt MCP server gives the model governed metrics instead of raw tables, which is often the better door.

If you would like a second pair of eyes on what you are about to connect, that is the kind of work I do. If all you need is one read-only role and a timeout, you have it above, and you don't need me.

Common questions

Is there an official Postgres MCP server?
Not a maintained one. The Model Context Protocol project's reference Postgres server (@modelcontextprotocol/server-postgres) was deprecated on 10 July 2025 and now sits in the archived modelcontextprotocol/servers-archived repository, which describes itself as "reference MCP servers that are no longer maintained". In August 2025 Datadog Security Labs showed that its read-only mode could be escaped with a COMMIT followed by any statement. The most widely used maintained option is Postgres MCP Pro by Crystal DBA (crystaldba/postgres-mcp, MIT licence), which still received commits in August 2026. Whatever you run, the read-only guarantee should come from the database role, not the server.
How do I add a Postgres MCP server to Claude Code or Cursor?
In Claude Code, run `claude mcp add` with the server's command after a double dash, and pass the connection string with `--env`, for example `claude mcp add --transport stdio --env DATABASE_URI=postgresql://mcp_reader@host:5432/app postgres -- uvx postgres-mcp --access-mode=restricted`. Add `--scope project` to share it through a `.mcp.json` file in the repository. In Cursor, put the same command, args and env under `mcpServers` in `.cursor/mcp.json` in the project, or in `~/.cursor/mcp.json` for every project. Keep the password out of any file you commit.
Can I run a Postgres MCP server in Docker?
Yes. Postgres MCP Pro publishes the crystaldba/postgres-mcp image. Use `docker` as the command and `run -i --rm -e DATABASE_URI crystaldba/postgres-mcp --access-mode=restricted` as the arguments, with DATABASE_URI set in the env block. The image rewrites `localhost` in the connection string to the host's address, so a database on your own machine still works from inside the container. For one server shared by several clients it can also run with `--transport=sse` or `--transport=streamable-http` on a port.
How do I make a Postgres MCP server read-only?
Do it in Postgres, not only in the server. Create a login role that has CONNECT, USAGE on the schemas you want and SELECT on the tables you want, and nothing else. Then set limits on the role itself: `ALTER ROLE mcp_reader SET default_transaction_read_only = on`, `SET statement_timeout = '15s'` and `SET idle_in_transaction_session_timeout = '30s'`. Point the MCP server at that role, and also turn on the server's own read-only mode, such as `--access-mode=restricted` in Postgres MCP Pro. The grants are the real boundary; the settings and the server mode are extra layers.
What can go wrong with a Postgres MCP server?
Four things come up most. The server's read-only check is bypassed, as happened to the archived reference server, so a role with write grants can write. A query scans a huge table or joins two of them and holds a connection and CPU until something kills it, which is why statement_timeout matters. The model reads columns it should not see, such as emails or tokens, and they end up in a chat transcript, so grant only the tables or views it needs. And the connection string with its password ends up in a committed config file or in the chat history itself.

Get new posts by email

Data engineering notes like this one — what breaks and what it costs, in production.

What breaks and what it costs — pipelines, warehouse bills, and the failures that only show up in production. A few a month, never padded to hit a schedule. No sequence, no pitch deck. Reply 'stop' once and you're off — it reaches me, not a queue.

Want the whole playbook?

If this was useful, the long version is my book. Local-First Analytics — 313 pages, runnable code for every chapter — is the full build: DuckDB, Parquet and Arrow, from install to production. On Amazon, and chapter 1 is free to read here.

Get the book

Not ready to buy? Read chapter 1 free — the whole chapter, no email required.

Rather talk it through? Book a free 30-minute call. No slot that suits your time zone? Email info@hikmahtech.in.