Guide
PostgreSQL MCP server: connect Claude and Cursor to Postgres safely
By Aman Singh · Published · 11 min read
Giving Claude or Cursor access to your PostgreSQL database is useful: "which customers ordered more than twice last month?" becomes a question you can just ask. It is also how you end up with an assistant that runs a DELETE or reads a column it should never see. An MCP server is where you decide which of those can happen.
This guide builds a small PostgreSQL MCP server in Node.js with three tools (list tables, describe a table, run a read-only query), and then tries to break it. Everything was run in October 2026 against a throwaway PostgreSQL 18 database, with Node.js 20, the pg driver 8.23 and version 1.32.1 of the official MCP SDK.
Server not showing up in Claude Desktop or Cursor? Five checks that fix it →
Use an existing server or build one?
There are ready-made PostgreSQL MCP servers. Search results include Microsoft's postgres-mcp, Crystal DBA's Postgres MCP Pro and pgEdge's server. If one of them fits, use it, and still follow Step 1: the database role matters whichever server you run.
Build your own when you need control the generic ones do not give you:
- Tools shaped around your business ("revenue by plan") instead of raw SQL.
- Rules about which tables and columns exist as far as the assistant is concerned.
- Your own logging, limits and approval steps around anything sensitive.
The rest of this guide builds the raw-SQL version, which is the foundation for both.
Step 1: a read-only role
Do not connect the assistant as your application user, and never as a superuser. Create a role that can read what it needs and nothing else. The test database has two tables; customers.email stands in for anything private.
-- Sample data, standing in for your real database.
CREATE TABLE customers (
id serial PRIMARY KEY,
name text NOT NULL,
email text NOT NULL,
country text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
total_cents int NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO customers (name, email, country) VALUES
('Ana', 'ana@example.com', 'US'),
('Ben', 'ben@example.com', 'UK'),
('Cleo', 'cleo@example.com', 'DE');
INSERT INTO orders (customer_id, total_cents, status)
SELECT 1 + (g % 3), 1000 + g * 37, CASE WHEN g % 5 = 0 THEN 'refunded' ELSE 'paid' END
FROM generate_series(1, 300) AS g;
-- The role the MCP server logs in as. Read-only, short timeout, and no
-- access to the email column.
CREATE ROLE mcp_reader LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE shop TO mcp_reader;
GRANT USAGE ON SCHEMA public TO mcp_reader;
GRANT SELECT ON orders TO mcp_reader;
GRANT SELECT (id, name, country, created_at) ON customers TO mcp_reader;
ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '5s';- Column-level grants.
GRANT SELECT (id, name, country, created_at) ON customersmeans the email column does not exist as far as this role is concerned. - Read-only by default.
default_transaction_read_only = onmakes every transaction this role opens refuse writes. - A timeout on the role itself.
statement_timeout = '5s'stops a runaway query even if the server code forgets to.
Step 2: the MCP server
mkdir postgres-mcp && cd postgres-mcp
npm init -y
npm pkg set type=module scripts.build=tsc
npm install @modelcontextprotocol/sdk zod pg
npm install -D typescript @types/pg @types/node{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src/server.ts"]
}import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import pg from "pg";
import { z } from "zod";
// Connect as the read-only role, never as an admin user.
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 3,
});
const MAX_ROWS = 200;
const log = (...args: unknown[]) => console.error("[postgres-mcp]", ...args);
const ok = (data: unknown) => ({
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
});
const fail = (message: string) => ({
isError: true,
content: [{ type: "text" as const, text: message }],
});
// Every query runs in a read-only transaction with a timeout, as a second
// layer on top of the role's own settings.
async function readOnly<T>(run: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN READ ONLY");
await client.query("SET LOCAL statement_timeout = '5s'");
const result = await run(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
client.release();
}
}
const server = new McpServer({ name: "postgres", version: "1.0.0" });
server.registerTool(
"list_tables",
{
title: "List tables",
description: "List the tables you can query in the public schema. Call this first.",
inputSchema: {},
annotations: { readOnlyHint: true },
},
async () => {
try {
const { rows } = await readOnly((c) =>
c.query(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`,
),
);
return ok(rows.map((r) => r.table_name));
} catch (err) {
return fail((err as Error).message);
}
},
);
server.registerTool(
"describe_table",
{
title: "Describe table",
description: "Show the columns and types of one table, so you can write a correct query.",
inputSchema: {
table: z.string().regex(/^[a-z_][a-z0-9_]*$/, "Use a plain table name like orders"),
},
annotations: { readOnlyHint: true },
},
async ({ table }) => {
try {
// Only columns this role may read are listed.
const { rows } = await readOnly((c) =>
c.query(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
AND has_column_privilege(format('public.%I', table_name), column_name, 'SELECT')
ORDER BY ordinal_position`,
[table],
),
);
return rows.length ? ok(rows) : fail(`No readable table named ${table}.`);
} catch (err) {
return fail((err as Error).message);
}
},
);
server.registerTool(
"run_select",
{
title: "Run a SELECT query",
description: `Run one read-only SQL query (SELECT or WITH ... SELECT) and get up to ${MAX_ROWS} rows. Use describe_table first. Prefer aggregates over pulling raw rows.`,
inputSchema: {
sql: z.string().min(1).max(5000),
},
annotations: { readOnlyHint: true },
},
async ({ sql }) => {
const text = sql.trim().replace(/;\s*$/, "");
if (!/^(select|with)\b/i.test(text)) {
return fail("Only SELECT queries are allowed.");
}
log("run_select", text.slice(0, 200));
try {
// Wrapping the query as a subquery caps the rows and makes Postgres reject
// anything that is not a single SELECT ("SELECT 1; DROP ..." is a syntax error).
const result = await readOnly((c) =>
c.query(`SELECT * FROM (${text}) AS q LIMIT ${MAX_ROWS + 1}`),
);
const truncated = result.rows.length > MAX_ROWS;
return ok({ rows: result.rows.slice(0, MAX_ROWS), rowCount: Math.min(result.rows.length, MAX_ROWS), truncated });
} catch (err) {
return fail((err as Error).message);
}
},
);
await server.connect(new StdioServerTransport());
log("ready");Build it with npm run build. Clients run the compiled dist/server.js, which also works when the app starts it from a different folder.
What each part does:
list_tablesanddescribe_tablelet the assistant learn the schema before writing SQL.describe_tableonly lists columns the role may read, so the assistant is never told about the email column.run_selectaccepts one SELECT, wraps it as a subquery with aLIMIT, and returns at most 200 rows plus atruncatedflag, so the model knows when it is not seeing everything.readOnly()runs every query insideBEGIN READ ONLYwith a local timeout, a second line of defence if someone later gives the role more rights than it should have.- Errors come back as results with Postgres's own message, so the assistant can fix its query and try again.
Step 3: what the tests showed
The test script starts a temporary Postgres (with the embedded-postgres package, so nothing needs installing), loads setup.sql, starts the MCP server as mcp_reader, and sends both normal and hostile queries through the official MCP client.
Show the test script (src/test.ts)
// Starts a throwaway Postgres, loads setup.sql, starts the MCP server as the
// read-only role, and calls each tool like a client would.
import { readFileSync, rmSync } from "node:fs";
import EmbeddedPostgres from "embedded-postgres";
import pg from "pg";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const dataDir = "D:/work/mine/pg-mcp-test/pgdata";
rmSync(dataDir, { recursive: true, force: true });
const db = new EmbeddedPostgres({ databaseDir: dataDir, user: "postgres", password: "admin-pass", port: 54329, persistent: false });
await db.initialise();
await db.start();
await db.createDatabase("shop");
const admin = new pg.Client({ connectionString: "postgres://postgres:admin-pass@localhost:54329/shop" });
await admin.connect();
await admin.query(readFileSync(new URL("./setup.sql", import.meta.url), "utf8"));
await admin.end();
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(
new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", "src/server.ts"],
env: { ...process.env, DATABASE_URL: "postgres://mcp_reader:change-me@localhost:54329/shop" } as Record<string, string>,
}),
);
async function call(label: string, name: string, args: Record<string, unknown> = {}) {
const res = (await client.callTool({ name, arguments: args })) as { isError?: boolean; content: { text: string }[] };
const raw = res.content[0].text;
let text = raw.replace(/\s+/g, " ").slice(0, 150);
if (name === "describe_table") text = JSON.parse(raw).map((c: { column_name: string }) => c.column_name).join(", ");
if (name === "run_select" && !res.isError) {
const r = JSON.parse(raw);
text = `rowCount ${r.rowCount}, truncated ${r.truncated}: ${JSON.stringify(r.rows.slice(0, 3))}`;
}
console.log(`${res.isError ? "ERROR" : "ok "} ${label.padEnd(30)} ${text}`);
}
await call("list_tables", "list_tables");
await call("describe customers", "describe_table", { table: "customers" });
await call("revenue by country (CTE)", "run_select", {
sql: `WITH paid AS (SELECT * FROM orders WHERE status = 'paid')
SELECT c.country, count(*) AS orders, sum(p.total_cents) / 100 AS revenue
FROM paid p JOIN customers c ON c.id = p.customer_id
GROUP BY c.country ORDER BY revenue DESC;`,
});
await call("all orders (row cap)", "run_select", { sql: "SELECT id, total_cents FROM orders" });
await call("INSERT", "run_select", { sql: "INSERT INTO orders (customer_id, total_cents, status) VALUES (1, 1, 'paid')" });
await call("two statements", "run_select", { sql: "SELECT 1; DELETE FROM orders" });
await call("DELETE hidden in a CTE", "run_select", { sql: "WITH d AS (DELETE FROM orders RETURNING *) SELECT count(*) FROM d" });
await call("read the email column", "run_select", { sql: "SELECT name, email FROM customers" });
await call("write via a function", "run_select", { sql: "SELECT nextval('orders_id_seq')" });
await call("slow query", "run_select", { sql: "SELECT pg_sleep(10)" });
const check = new pg.Client({ connectionString: "postgres://postgres:admin-pass@localhost:54329/shop" });
await check.connect();
console.log("orders still in table:", (await check.query("SELECT count(*) FROM orders")).rows[0].count);
await check.end();
await client.close();
await db.stop();ok list_tables [ "customers", "orders" ]
ok describe customers id, name, country, created_at
ok revenue by country (CTE) rowCount 3, truncated false: [{"country":"DE","orders":"80","revenue":"5277"}, ...]
ok all orders (row cap) rowCount 200, truncated true
ERROR INSERT Only SELECT queries are allowed.
ERROR two statements syntax error at or near ";"
ERROR DELETE hidden in a CTE WITH clause containing a data-modifying statement must be at the top level
ERROR read the email column permission denied for table customers
ERROR write via a function permission denied for sequence orders_id_seq
ERROR slow query canceling statement due to statement timeout
orders still in table: 300| Attempt | Stopped by | Message |
|---|---|---|
| INSERT | Tool rule (SELECT only) | Only SELECT queries are allowed. |
| SELECT 1; DELETE FROM orders | Subquery wrapper | syntax error at or near ";" |
| DELETE hidden in a WITH clause | Subquery wrapper | data-modifying WITH must be at the top level |
| Read the email column | Role (column grant) | permission denied for table customers |
| nextval() on a sequence | Role (no sequence rights) | permission denied for sequence |
| pg_sleep(10) | Statement timeout | canceling statement due to statement timeout |
All 300 orders were still there afterwards. One honest note: in these tests the tool rules and the role caught every write before the read-only transaction had to. That layer is there for the day a colleague grants the role more than it should have.
Step 4: connect Claude, Cursor or Claude Code
The server reads the connection string from DATABASE_URL. Put it in the client config's env block, with the read-only role's credentials:
{
"mcpServers": {
"postgres": {
"command": "node",
"args": ["/absolute/path/to/postgres-mcp/dist/server.js"],
"env": {
"DATABASE_URL": "postgres://mcp_reader:change-me@db.example.com:5432/shop"
}
}
}
}claude mcp add postgres -e DATABASE_URL=postgres://mcp_reader:change-me@db.example.com:5432/shop -- node /absolute/path/to/postgres-mcp/dist/server.jsThen ask something your data can answer, such as "Which country brought in the most paid revenue?", and watch the assistant call list_tables, describe_table and then run_select. If the server does not appear, see MCP server not showing up in Claude Desktop.
Before you point it at production
- 1Use a read replica. Analytical questions can produce heavy queries. Point the server at a replica so the live application never waits on the assistant.
- 2Expose views, not raw tables. A view per question area (for example monthly revenue by plan) is easier for the model to use and hides joins and private fields.
- 3Use row-level security for multi-tenant data. If one database holds many customers, Postgres row-level security policies keep each connection to its own rows.
- 4Log every query. Record the SQL, who asked, the row count and the duration, without logging the results themselves.
- 5Keep credentials out of shared files. The role password lives in the client's environment or a secret store, never in the repository.
Common questions
Is it safe to let Claude query my production PostgreSQL database?+
Can the assistant still change data with a read-only role?+
How do I hide sensitive columns from the assistant?+
Does this work with Supabase, Neon or RDS?+
Want this on your database?
Shaping the tools around your own schema, views and approval steps is what custom MCP server development covers. For the general pattern of wrapping an API, see how to build a Node.js MCP server.
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call