Skip to content
AI Agent How-To

Home › MCP server setup guides

MCP Server for Postgres: Safe Read-Only Setup Guide

Intermediate ~6 min read Last tested October 2026

Updated 2026-10-15

Letting an AI query your database is powerful and risky. This guide sets up a Postgres MCP server the safe way: a read-only database role, a read-only transaction, a statement timeout and a row limit.

A note on the old reference server: @modelcontextprotocol/server-postgres was moved to the archived servers repository in 2025 and is no longer maintained, so we build a small server you control (about 40 lines). Maintained community options exist too (for example Postgres MCP Pro); evaluate them with the same safety checklist below.

Prerequisites

  • PostgreSQL you can create roles on
  • Python 3.10+ and uv (or pip)
  • An MCP client (Claude Desktop, Cursor, VS Code)

Steps

1. Create a read-only role

CREATE ROLE ai_reader LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE appdb TO ai_reader;
GRANT USAGE ON SCHEMA public TO ai_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ai_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ai_reader;
ALTER ROLE ai_reader SET statement_timeout = '5s';

Leave sensitive tables (users’ passwords, payment data) out, or expose them through views without those columns.

2. Install dependencies

uv init pg-mcp && cd pg-mcp
uv add "mcp[cli]" "psycopg[binary]"

3. Write the server

# server.py
import os, json
import psycopg
from mcp.server.fastmcp import FastMCP

DSN = os.environ["DATABASE_URL"]          # postgresql://ai_reader:...@host:5432/appdb
MAX_ROWS = 200
mcp = FastMCP("postgres-readonly")

def run(sql: str, params=None):
    with psycopg.connect(DSN, autocommit=False) as conn:
        conn.read_only = True                 # BEGIN READ ONLY: writes fail even if granted
        with conn.cursor() as cur:
            cur.execute(sql, params)
            cols = [d.name for d in cur.description] if cur.description else []
            rows = cur.fetchmany(MAX_ROWS)
        conn.rollback()
    return [dict(zip(cols, r)) for r in rows]

@mcp.tool()
def list_tables() -> str:
    """List tables in the public schema."""
    rows = run("SELECT table_name FROM information_schema.tables WHERE table_schema='public' ORDER BY 1")
    return "\n".join(r["table_name"] for r in rows)

@mcp.tool()
def describe_table(table: str) -> str:
    """Show column names and types for a table."""
    rows = run("""SELECT column_name, data_type, is_nullable FROM information_schema.columns
                  WHERE table_schema='public' AND table_name=%s ORDER BY ordinal_position""", (table,))
    return json.dumps(rows, indent=1)

@mcp.tool()
def query(sql: str) -> str:
    """Run a read-only SQL SELECT. Results are capped at 200 rows."""
    return json.dumps(run(sql), default=str, indent=1)

if __name__ == "__main__":
    mcp.run()

4. Register it in your client

{
  "mcpServers": {
    "postgres": {
      "command": "uv",
      "args": ["--directory", "/ABSOLUTE/PATH/pg-mcp", "run", "server.py"],
      "env": { "DATABASE_URL": "postgresql://ai_reader:change-me@localhost:5432/appdb" }
    }
  }
}

Use this in claude_desktop_config.json or .cursor/mcp.json, then restart the client.

5. Try it

Ask: “Which 5 customers placed the most orders last month?” The model calls list_tables, describe_table, then query.

Safety checklist

  • Read-only role and read-only transaction (defense in depth)
  • statement_timeout so a bad join can’t hammer production
  • Point it at a replica if you have one
  • Never put a superuser connection string in an MCP config

Common errors

permission denied for table x: working as intended for ungranted tables. Grant SELECT if the agent should see it.

cannot execute INSERT in a read-only transaction: also working as intended.

connection refused from Claude Desktop but works in terminal: the env block is missing. Desktop apps don’t inherit your shell variables.

canceling statement due to statement timeout: the query ran longer than 5 s. Add indexes or ask the model for a narrower query.