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 →

Each layer catches different mistakes. The role is the one that cannot be talked around, so it does not depend on the code above it being perfect.

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.

setup.sql
-- 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 customers means the email column does not exist as far as this role is concerned.
  • Read-only by default. default_transaction_read_only = on makes 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

Terminal
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
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/server.ts"]
}
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_tables and describe_table let the assistant learn the schema before writing SQL. describe_table only lists columns the role may read, so the assistant is never told about the email column.
  • run_select accepts one SELECT, wraps it as a subquery with a LIMIT, and returns at most 200 rows plus a truncated flag, so the model knows when it is not seeing everything.
  • readOnly() runs every query inside BEGIN READ ONLY with 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)
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();
Output (shortened)
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
AttemptStopped byMessage
INSERTTool rule (SELECT only)Only SELECT queries are allowed.
SELECT 1; DELETE FROM ordersSubquery wrappersyntax error at or near ";"
DELETE hidden in a WITH clauseSubquery wrapperdata-modifying WITH must be at the top level
Read the email columnRole (column grant)permission denied for table customers
nextval() on a sequenceRole (no sequence rights)permission denied for sequence
pg_sleep(10)Statement timeoutcanceling 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:

claude_desktop_config.json (Cursor: .cursor/mcp.json)
{
  "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 Code
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.js

Then 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

  1. 1
    Use a read replica. Analytical questions can produce heavy queries. Point the server at a replica so the live application never waits on the assistant.
  2. 2
    Expose 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.
  3. 3
    Use 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.
  4. 4
    Log every query. Record the SQL, who asked, the row count and the duration, without logging the results themselves.
  5. 5
    Keep 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?+
It can be, if it connects as a read-only role with only the grants it needs, runs queries with a timeout and row cap, and ideally points at a read replica. Never connect it as your application user or a superuser.
Can the assistant still change data with a read-only role?+
Not through normal SQL: writes fail with permission errors, and a read-only transaction blocks them a second time. In the tests above, INSERT, a hidden DELETE and nextval() were all refused.
How do I hide sensitive columns from the assistant?+
Grant SELECT on specific columns instead of the whole table, or expose a view without them. In the test, the email column was invisible to describe_table and reading it returned permission denied.
Does this work with Supabase, Neon or RDS?+
The approach is plain PostgreSQL, so it applies to hosted Postgres too: create the role, grant what it needs, and give the server that role's connection string. Check your provider's docs for how roles and connection pooling are set up.

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

← All guides