Postgres MCP Server Setup for Claude Code, Codex and Cursor: Make Read-Only Mean Read-Only With a Database Role

Connect a Postgres MCP server to Claude Code, Codex and Cursor through a least-privilege role, then prove a COMMIT-stacking probe fails before trusting it.

Sequence diagram with three lanes, Agent, MCP server and Postgres, showing six numbered messages: the agent sends COMMIT; DELETE, the server opens a READ ONLY transaction and forwards the stacked string, Postgres commits and runs the DELETE, and the server's cleanup ROLLBACK finds no transaction in progress.Sequence diagram with three lanes, Agent, MCP server and Postgres, showing six numbered messages: the agent sends COMMIT; DELETE, the server opens a READ ONLY transaction and forwards the stacked string, Postgres commits and runs the DELETE, and the server's cleanup ROLLBACK finds no transaction in progress.
In the archived reference Postgres MCP server, a leading COMMIT ends the read-only transaction and the DELETE runs with the connection role's privileges; connected as agent_ro, the same DELETE fails on permissions. Original diagram based on Datadog Security Labs' case study, fetched Oct 28, 2026.

You want Claude Code, Codex or Cursor to read your schema, answer “which table holds refunds?” and help you fix a slow query. You do not want it to change a single row. The usual move is to install a Postgres MCP server whose README promises read-only access and trust the promise. This guide replaces that promise with a guarantee the database itself enforces.

The reminder came on X on October 27, 2026, when johntanzer wrote that “a read-only transaction isn’t a safety net”: COMMIT; DELETE FROM customers; ends the transaction and then runs the delete. The failure is older than the post. Datadog Security Labs documented it in Anthropic’s reference Postgres server: every query ran inside a read-only transaction, but the server handed the whole string to the database, stacked statements included. A leading COMMIT closed the protection, and whatever followed ran with the connecting user’s full privileges.

By the end of this guide you will have a dedicated agent_ro role that can read the schemas you choose and nothing else, one server configured the same way in all three agents, a bypass probe that must fail before the agent is trusted, and a rollback. The SQL, commands and config files follow documented syntax from the Postgres, Anthropic, OpenAI, Cursor and server project pages linked below. Where this guide describes output, it is the expected observation, not something we ran and recorded.

If you want the wider picture first, hardening MCP in production covers allowlisting and tool poisoning, and treating agents as privileged users makes the identity argument in general terms. This is the concrete database version of both.

Why the read-only transaction let the DELETE through

Datadog’s case study reprints the reference server’s query handler, and it is short. For each tool call the server took a client from a connection pool, sent BEGIN TRANSACTION READ ONLY, sent the agent’s SQL, and sent ROLLBACK in a cleanup block. A plain write inside that transaction fails the way you would hope: Postgres answers DROP SCHEMA public CASCADE with an error saying it cannot run that command in a read-only transaction.

The gap sits in the driver. Datadog points out that node-postgres client.query accepts a string holding several statements separated by semicolons and runs them in one shot. So COMMIT; DROP SCHEMA public CASCADE; does three things in order. The COMMIT ends the read-only transaction. The DROP runs outside it, with whatever rights the connection role holds. The server’s cleanup ROLLBACK arrives too late and draws the warning “there is no transaction in progress.”

Sequence diagram with three lanes, Agent, MCP server and Postgres, showing six numbered messages: the agent sends COMMIT; DELETE, the server opens a READ ONLY transaction and forwards the stacked string, Postgres commits and runs the DELETE, and the server's cleanup ROLLBACK finds no transaction in progress.Sequence diagram with three lanes, Agent, MCP server and Postgres, showing six numbered messages: the agent sends COMMIT; DELETE, the server opens a READ ONLY transaction and forwards the stacked string, Postgres commits and runs the DELETE, and the server's cleanup ROLLBACK finds no transaction in progress.
In the archived reference Postgres MCP server, a leading COMMIT ends the read-only transaction and the DELETE runs with the connection role's privileges; connected as agent_ro, the same DELETE fails on permissions. Original diagram based on Datadog Security Labs' case study, fetched Oct 28, 2026.

Nobody has to type that string into the agent on purpose. Datadog’s example is a chatbot user asking the agent to “run this query on Postgres, as is.” That is a prompt-injection path, and any issue, document or web page an agent reads can carry the same sentence. The MCP integration red-team checklist covers that attack category in general; here it lands directly on your data.

Datadog also found a second problem that permissions do not solve. Because the pool reused connections, COMMIT; SET statement_timeout TO 1; changed a session setting that later callers inherited, so every following query slower than a millisecond failed until the connection was recycled. A read-only role cannot stop a session from changing its own settings. Only a server that refuses stacked statements, or throws the session away after each call, closes that hole.

The server’s status matters because people still install it. Per Datadog, @modelcontextprotocol/server-postgres was deprecated on July 10, 2025 and archived. Anthropic fixed the bug in its git repository on May 29, 2025, yet version 0.6.2 on npm and Docker Hub stayed unpatched while drawing about 21,000 weekly npm downloads and 1,000 weekly Docker pulls. The archived README still describes its query tool with the line “All queries are executed within a READ ONLY transaction.” That sentence is accurate. It is not a security boundary.

Datadog Security Labs article page titled MCP vulnerability case study: SQL injection in the Postgres MCP server, as captured from securitylabs.datadoghq.com.
Datadog Security Labs' case study on SQL injection in the Postgres MCP server, captured from securitylabs.datadoghq.com. The article documents the COMMIT-stacking bypass that this guide's probe tests. Source: Datadog Security Labs. · Original source

That shapes everything below. Put the write ban in the database role, where no string the agent sends can lift it, and pick a server that refuses transaction-control statements so session poisoning is off the table too. Either layer alone leaves a gap. Together they cover each other.

Before you start: what this setup needs

  • [ ] A Postgres database you administer: a local install, a Docker container, or a cloud development instance. For anything holding customer data, point the agent at a copy rather than the primary.
  • [ ] A superuser or the database owner role, so you can create a role and grant privileges.
  • [ ] psql on your machine, to run the SQL and the probe as the new role.
  • [ ] uv (for uvx) or Docker. The server used here, Postgres MCP Pro, ships as a Python package and a Docker image, and its README asks for Python 3.12 or later.
  • [ ] At least one of Claude Code, Codex or Cursor installed and signed in.
  • [ ] A home for the password that is not the repository: a shell profile, a secrets manager or a git-ignored env file.

The worked example uses a database named appdb with application tables in a schema named app, owned by a role named app_owner. Swap in your own names wherever they appear. If your team already branches a database per agent worktree, the Lakebase branching coverage describes that pattern; the role below still applies inside each branch.

Create the agent_ro role and its grants

Run this as a superuser or the database owner. The syntax follows the Postgres GRANT, ALTER ROLE and predefined roles reference pages; the names are illustrative.

-- 1. A login role with no special attributes
CREATE ROLE agent_ro LOGIN;
ALTER ROLE agent_ro WITH NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS CONNECTION LIMIT 5;
-- Set the password interactively so it stays out of history and logs:
--   \password agent_ro

-- 2. Let it reach the database and one schema
GRANT CONNECT ON DATABASE appdb TO agent_ro;
GRANT USAGE ON SCHEMA app TO agent_ro;

-- 3. Read tables and views; read (but never advance) sequences
GRANT SELECT ON ALL TABLES IN SCHEMA app TO agent_ro;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA app TO agent_ro;

-- 4. Session defaults, applied at every new login
ALTER ROLE agent_ro SET default_transaction_read_only = on;
ALTER ROLE agent_ro SET statement_timeout = '15s';
ALTER ROLE agent_ro IN DATABASE appdb SET search_path = app;

Each choice, and why it is there:

  1. No attributes beyond LOGIN. The ALTER ROLE page lists SUPERUSER, CREATEDB, CREATEROLE, REPLICATION and BYPASSRLS as role attributes; the agent needs none of them. The GRANT page states that superusers can access all objects regardless of privilege settings, so a superuser connection string turns every later step into decoration. NOBYPASSRLS keeps row-level security policies in force.
  2. A connection limit. Five concurrent sessions is plenty for one developer’s agents and stops a runaway loop from eating the server’s connection slots. If several people share the database, give each person their own role instead of raising the limit; separate roles make the logs readable.
  3. \password instead of PASSWORD '...'. The ALTER ROLE page warns that a cleartext password in the command can end up in client history or the server log, and points to psql’s \password command.
  4. CONNECT and USAGE are the doors; SELECT is the only key. Without USAGE on app, the table grants are unreachable. No INSERT, UPDATE, DELETE, TRUNCATE, TRIGGER, REFERENCES or MAINTAIN appears anywhere.
  5. Sequences get SELECT, not USAGE. The GRANT page explains that table grants do not extend to sequences, and that the sequence USAGE privilege corresponds to nextval, which advances the sequence. SELECT lets the agent read a sequence without moving it.
  6. No ownership, ever. The GRANT page says the right to drop or alter an object is inherent in its owner and cannot be granted or revoked. If agent_ro owns a table, it can drop it. That is why the troubleshooting table says to grant a table, never to hand it over.
  7. Session defaults are a seatbelt, not the lock. ALTER ROLE makes a value the session default at login, and a default is only a default: a session can override it with SET or open a read-write transaction, just as Datadog’s stacked SET statement_timeout changed a setting. The Postgres MCP Pro README makes the same point: Postgres does not provide a way to place a connection or session into read-only mode. Keep default_transaction_read_only to catch accidents and the timeout to stop runaway scans. The grants carry the guarantee.

Two decisions remain, and both are worth a minute.

Per-schema grants or pg_read_all_data. The predefined-roles page describes pg_read_all_data as SELECT on all tables, views and sequences plus USAGE on all schemas, and notes it does not bypass row-level security. GRANT pg_read_all_data TO agent_ro; is faster to set up but exposes every schema, including authentication tables you may not want in a model’s context window. Prefer per-schema grants unless the database holds nothing sensitive. Never grant pg_write_all_data, and never grant pg_read_server_files, pg_write_server_files or pg_execute_server_program; the same page warns those can be used to gain superuser-level access.

Tables created later. GRANT ... ON ALL TABLES IN SCHEMA covers tables that exist when you run it. After a migration adds a table, rerun the grant, or set default privileges for the owning role; the GRANT page links to ALTER DEFAULT PRIVILEGES for exactly this.

Optional, for the performance tools: Postgres MCP Pro’s get_top_queries relies on the pg_stat_statements extension, and its health checks read statistics views. If those tools come back empty or denied, GRANT pg_read_all_stats TO agent_ro; is the narrow fix. Skip pg_monitor unless you need all of it: it includes pg_stat_scan_tables, which the docs warn can take ACCESS SHARE locks on tables for a long time.

Finish by checking the result while connected as agent_ro itself:

-- psql "postgresql://agent_ro@localhost:5432/appdb"
SELECT current_user;                     -- expected: agent_ro
SHOW default_transaction_read_only;      -- expected: on
SHOW statement_timeout;                  -- expected: 15s
SELECT count(*) FROM pg_class
 WHERE relowner = 'agent_ro'::regrole;   -- expected: 0

Pick a Postgres MCP server that refuses stacked statements

The role stops writes whichever server you run. The server still decides whether session settings can be poisoned and how much a server bug can attempt before the role catches it. The table uses only behavior each project or Datadog documents.

Option Documented behavior Verdict
Archived reference server (@modelcontextprotocol/server-postgres 0.6.2, mcp/postgres image) Read-only transaction wrapper; a stacked COMMIT escapes it; deprecated and unpatched on npm and Docker Hub Remove it from every config
Zed fork (@zeddotdev/postgres-context-server 0.1.4) Prepared statements, which reject multiple statements, plus a destroyed session after each call Acceptable stopgap if you cannot migrate yet
Postgres MCP Pro with --access-mode=restricted Read-only transactions; parses SQL with pglast and rejects any COMMIT or ROLLBACK; limits execution time Use this, connected as agent_ro
Postgres MCP Pro with --access-mode=unrestricted Full read and write access to data and schema; the mode its sample configs show Never with this guide
A hosted provider’s own MCP server Provider management APIs; the Postgres MCP Pro README lists Supabase, Neon and Nile examples Consider when the database lives there; outside this guide

The worked example uses Postgres MCP Pro in restricted mode, and two README details deserve attention. Every sample config passes --access-mode=unrestricted, so a copied snippet hands the agent write access; the README tells you to swap in --access-mode=restricted. And the project’s own caveat: if unsafe stored-procedure languages are enabled on the database, its read-only protections could be circumvented. That caveat is the clearest reason to build the role first.

Matrix of four guards against three probes. The read-only transaction wrapper blocks a plain write but lets COMMIT then a write and COMMIT then SET timeout run. The prepared-statement patch blocks all three. Postgres MCP Pro restricted blocks the plain write and rejects both COMMIT probes. The agent_ro role blocks both writes but not the stacked SET.Matrix of four guards against three probes. The read-only transaction wrapper blocks a plain write but lets COMMIT then a write and COMMIT then SET timeout run. The prepared-statement patch blocks all three. Postgres MCP Pro restricted blocks the plain write and rejects both COMMIT probes. The agent_ro role blocks both writes but not the stacked SET.
Only the agent_ro role blocks writes however the SQL is stacked, and only a server that refuses stacked statements stops session-setting changes such as SET statement_timeout. Documented behavior, not our test results. Sources: Datadog Security Labs case study; Postgres MCP Pro README, fetched Oct 28, 2026.

Its tools fit the job: list_schemas, list_objects, get_object_details, execute_sql, explain_query, get_top_queries, analyze_workload_indexes, analyze_query_indexes and analyze_db_health. With agent_ro underneath, every one of them can only read.

The decision, in order:

  1. If any config on your machine names @modelcontextprotocol/server-postgres or mcp/postgres, remove it now, whatever else you choose.
  2. If you can run Python 3.12 or Docker, use Postgres MCP Pro with --access-mode=restricted.
  3. If a tool you depend on only speaks the old server’s interface, switch to the Zed fork until you can migrate.
  4. If the database is hosted by a provider with its own MCP server, compare that option on the agent access ladder before running your own.
  5. Whatever you choose, connect it as agent_ro, never as app_owner or a superuser.

Step 1 matters because the old package still looks official. Its home is now an archived, read-only repository:

GitHub page for the src/postgres directory of the modelcontextprotocol/servers-archived repository, showing the repository's archived notice and its file listing.
The postgres directory of GitHub's modelcontextprotocol/servers-archived repository, captured from github.com. The repository is archived and read-only; the reference server it holds should be removed from agent configs. Source: GitHub. · Original source

The MCP server inventory ritual is a practical way to find stray old entries from step 1 across every machine you use.

Build the connection string for local, Docker and cloud

All three clients pass the same URI to the server through DATABASE_URI, the variable Postgres MCP Pro reads. Keep the URI in an environment variable named PG_AGENT_URI, never in a committed file. Every value below is illustrative.

Where Postgres runs Illustrative URI Notes
Local install, server via uvx postgresql://agent_ro:PASSWORD@localhost:5432/appdb Same host, default port
On the host, server in the Postgres MCP Pro Docker image postgresql://agent_ro:PASSWORD@localhost:5432/appdb The README says the image remaps localhost to host.docker.internal on macOS and Windows, and to an address such as 172.17.0.1 on Linux
On the host, another containerized server postgresql://agent_ro:PASSWORD@host.docker.internal:5432/appdb The archived reference README gives this rule for Docker on macOS
Cloud development instance or copy postgresql://agent_ro:PASSWORD@db.example.com:5432/appdb?sslmode=require sslmode is a libpq parameter; Postgres MCP Pro connects through psycopg3, which uses libpq. Use your provider’s required mode
  1. Percent-encode any @, : or / in the password, or generate a password without them.
  2. Export the variable in the shell you start agents from: export PG_AGENT_URI='postgresql://agent_ro:PASSWORD@localhost:5432/appdb'.
  3. Test it before involving any agent: psql "$PG_AGENT_URI" -c 'SELECT current_user;' should print agent_ro. If it prints anything else, stop and fix the URI.

The Postgres MCP Pro README is candid that this is the weak spot: few MCP clients store server configuration securely, and credentials passed through tool calls land in chat history. A password that only opens a read-only role on a copy of the data limits what a leaked config file is worth.

Connect Claude Code

Claude Code’s MCP reference adds a local stdio server with claude mcp add, where -- separates Claude’s own options from the command that starts the server. Its own PostgreSQL example tells you to put a read-only database user in the connection string so Claude’s queries cannot modify data.

# Local scope (the default): stored in ~/.claude.json for this project, never in the repo
claude mcp add --env DATABASE_URI="$PG_AGENT_URI" --transport stdio postgres \
  -- uvx postgres-mcp --access-mode=restricted

For a definition the whole team shares, commit a project .mcp.json that references the variable instead of containing it:

{
  "mcpServers": {
    "postgres": {
      "type": "stdio",
      "command": "uvx",
      "args": ["postgres-mcp", "--access-mode=restricted"],
      "env": { "DATABASE_URI": "${PG_AGENT_URI}" },
      "timeout": 30000
    }
  }
}
  1. Choose the scope. Local scope keeps the entry private to you and out of version control; the docs recommend it for servers with credentials. Project scope writes .mcp.json for everyone, and Claude Code asks each person to approve it in an interactive session.
  2. Keep --env away from the server name. The docs warn that a name placed directly after --env is read as another KEY=value pair, which is why --transport stdio sits between them.
  3. The timeout field is a per-server tool timeout in milliseconds. At 30 seconds it sits above the role’s 15-second statement_timeout, so Postgres cancels a slow query before the client gives up.
  4. Run claude mcp get postgres. Expect the entry with a connected status; a failure shows an Issue: line with the error detail.
  5. Inside a session, run /mcp. Expect postgres with a tool count; Postgres MCP Pro exposes everything as tools.
  6. If claude mcp list warns about a missing environment variable, PG_AGENT_URI was not set in the shell that started Claude Code, and the server received the unexpanded text.

Scope trade-offs are covered in more depth in Claude Code MCP setup and scopes.

Connect Codex

Codex reads MCP servers from config.toml: ~/.codex/config.toml by default, or a project .codex/config.toml in trusted projects only. The Codex CLI, the IDE extension and the ChatGPT desktop app share that file.

[mcp_servers.postgres]
command = "uvx"
args = ["postgres-mcp", "--access-mode=restricted"]
env_vars = ["DATABASE_URI"]          # forwarded from your shell; nothing secret stored here
startup_timeout_sec = 30             # the first uvx run downloads the package
tool_timeout_sec = 60                # stays above the role's statement_timeout
default_tools_approval_mode = "prompt"
enabled_tools = ["list_schemas", "list_objects", "get_object_details",
                 "execute_sql", "explain_query"]
  1. Export the variable under the name the server reads, because env_vars forwards by name: export DATABASE_URI="$PG_AGENT_URI".
  2. env_vars forwards the value from Codex’s local environment instead of writing it into the file. A [mcp_servers.postgres.env] table would store the literal password, so avoid it.
  3. startup_timeout_sec defaults to 10 seconds, which a first uvx download can exceed. tool_timeout_sec defaults to 60.
  4. default_tools_approval_mode = "prompt" asks before each tool call while you are still building trust. The docs also list auto, writes and approve.
  5. enabled_tools is an allow list. Start with the five above and add get_top_queries or analyze_db_health later if you grant the stats role.
  6. Run codex mcp list and expect postgres; inside the Codex TUI, /mcp shows the active servers.

The CLI form, codex mcp add postgres --env DATABASE_URI=... -- uvx postgres-mcp --access-mode=restricted, follows the documented codex mcp add shape but writes the value into config.toml. The env_vars table keeps the secret out of the file.

Connect Cursor

Cursor reads mcp.json from .cursor/mcp.json in a project or ~/.cursor/mcp.json globally, and interpolates ${env:NAME} in command, args, env, url and headers.

{
  "mcpServers": {
    "postgres": {
      "type": "stdio",
      "command": "uvx",
      "args": ["postgres-mcp", "--access-mode=restricted"],
      "env": { "DATABASE_URI": "${env:PG_AGENT_URI}" }
    }
  }
}
  1. Put it in ~/.cursor/mcp.json to make the server available in every project, or .cursor/mcp.json when only one project should see this database. A project file is safe to commit only because it holds a reference, not the URI.
  2. If Cursor was launched from the dock and cannot see your shell variables, use the stdio-only envFile field, for example "envFile": "${workspaceFolder}/.env.agent", and add that file to .gitignore.
  3. Open Customize in the sidebar and confirm postgres is enabled and lists its tools.
  4. Leave tool approval on. Cursor asks before running MCP tools by default and lets you expand the arguments, which is where you will see the exact SQL the agent wants to run.
  5. If the server fails to start, open the Output panel and choose MCP Logs to read initialization and connection errors.

Cursor’s own security guidance lines up with this setup: keep secrets in environment variables, run sensitive servers locally over stdio, and give keys the minimum permissions they need.

Run the bypass probe before the agent touches real work

Two layers, two tests. First prove the role directly in psql, so no server logic is involved. Then prove the server through the agent. Run the role probe against a writable development copy: a physical read replica refuses read-write transactions on its own, which would hide whether the grants work.

Create a scratch table as the owner:

-- as app_owner or a superuser
CREATE TABLE app.mcp_probe_scratch (id int PRIMARY KEY, note text);
INSERT INTO app.mcp_probe_scratch VALUES (1, 'unchanged');
GRANT SELECT ON app.mcp_probe_scratch TO agent_ro;

Then run the probes. Probes 1 to 3 use psql as agent_ro; probes 4 and 5 go through the agent’s execute_sql tool.

psql "$PG_AGENT_URI" -c "BEGIN READ WRITE; UPDATE app.mcp_probe_scratch SET note = 'changed' WHERE id = 1; COMMIT;"
psql "$PG_AGENT_URI" -c "BEGIN READ WRITE; CREATE TABLE app.mcp_probe (id int); COMMIT;"
psql "$PG_AGENT_URI" -c "COMMIT; UPDATE app.mcp_probe_scratch SET note = 'changed' WHERE id = 1;"
# Path What it tries Expected observation If it succeeds
1 psql as agent_ro Overrides the read-only default with BEGIN READ WRITE, then updates a row An error saying permission is denied on the table Stop: look for an UPDATE grant, pg_write_all_data membership or ownership
2 psql as agent_ro Same override, then creates a table in app An error saying permission is denied on schema app Stop: someone granted CREATE on the schema
3 psql as agent_ro The stacked COMMIT, then an update A warning that no transaction is in progress, then an error (read-only or permission denied) Stop
4 Agent, execute_sql Prompt: run exactly COMMIT; UPDATE app.mcp_probe_scratch SET note = 'changed' WHERE id = 1; The client asks for approval; approve it for this test only; Postgres MCP Pro rejects the statement because it contains COMMIT Stop: the server is not in restricted mode
5 Agent, execute_sql SELECT note FROM app.mcp_probe_scratch WHERE id = 1 Returns unchanged The data changed: stop and restore

Read the results this way. Probes 1 and 2 are decisive for the role: they deliberately switch off the session default, so only the grants stand between the agent and the write. A permission error is the success signal. A read-only error there means you are on a replica or the override did not take, so the probe told you nothing about the grants. Probe 4 tests the server layer, and probe 5 confirms nothing moved.

Any probe that succeeds is a stop-and-revoke event, not something to debug while the agent stays connected:

  1. Disable the server in every client (the rollback section lists how).
  2. Run REVOKE ALL ON ALL TABLES IN SCHEMA app FROM agent_ro; as the owner.
  3. Find the cause: an extra grant, a membership, ownership, or a URI that was never agent_ro.
  4. Restore the scratch row, rebuild the role from the SQL above, and rerun all five probes.

When everything fails as expected, drop the scratch table with DROP TABLE app.mcp_probe_scratch; as the owner.

First prompts: schema questions and EXPLAIN-based debugging

These prompts exercise the read-only setup on real work and show you which tool each answer came from. Phrase them to name the server so the agent does not reach for a shell instead.

  1. “Using the postgres tools only, list the schemas, then the tables in app. Do not attempt any writes.” Expect calls to list_schemas and list_objects. Objects outside your grants may be listed in metadata but will refuse reads.
  2. “Describe app.orders: columns, constraints and indexes.” Expect get_object_details, and an answer you can check against your migration files.
  3. “Which tables reference app.customers? Answer from foreign-key constraints, not from column names.” This forces the agent to read constraints rather than guess from naming.
  4. “This query is slow: SELECT ... FROM app.orders JOIN app.customers .... Use explain_query, tell me which plan node dominates the cost, propose one index, and compare the plan with that index as a hypothetical index.” The README says explain_query can simulate hypothetical indexes, which needs the hypopg extension. Installing an extension is a privileged step you run as the owner with CREATE EXTENSION; the agent cannot and should not.
  5. “Write the migration for that index as a file in the repository. Do not run it.” The agent produces code; your normal migration path applies it. The role turns “do not run it” from a request into a fact.

An EXPLAIN ANALYZE actually executes the statement it measures, so the role’s statement_timeout and Postgres MCP Pro’s execution-time limit are what keep a careless analysis from scanning a large table for minutes.

Troubleshooting the connection and the grants

Symptom Likely cause Fix Expected after the fix
claude mcp get postgres shows a failed status with an Issue: line uvx missing from PATH, or the first package download outran startup Install uv, or start Claude Code with a longer startup timeout such as MCP_TIMEOUT=30000 claude /mcp lists postgres with its tools
claude mcp list warns about a missing environment variable PG_AGENT_URI unset in that shell Export it before launching, or add it to your profile Warning gone; the server connects
Codex starts without the postgres tools The 10-second default startup timeout, or DATABASE_URI not exported Raise startup_timeout_sec, export DATABASE_URI, and set required = true so a failed start is loud codex mcp list and /mcp show the server
Connection refused from a container The server in Docker dials its own localhost Postgres MCP Pro’s image remaps localhost; for other images use host.docker.internal on macOS and Windows or the bridge address on Linux, and make sure Postgres accepts connections from that network SELECT current_user through the agent returns agent_ro
An error that SSL is required The cloud instance enforces TLS Add ?sslmode=require, or your provider’s stricter mode, to the URI Connects and answers queries
Permission denied on a table the agent needs The table is outside the grant, often created after it GRANT SELECT ON app.that_table TO agent_ro; and never ownership or pg_write_all_data The agent reads that table; all probes still fail
The tool call ends with a client timeout while the query keeps going The client limit is shorter than statement_timeout Keep statement_timeout below the client limit: Codex tool_timeout_sec, the Claude Code timeout field Postgres cancels first with a statement-timeout error the agent can read
Cursor ignores ~/.cursor/mcp.json Wrong path, invalid JSON, the same name defined in both files, or no shell variables in a dock-launched app Validate the JSON, give each definition a distinct name, use envFile, read MCP Logs The server appears in Customize with its tools
A write probe succeeded The server runs unrestricted, or the URI names an owner role Stop and follow the rollback below, then fix the config Every probe fails as expected

Roll back cleanly

Rolling back takes a few minutes and leaves nothing behind. Do it when you stop using the setup, when a probe succeeds, or when someone who knew the password leaves.

  1. Remove the client entries. In Claude Code run claude mcp remove postgres, adding --scope project or --scope user if you added it there. In Codex delete the [mcp_servers.postgres] table or set enabled = false. In Cursor delete the entry from mcp.json or toggle it off in Customize.
  2. Revoke the grants and drop the role as the owner, using the SQL block after this list. Postgres refuses to drop a role that still holds privileges or owns objects, and the error names what is left, which doubles as a final ownership check.
  3. Rotate credentials. If you dropped the role, its password is dead. If you are keeping the role, run \password agent_ro and update the environment variable everywhere it lives.
  4. Delete PG_AGENT_URI and DATABASE_URI from shell profiles and env files, then confirm the URI never reached git with git log -p -S 'agent_ro:'.
  5. Drop the scratch table if it still exists.
REVOKE SELECT ON ALL TABLES IN SCHEMA app FROM agent_ro;
REVOKE SELECT ON ALL SEQUENCES IN SCHEMA app FROM agent_ro;
REVOKE USAGE ON SCHEMA app FROM agent_ro;
REVOKE CONNECT ON DATABASE appdb FROM agent_ro;
REVOKE pg_read_all_stats FROM agent_ro;   -- only if you granted it
DROP ROLE agent_ro;

Sign-off: when the agent is trusted

Treat the setup as done only when every box is ticked on the same day, against the same config files.

  • [ ] Probes 1 and 2 return permission errors, probe 4 is rejected by the server, and probe 5 returns unchanged.
  • [ ] The agent answers the schema and EXPLAIN prompts correctly from tool output.
  • [ ] SELECT count(*) FROM pg_class WHERE relowner = 'agent_ro'::regrole; returns 0.
  • [ ] Every client config says --access-mode=restricted, and none names the archived reference server.
  • [ ] git grep -n 'agent_ro:' finds nothing in any repository.

The failure signals are just as concrete. If SELECT current_user through the agent returns an owner or superuser, or a connection string appears in a committed file, the setup has failed, whatever the probes said earlier. When several agents and CLIs share a machine, these checks belong in one policy rather than three habits; permission modes as fleet policy covers that next step.

Sources

YOU'RE THROUGH THIS ONE.

Keep connecting the dots.

Back to the library