All articles

MCP + PostgreSQL: Query Your Database with Natural Language (2026)

SWATI BARWAL

SWATI BARWAL

·16 min read

mcp

An MCP PostgreSQL natural language workflow lets you ask questions such as “Which plans grew fastest last month?” while Cursor handles schema inspection, writes SQL, and calls a controlled database tool. The important word is controlled. This tutorial builds a minimal read-only server with the supported v1 @modelcontextprotocol/sdk, pg, and zod. It combines a restricted PostgreSQL login, read-only transactions, a five-second timeout, and a 100-row response ceiling so an AI agent never receives normal application credentials.

The result is intentionally small: one local server, one tool named query_postgres, and one purpose-built database role. It is suitable for development or access to a deliberately scoped reporting database. It is not permission to point an agent at production with an owner account.

Author: Aneh Thakur · Last updated: 2026-07-21

What you'll learn

  • How a local PostgreSQL MCP server fits between Cursor and your database

  • Why the archived Postgres reference server is no longer a production recommendation

  • How to create a PostgreSQL role with only the access this workflow needs

  • How to build a complete TypeScript server with McpServer, registerTool, and stdio

  • How to enforce read-only transactions, statement timeouts, and a 100-row response limit

  • How to connect the compiled server to Cursor without committing a database password

  • Which natural-language prompts produce useful, bounded analytical queries

  • How to troubleshoot connections, permissions, timeouts, and missing tools

Prerequisites

  • Node.js 18 or newer and npm

  • PostgreSQL 14 or newer that you can administer

  • A non-production database, read replica, or reporting database for initial testing

  • Cursor with MCP support

  • An absolute local path where Cursor can run the compiled server

  • Basic familiarity with SQL and TypeScript

You should also know which schemas and tables the agent is allowed to inspect. If the answer is “everything because it is easier,” stop and define a narrower reporting surface first. Views are useful when raw tables contain private fields.

Architecture: where natural language becomes SQL

The flow has four parts:

1. You ask Cursor a question in natural language. 2. Cursor inspects the query_postgres tool description and creates a SELECT or WITH query. 3. The local TypeScript process validates the tool input, starts a read-only transaction, and sends the query through pg. 4. PostgreSQL evaluates the query as the dedicated mcp_reader login and returns at most 100 rows to Cursor.

The MCP server does not translate language with its own model. Cursor’s model writes SQL, while the server provides a narrow execution interface. That separation is useful: the server remains deterministic and auditable, and database authorization stays inside PostgreSQL.

There are several layers, but they are not equally strong. A string check rejects obvious INSERT, UPDATE, DELETE, and multi-statement input. It is only defense-in-depth. SQL has comments, writable common table expressions, functions, and dialect details that make regex an unreliable parser and an unsafe security boundary. The real boundary is the database login: no write grants, default_transaction_read_only=on, and every tool call wrapped in BEGIN READ ONLY.

The timeout limits expensive mistakes, not data exposure. The row cap limits the response size, not how many rows PostgreSQL may scan before producing an aggregate. Use a reporting replica, indexes, and database monitoring when query load matters.

Step 1 — Create a least-privilege PostgreSQL login

Connect as a database administrator to the target database. Replace appdb, the password, and public with your actual database and reporting schema.

SQL
CREATE ROLE mcp_reader
  LOGIN
  PASSWORD 'replace-with-a-long-random-secret'
  NOSUPERUSER
  NOCREATEDB
  NOCREATEROLE
  NOREPLICATION
  NOBYPASSRLS;

REVOKE ALL ON DATABASE appdb FROM mcp_reader;
GRANT CONNECT ON DATABASE appdb TO mcp_reader;

REVOKE ALL ON SCHEMA public FROM mcp_reader;
GRANT USAGE ON SCHEMA public TO mcp_reader;

GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO mcp_reader;

ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '5s';

ALTER DEFAULT PRIVILEGES affects tables created later by the role that runs that command. If migrations run as a different owner, execute it as that owner or add the grant to your migration process. Otherwise, existing tables work but newly created tables may return permission denied.

PostgreSQL privileges inherited from PUBLIC or another membership still apply. The commands above remove direct privileges from mcp_reader; they do not erase broad grants made elsewhere. Inspect the effective role and database privileges before relying on the account:

SQL
SELECT
  current_user,
  current_setting('default_transaction_read_only') AS read_only,
  current_setting('statement_timeout') AS timeout;

SELECT
  has_database_privilege(current_user, current_database(), 'CONNECT') AS can_connect,
  has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_schema,
  has_schema_privilege(current_user, 'public', 'CREATE') AS can_create;

Run those checks while logged in as mcp_reader. can_create should be false. Also review role memberships, row-level security policies, views, and executable functions. A SECURITY DEFINER function can expose more than table grants suggest. For sensitive systems, expose reviewed reporting views in a dedicated schema instead of granting SELECT on every application table.

Create a connection URL without printing it into shell history if your environment offers a secrets manager:

Code
postgresql://mcp_reader:[email protected]:5432/appdb

URL-encode reserved characters in the password. Require TLS for a remote database according to your provider’s connection instructions.

Step 2 — Create the TypeScript project

Create a small standalone project:

Bash
mkdir postgres-readonly-mcp
cd postgres-readonly-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk@^1 pg zod
npm install -D typescript tsx @types/node @types/pg
mkdir src

The explicit @^1 keeps this tutorial on the production-supported SDK line. As of 2026-07-21, TypeScript SDK v1 remains supported for production. Version 2 is beta and targets the 2026-07-28 draft specification, so do not migrate a production integration merely to follow a beta.

Add these scripts to package.json:

JSON
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Keep the dependencies npm generated. For a shared or deployed installation, commit package-lock.json and use npm ci so the exact SDK and driver versions are reproducible.

Create tsconfig.json:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*.ts"]
}

Step 3 — Build the read-only PostgreSQL MCP server

Create src/index.ts with the complete server:

TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Pool } from "pg";
import { z } from "zod";

const databaseUrl = process.env.DATABASE_URL;

if (!databaseUrl) {
  throw new Error("DATABASE_URL is required");
}

const MAX_ROWS = 100;
const QUERY_TIMEOUT_MS = 5_000;

const pool = new Pool({
  connectionString: databaseUrl,
  max: 3,
  application_name: "postgres-readonly-mcp",
});

const server = new McpServer({
  name: "postgres-readonly-mcp",
  version: "1.0.0",
});

function rejectObviousUnsafeSql(sql: string): string | null {
  const trimmed = sql.trim();
  const withoutTrailingSemicolon = trimmed.replace(/;\s*$/, "");

  if (!/^(SELECT|WITH)\b/i.test(withoutTrailingSemicolon)) {
    return "Only SELECT or WITH queries are accepted.";
  }

  if (withoutTrailingSemicolon.includes(";")) {
    return "Multiple SQL statements are not accepted.";
  }

  return null;
}

server.registerTool(
  "query_postgres",
  {
    description:
      "Run one read-only SELECT or WITH query against PostgreSQL. Return no more than 100 rows. Prefer explicit columns, bounded date ranges, and aggregates.",
    inputSchema: {
      sql: z
        .string()
        .min(1)
        .max(20_000)
        .describe("One PostgreSQL SELECT or WITH query"),
    },
  },
  async ({ sql }) => {
    const rejection = rejectObviousUnsafeSql(sql);

    if (rejection) {
      return {
        isError: true,
        content: [{ type: "text", text: rejection }],
      };
    }

    const client = await pool.connect();
    let transactionOpen = false;

    try {
      await client.query("BEGIN READ ONLY");
      transactionOpen = true;
      await client.query(`SET LOCAL statement_timeout = '${QUERY_TIMEOUT_MS}ms'`);

      const result = await client.query(sql.trim());
      const rows = result.rows.slice(0, MAX_ROWS);

      await client.query("ROLLBACK");
      transactionOpen = false;

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                rows,
                returnedRows: rows.length,
                totalRowsReceived: result.rows.length,
                truncated: result.rows.length > MAX_ROWS,
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (error) {
      if (transactionOpen) {
        try {
          await client.query("ROLLBACK");
        } catch (rollbackError) {
          console.error("Rollback failed:", rollbackError);
        }
      }

      const message =
        error instanceof Error ? error.message : "Unknown PostgreSQL error";

      return {
        isError: true,
        content: [{ type: "text", text: `Query failed: ${message}` }],
      };
    } finally {
      client.release();
    }
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch(async (error) => {
  console.error("PostgreSQL MCP server failed:", error);
  await pool.end();
  process.exit(1);
});

This implementation always releases the pooled connection. It rolls back after success because no commit is needed, and it attempts rollback after failure so a poisoned transaction never returns to the pool. Errors go to stderr; writing debug output with console.log would corrupt the stdio JSON-RPC channel.

The fixed SET LOCAL statement is server-owned configuration, not user input. The tool sends the model-produced SQL as the query itself; do not build examples that splice user values into SQL strings. In an application tool with separate fields such as customer_id, use placeholders like $1 and pass a values array. This particular tool deliberately accepts a complete analytical query, so its safety comes from the restricted role and read-only transaction.

The code caps returned JSON at 100 rows. The pg driver may still receive more rows before slicing, which is another reason prompts should include LIMIT and queries should use aggregates. If you need a hard streaming limit for arbitrary large result sets, add a cursor-based design and test its cancellation behavior; that complexity is unnecessary for the bounded reporting workflow here.

Step 4 — Build and smoke-test

Set DATABASE_URL in the process environment, then compile:

Bash
export DATABASE_URL="postgresql://mcp_reader:[email protected]:5432/appdb"
npm run build
npm start

An idle process with no normal output is expected because it is waiting for MCP messages over stdin. Stop it with Ctrl+C.

Before involving Cursor, verify the account independently with psql:

Bash
psql "$DATABASE_URL" -c "SHOW default_transaction_read_only"
psql "$DATABASE_URL" -c "SHOW statement_timeout"
psql "$DATABASE_URL" -c "SELECT current_user, current_database()"

The first command should report on, the timeout should report 5s, and the user should be mcp_reader. Then prove writes fail using a disposable table name:

Bash
psql "$DATABASE_URL" -c "CREATE TABLE mcp_write_test(id integer)"

The expected result is an error caused by read-only mode or missing create privilege. Do not weaken permissions to make this test pass.

Step 5 — Connect the server to Cursor

Build first, then add a stdio entry to Cursor’s MCP configuration. Use an absolute path to dist/index.js:

JSON
{
  "mcpServers": {
    "postgres-readonly": {
      "command": "node",
      "args": [
        "/ABSOLUTE/PATH/postgres-readonly-mcp/dist/index.js"
      ],
      "env": {
        "DATABASE_URL": "${env:DATABASE_URL}"
      }
    }
  }
}

Prefer user-level MCP configuration or secure environment injection for credentials. A project .cursor/mcp.json is easy to commit, copy into logs, or expose to teammates. Putting the literal connection URL there may leak the password even when the database role is read-only. Environment substitution keeps the example shareable, but confirm that your Cursor version and launch method actually pass the variable.

On macOS, an app opened from the Dock may not inherit values exported by an interactive shell. If substitution is empty, launch Cursor from a shell that has DATABASE_URL, use an approved secrets wrapper, or place the secret in an untracked user-level config with strict file permissions. Never commit it to the repository.

Reload Cursor, open Settings → MCP, and confirm postgres-readonly connects and exposes query_postgres. If the server is red, open MCP logs before changing code.

Step 6 — Ask bounded natural-language questions

Start with schema discovery. information_schema lets the model understand table and column names without guessing:

Use query_postgres to list tables in non-system schemas. Return schema name and table name, ordered alphabetically, with a limit of 100.

Then inspect only the table you need:

Using information_schema.columns, show column names and data types for public.orders. Do not query table data yet.

For analytics, state the time window, grouping, output columns, and limit:

Query completed orders from the last 30 days. Return order date, order count, and total revenue by day. Use the actual schema you discovered, exclude cancelled rows, order newest first, and limit the result to 30 rows.

Compare the five highest-revenue product categories for the previous complete calendar month. Return category, order count, revenue, and average order value. Use an aggregate query and do not return customer-level records.

Find weekly signup counts for the last 12 complete weeks. Return week start and count only. Confirm the timestamp column from the schema before querying.

For potentially sensitive data, ask for aggregates instead of raw rows:

Calculate counts of active customers by country. Do not select names, emails, phone numbers, addresses, IDs, or free-text fields. Return only countries with at least 20 customers and limit to 50 rows.

These constraints improve both safety and answer quality. The model can still write an inefficient query, so review generated SQL in the tool approval UI when working with a large database.

Why not use `@modelcontextprotocol/server-postgres`?

The old @modelcontextprotocol/server-postgres reference package and its repository are archived and unmaintained. It was useful as an early MCP example, but it should not be a production dependency in 2026. Archived code does not receive normal security fixes, dependency maintenance, or compatibility work.

For a throwaway local demonstration against disposable data, you may still encounter instructions that run the legacy package. Treat those instructions as historical material, pin nothing important to them, and do not give that process production credentials. The safer practical path is the small maintained-code surface in this tutorial: current v1 SDK, pg, zod, and database permissions you control.

Building the server does mean your team owns dependency updates and testing. That is preferable to silently relying on an archived reference implementation. Check the official TypeScript SDK repository before upgrades, keep the v1 lockfile stable, and evaluate v2 only after its target specification and APIs are appropriate for your deployment.

Security checklist

  • Use a dedicated login; never reuse an application owner, migration user, or personal admin account.

  • Grant only CONNECT, schema USAGE, and SELECT on reviewed tables or views.

  • Set default_transaction_read_only=on on the role and use BEGIN READ ONLY in every tool call.

  • Keep a short role-level timeout and a shorter or equal SET LOCAL timeout in the server.

  • Review privileges inherited through PUBLIC and role memberships.

  • Verify row-level security behavior using the actual mcp_reader login.

  • Prefer a reporting replica or dedicated reporting schema for production-scale data.

  • Exclude secrets, tokens, password hashes, private notes, and unnecessary personal data with views.

  • Audit SECURITY DEFINER functions and extensions available to the role.

  • Keep the connection URL out of git, screenshots, chat transcripts, and project-level config.

  • Require TLS and provider-approved certificate settings for remote connections.

  • Log tool use and database queries through an approved audit path without logging credentials.

  • Treat the SQL prefix check as convenience only; never replace database permissions with regex.

  • Review expensive-query metrics and revoke access immediately if the tool is abused.

Troubleshooting

Cursor shows the server as disconnected

Run npm run build, then execute the exact node /absolute/path/dist/index.js command from a terminal. Common causes are a relative path, missing DATABASE_URL, Node not present in Cursor’s GUI PATH, or an SDK/runtime mismatch. Use the absolute path returned by your environment rather than assuming /usr/local/bin/node.

`DATABASE_URL is required`

Cursor did not pass the variable. Confirm the variable exists in the environment that launches Cursor, not only in another terminal tab. Test temporarily with a user-level config, then move the secret to your team’s approved injection method. Do not solve this by committing the URL to .cursor/mcp.json.

`permission denied for table`

The role lacks SELECT, the table is in another schema, or a new table was created by a migration owner whose default privileges were not updated. Grant access only after confirming the table belongs in the reporting surface. Avoid blanket grants across unrelated schemas.

Queries fail with `cannot execute ... in a read-only transaction`

The generated SQL attempted a write, lock, sequence change, or write-capable function. That failure is the safety system working. Rephrase the prompt to request a plain analytical SELECT; do not disable read-only mode.

Queries are cancelled after five seconds

PostgreSQL enforced statement_timeout. Narrow the date range, select fewer columns, aggregate earlier, or add an appropriate index through the normal reviewed migration process. Increasing the timeout should be a database-owner decision, not an agent workaround.

The response says `truncated: true`

The query returned more than 100 rows, and the server included only the first 100. Add a deterministic ORDER BY, a smaller LIMIT, or an aggregate. Never infer totals from a truncated raw result.

Tool calls work but return unexpected or sensitive columns

SELECT is read-only, not privacy-safe. Revoke table access and expose a reviewed view containing only approved columns. Prompts are not access control, and hiding a column from schema discovery does not stop a query if the role can still select it.

FAQ

  • Can MCP query PostgreSQL from natural language?

  • Is a SELECT-only regex enough to secure an MCP database tool?

  • Does this server guarantee that PostgreSQL reads only 100 rows?

  • Should I connect Cursor directly to my production database?

  • Is `@modelcontextprotocol/server-postgres` still recommended?

  • Which MCP TypeScript SDK version should I use in July 2026?

  • Why use `ROLLBACK` after a successful SELECT?

  • Can the tool query multiple schemas?

  • How do I pass filter values safely in a custom database tool?

Related posts

SWATI BARWAL

Written by

SWATI BARWAL

1 follower

Related posts