Postgres MCP Server: Setup, Tools, and Safe Query Access
A practical guide to running a Postgres MCP server — which implementation to pick, how to wire it into Claude Desktop, Cursor, and VS Code with Docker, what tools it exposes, and how to give an agent query access without handing it your database.
TL;DR: A Postgres MCP server is a small process that exposes your PostgreSQL database to an AI client as a set of Model Context Protocol tools — usually schema introspection, query execution, and EXPLAIN. Setup is a Docker command plus a JSON block in your client config. The hard part is not setup, it is access: a “read-only mode” implemented in the server process is not a security boundary. Enforce it in Postgres with a dedicated role, GRANT SELECT only, row-level security, and a statement timeout — then let the server be convenient rather than trusted.
What a Postgres MCP Server Actually Does
A Postgres MCP server is a server implementing the Model Context Protocol that sits between an AI client (Claude Desktop, Cursor, VS Code, or a custom agent) and a PostgreSQL database. It advertises a set of callable tools — typically list schemas, describe tables, run a SQL query, and explain a query plan — and translates each tool call into a real connection against Postgres, returning rows as text the model can read.
The protocol part is unglamorous and that is the point. MCP standardizes how a client discovers what a server can do and how it invokes it. Before MCP, every assistant that wanted to read your database needed a bespoke integration. Now the assistant speaks one protocol, and a PostgreSQL MCP server translates it into psql-shaped work.
What you get in practice is an assistant that can answer “why is this order stuck in pending?” by actually looking, instead of guessing from your schema file. It can read information_schema, sample a table, check an index, and run EXPLAIN ANALYZE on the query you are complaining about.
What you also get, if you are careless, is a language model with a psql prompt and no supervision. That is the second half of this guide.
The Tool Surface: What Gets Exposed
Implementations differ, but nearly every SQL MCP server converges on the same four categories.
Schema introspection. Tools like list_schemas, list_objects, and get_object_details walk the catalog so the model knows what tables exist and how they relate. Some servers expose schemas as MCP resources rather than tools, which lets the client load them into context without a round trip per table.
Query execution. One tool — query, execute_sql, run_sql — that takes a SQL string and returns rows. This is the tool that does the work and the tool that will hurt you.
Plan analysis.explain_query wraps EXPLAIN or EXPLAIN (ANALYZE, BUFFERS) so the assistant can reason about a plan instead of guessing. Richer servers add index-tuning advice built on hypothetical indexes.
Health and diagnostics. The more opinionated servers add analyze_db_health, get_top_queries (reading pg_stat_statements), bloat checks, and vacuum status.
Note what is missing from that list: any concept of which rows this particular user is allowed to see. MCP has no identity model for your data. The server connects as whatever role is in the connection string, and every tool call inherits that role’s full privileges.
Choosing a Postgres MCP Server
The last row deserves emphasis. Most MCP server Postgres integrations expose a general “run this SQL” tool. The Toolbox model inverts it: you define search_orders_by_customer with typed parameters, and that is the entire surface. You lose ad-hoc exploration and gain the ability to sleep. For a production-facing agent, that trade is usually correct.
Server
How it runs
Access control
Good for
@modelcontextprotocol/server-postgres (reference)
npx / Docker mcp/postgres
Read-only transaction wrapper only
Nothing new — deprecated and moved to modelcontextprotocol/servers-archived in 2025 under a blanket “no security guarantees” notice, and the read-only wrapper was bypassable. Do not start here.
Postgres MCP Pro (crystaldba/postgres-mcp)
Docker or pipx, stdio or SSE
--access-mode=restricted (read-only transaction, statement timeout, SQL parsed to reject COMMIT/ROLLBACK) or unrestricted
The default recommendation. Adds index tuning, health checks, and pg_stat_statements analysis.
host.docker.internal, not localhost. Inside the container, localhost is the container. On Linux you may need --add-host=host.docker.internal:host-gateway.
-i is mandatory for stdio. The client speaks to the server over stdin/stdout. Without -i, the container starts and immediately sees EOF.
If you would rather run it over HTTP so several clients share one server:
An HTTP-transport server is now a network service holding database credentials. Bind it to loopback or put it behind auth. Do not publish 0.0.0.0:8080 on a shared machine.
Wiring It Into Claude Desktop, Cursor, and VS Code
All three clients use the same JSON shape with different file paths and one different root key.
Claude Desktop — ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows:
Cursor — .cursor/mcp.json in the project root for a project-scoped server, or ~/.cursor/mcp.json globally. Same mcpServers key, same body.
VS Code — .vscode/mcp.json in the workspace. The root key here is servers, not mcpServers, and this is the single most common setup failure when someone copies a Cursor snippet:
The inputs block is worth copying even outside VS Code’s syntax: a connection string in a config file is a credential in a config file, and .cursor/mcp.json gets committed by accident constantly. Reference an environment variable or a prompt, and add the file to .gitignore.
Restart the client after editing. Claude Desktop and Cursor both cache the server list at launch.
Read-Only Is a Grant, Not a Flag
Here is the section the rest of the internet skips.
In August 2025, Datadog Security Labs published a case study on Anthropic’s reference Postgres MCP server, @modelcontextprotocol/server-postgres. That server implemented “read-only” by wrapping each query in BEGIN TRANSACTION READ ONLY, then interpolating the model’s SQL string into it. The bypass is exactly what you would guess: submit COMMIT; DROP SCHEMA public CASCADE; and the read-only transaction ends before your statement runs. What makes it work is statement stacking — the Node Postgres driver accepts several semicolon-delimited statements in a single query string, so the server’s closing ROLLBACK finds no transaction left to roll back. The server had already been deprecated and archived by then, and was still pulling roughly 21,000 npm downloads a week.
This is SQL injection with a new attacker. The classic version had a hostile user typing into a form. The MCP version has a helpful model constructing SQL from text it read somewhere — a support ticket, a table comment, a row of user-supplied data — and that text can carry instructions. The model is a confused deputy with a database connection.
Every “read-only mode” implemented inside the server process is a convenience feature. String-parsing SQL to reject dangerous statements is a denylist, and denylists lose. The boundary has to be in Postgres.
Create a role that cannot write, and connect as that role:
sql
CREATE ROLE mcp_reader LOGIN PASSWORD 'use-a-secret-manager';
REVOKE ALL ON DATABASE appdb FROM PUBLIC;
GRANT CONNECT ON DATABASE appdb TO mcp_reader;
GRANT USAGE ON SCHEMA public TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;
-- New tables should not silently become readable later
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO mcp_reader;
-- Belt and braces: the role defaults to read-only sessions
ALTER ROLE mcp_reader SET default_transaction_read_only = on;
One subtlety in that ALTER DEFAULT PRIVILEGES statement: it applies only to objects created by the role that runs it. If your migrations run as app_owner, you need ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public GRANT SELECT ON TABLES TO mcp_reader; — otherwise next month’s tables are invisible to the agent and you will assume the grant is broken.
With this in place, DROP SCHEMA public CASCADE fails at the permission check regardless of what the server did with transaction boundaries. That is a boundary.
Withhold the columns you would not paste into a chat window. The agent’s output goes into a model’s context and often into a log. Revoke rather than trust:
sql
REVOKE SELECT ON users FROM mcp_reader;
GRANT SELECT (id, created_at, plan_tier, region) ON users TO mcp_reader;
A view can do the same job — by default a view executes with its owner’s privileges, so the reader selects from the view without any grant on the base table. The trap is security_invoker = true (Postgres 15+): set that and the view checks the caller’s privileges instead, which puts you back where you started. Column-level grants sidestep the question.
Row-Level Security for Multi-Tenant Data
If the database is multi-tenant, table-level SELECT is far too much. Row-level security scopes what the connection can see, and it applies to the MCP server’s queries the same as any other client:
sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_tenant_isolation ON orders
FOR SELECT TO mcp_reader
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
The catch is that app.tenant_id has to be set on the session, and most MCP servers give you no hook to set it per tool call. Two workable answers: run one server instance per tenant with the setting baked in via ALTER ROLE ... SET, or use the parameterized-tool model where you control the SQL and can bind the tenant yourself. Do not assume RLS is protecting you if nothing sets the variable — with current_setting(..., true) a missing setting returns NULL and the policy matches nothing, which fails closed, but the reverse mistake (a permissive USING (true) fallback) fails wide open.
Also remember that RLS is bypassed by superusers and by roles with BYPASSRLS. Your MCP role must be neither. It should not own the tables either — table owners bypass RLS unless you set FORCE ROW LEVEL SECURITY, which is why it is in the snippet above.
Timeouts, Limits, and Pooling
An agent will eventually write an unbounded cross join. Bound it at the role level so no server config can forget:
sql
ALTER ROLE mcp_reader SET statement_timeout = '10s';
ALTER ROLE mcp_reader SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE mcp_reader SET lock_timeout = '2s';
ALTER ROLE mcp_reader CONNECTION LIMIT 5;
CONNECTION LIMIT matters more than it looks. Each MCP client session tends to hold a connection, and a developer with three editors open is three connections before any query runs.
On pooling: if you front Postgres with PgBouncer in transaction mode, session-scoped state does not survive between statements. SET app.tenant_id, temp tables, prepared statements, and advisory locks will behave unpredictably. Either point the MCP server at a session-mode pool, give it a direct connection with a small CONNECTION LIMIT, or push every setting into ALTER ROLE so it is applied at connect time rather than by a SET the pooler may not preserve.
Finally, log it. log_statement = ’all’ on the MCP role gives you an audit trail of exactly what the agent ran. It is a superuser-only setting, so run the ALTER ROLE as a superuser — the reader role cannot set it for itself, which is the point:
sql
ALTER ROLE mcp_reader SET log_statement = 'all';
A Pre-Production Checklist
Dedicated login role, never the application role and never a superuser
GRANT SELECT only, column-scoped on anything sensitive, with default privileges pinned
RLS enabled and FORCEd on multi-tenant tables, with the session variable actually set
Credentials from a secret manager or a prompt, never committed in mcp.json
Point it at a replica if one exists — a read replica removes a whole class of accidents
Statement logging on, and someone reading the log
Server pinned to a specific image tag, and a plan for who watches its advisories
Where a Single Tool Call Stops Being Enough
An MCP server gives an agent a way to reach data. For exploration, debugging, and internal analysis, that is the whole job, and the guidance above is the whole job of doing it safely.
Production decision-making is a different shape. When an agent has to decide something — approve a limit increase, release an order, escalate a case — it usually needs several things at once: the current balance, a count of recent events, a similarity lookup against prior cases. Under MCP those become separate tool calls, often against separate systems, landing at separate moments. The protocol has nothing to say about whether the results agree with each other. Two calls moments apart can return states that never coexisted, and the agent will happily reason across both.
That is the context gap, and it is an architecture problem rather than a protocol problem. It closes when the context the decision needs is served from one place under one coherent snapshot instead of assembled from several. Tacnode is real-time, multi-modal context infrastructure built for that: the Tacnode Context Lake™ ingests from your existing systems of record via change data capture and holds structured state, aggregations, and vector similarity together, so a decision’s reads resolve against the same set of ingested events rather than three systems at three propagation stages. It speaks the Postgres wire protocol, so the MCP servers in this guide reach it through a changed connection string and nothing else.
Reach for it when the failure you are seeing is agents acting on context that disagreed with itself. For reading your database from an editor, a locked-down Postgres MCP server is the right tool, and the lockdown is the part that matters.