Skip to main content
Glama
amar141989-dev

readonly-postgres-mcp

readonly-postgres-mcp

Let AI read your PostgreSQL database - without letting it write to it.

npm version npm downloads CI license

One MCP server. One job. Read PostgreSQL safely.

This package never writes to the database. There is no write API and no migration runner - not a mode that is switched off, but code that does not exist. Three independent layers enforce it: a SQL guard, a read-only transaction, and a database role granted SELECT and nothing else.

Read SECURITY.md for the threat model and the guard's documented limits before pointing this at production.

Install from npm

npm install readonly-postgres-mcp

Or run the MCP server without a global install:

npx readonly-postgres-mcp

Optional peer for NestJS apps:

npm install @nestjs/common

Related MCP server: mcp-postgres

Environment

A connection URL works, if you already have one:

DATABASE_URL=postgresql://readonly_user:pw@db.example.com:5432/analytics?sslmode=require

PG_URL is also accepted and takes precedence. Any discrete PG_* variable overrides the matching part of the URL.

Or set the parts individually:

PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=postgres
PG_USERNAME=readonly_user
PG_PASSWORD=...
PG_SSL_MODE=require
PG_SEARCH_PATH=public
PG_STATEMENT_TIMEOUT_MS=30000
PG_MAX_ROWS=10000
PG_MCP_ALLOW_ADHOC=true
PG_ALLOW_EXPLAIN_ANALYZE=false

Variable

Default

Purpose

PG_HOST PG_DATABASE PG_USERNAME PG_PASSWORD

-

Required unless a connection URL is set

PG_PORT

5432

PG_SSL_MODE

see TLS

libpq sslmode value

PG_SEARCH_PATH

public

Comma-separated schemas

PG_STATEMENT_TIMEOUT_MS

30000

MCP tools use 15000

PG_MAX_ROWS

10000

MCP tools use 1000

PG_MCP_ALLOW_ADHOC

true

false hides pg_query_sql

PG_ALLOW_EXPLAIN_ANALYZE

false

EXPLAIN ANALYZE executes what it explains

PG_QUERY_REGISTRY

-

Path to your own registry.json; enables pg_query

Optional (backward-compatible fallbacks): PG_SSL (boolean) and PG_SSL_REJECT_UNAUTHORIZED (boolean, overrides certificate verification for whatever mode is resolved).

TLS

PG_SSL_MODE is a discrete connection parameter, set the same way as PG_HOST, PG_DATABASE, PG_USERNAME and PG_PASSWORD - no connection string required. It accepts the same values as a libpq sslmode= parameter:

PG_SSL_MODE

Pool ssl value

disable

false

allow

{ rejectUnauthorized: false }

prefer

{ rejectUnauthorized: false }

require

{ rejectUnauthorized: false }

no-verify

{ rejectUnauthorized: false }

verify-ca

{ rejectUnauthorized: true }

verify-full

{ rejectUnauthorized: true }

Unset: TLS is disabled for localhost / 127.0.0.1 and enabled without certificate verification for any other host.

Use a dedicated database role with SELECT only. See docs/db-role.sql.

Usage

import { PgReadonlyClient, QUERY_IDS } from 'readonly-postgres-mcp';

const pg = await PgReadonlyClient.fromEnv();

const result = await pg.readonly().run(QUERY_IDS.EXAMPLE_PING, {
  params: { message: 'hello' },
});

await pg.readonly().query(
  'SELECT table_name FROM information_schema.tables WHERE table_schema = $1 LIMIT 10',
  { values: ['public'] },
);

await pg.close();

NestJS

import { PgReadonlyModule, NESTJS, PgReadonlyClient } from 'readonly-postgres-mcp/nestjs';

@Module({
  imports: [PgReadonlyModule.forRoot()],
})
export class AppModule {}

@Injectable()
export class ReportService {
  constructor(@Inject(NESTJS.PG_READONLY_CLIENT) private readonly pg: PgReadonlyClient) {}
}

MCP server

Tool

Purpose

Shown when

pg_query_sql

Ad-hoc SELECT / WITH / EXPLAIN

Unless PG_MCP_ALLOW_ADHOC=false

pg_describe

List tables/views, or describe one relation's columns

Always

pg_query

Named catalog query by queryId

When PG_QUERY_REGISTRY is set

All three are annotated readOnlyHint: true, so MCP clients that surface the distinction show them as non-destructive.

pg_describe reads pg_catalog directly - faster than information_schema, and it reports row estimates, comments and partitioned tables correctly. Let the model call it rather than guessing at table names:

{}                        // list every relation in the search path
{ "table": "users" }      // columns, types, nullability, defaults, primary keys
pg-readonly-mcp

Ad-hoc example (pg_query_sql):

{
  "sql": "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1 LIMIT 20"
}

With positional params:

{
  "sql": "SELECT table_name FROM information_schema.tables WHERE table_schema = $1 LIMIT 10",
  "values": ["public"]
}

Set PG_MCP_ALLOW_ADHOC=false to hide/disable pg_query_sql.

Limits applied to every query

Limit

MCP default

Setting

Statement timeout

15s

PG_STATEMENT_TIMEOUT_MS

Rows returned

1,000

PG_MAX_ROWS

The row cap is enforced by PostgreSQL, not after the fact: statements are wrapped as SELECT * FROM (<your query>) LIMIT <cap>+1, so a SELECT * against a large table cannot exhaust the server's memory. Because the cap is pushed down, rowCount reports rows returned, not rows matched, and truncated tells you whether more exist.

Supported SQL

SELECT, WITH (non-data-modifying CTEs) and EXPLAIN. One statement per call - no trailing second statement, and no semicolon needed.

EXPLAIN ANALYZE is rejected by default because it executes the statement it explains. Set PG_ALLOW_EXPLAIN_ANALYZE=true if you need it. Plain EXPLAIN always works.

Everything else is rejected before it reaches the database: INSERT, UPDATE, DELETE, MERGE, COPY, CREATE, DROP, ALTER, TRUNCATE, GRANT, REVOKE, VACUUM, REINDEX, CLUSTER, CALL, DO, SELECT INTO, data-modifying CTEs, and multiple statements in one call.

Cursor MCP config:

{
  "mcpServers": {
    "readonly-postgres-mcp": {
      "command": "npx",
      "args": ["-y", "readonly-postgres-mcp"],
      "env": {
        "PG_HOST": "localhost",
        "PG_DATABASE": "postgres",
        "PG_USERNAME": "readonly_user",
        "PG_PASSWORD": "...",
        "PG_SSL_MODE": "require"
      }
    }
  }
}

Named query catalog (optional)

Instead of ad-hoc SQL, you can expose a fixed set of pre-approved queries. Point PG_QUERY_REGISTRY at your own registry file; query paths resolve relative to it, so a catalog is a self-contained folder:

my-catalog/
  registry.json
  queries/
    reports/active-users.sql
  1. Write the .sql file using :namedParams

  2. Register it in registry.json with its param types

  3. Set PG_QUERY_REGISTRY=/path/to/my-catalog/registry.json

  4. Validate with npx pg-validate-catalog

Every catalog query is checked by the same SQL guard at startup, so a write statement in a catalog file stops the server rather than running.

Scripts

npm run validate:catalog
npm test
npm run build
npm run pack:check

Publishing to npm

npm login
npm run pack:check
npm publish --access public

Defense in depth

Layer

Mechanism

SDK

SqlGuard allowlist + DML scan + param limits + no write API

Connection

default_transaction_read_only=on

Database

Readonly role with SELECT only

The database role is the security boundary; the other two layers are defense in depth. The guard does not understand function calls, and a few functions (dblink, nextval) escape a read-only transaction - see SECURITY.md. Set the role up with docs/db-role.sql, which includes a checklist for verifying that writes actually fail.

Questions, ideas or feedback?

Email: amar141989@gmail.com

Or open a GitHub issue.

I read every email.

Available Tools

2 tools
pg_describeA
Read-onlyIdempotent

Inspect the database schema.

Call with no arguments to list the tables, views and materialized views in the configured search path, with their estimated row counts and comments. Call with "table" to list that relation's columns, types, nullability, defaults, primary keys and comments.

Prefer this over querying information_schema by hand: it is faster and reports partitioned tables and comments correctly.

ParametersJSON Schema
NameRequiredDescriptionDefault
tableNoTable or view name. Omit to list all relations in the search path.
schemaNoRestrict to a single schema. Defaults to the configured search path.

TDQS

A4/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations declare readOnlyHint=true, idempotentHint=true, destructiveHint=false, and openWorldHint=false, covering the safety and idempotency profile. The description adds that it is faster than querying information_schema and correctly reports partitioned tables and comments, which is useful behavioral context. But it does not describe output format, pagination, or performance limits beyond the speed claim.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Four sentences, each earning its place. The purpose is front-loaded, followed by mode-specific instructions and a comparative advantage. No filler or repetition.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Complete enough for an agent to call the tool correctly: it knows the two modes, what each returns, and why to prefer it. The absence of an output schema is offset by the description's enumeration of return fields (tables, views, row counts, comments; columns, types, nullability, defaults, primary keys). Missing only explicit guidance on when to use pg_query_sql instead.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so both parameters are fully documented in the schema. The description hints at the 'table' parameter's dual role (listing mode when omitted, detail mode when provided) but adds little beyond the schema's own descriptions. Baseline 3 is appropriate.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource: 'Inspect the database schema.' The description then distinguishes the two modes of operation (no-args relation listing vs. table-specific column inspection). An agent knows exactly what this tool does and how it differs from pg_query_sql.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit usage modes: call with no arguments for relation listing, call with 'table' for column details. It also advises preferring this over querying information_schema by hand. However, it does not explicitly state when to use pg_query_sql instead, which is the sibling tool.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

pg_query_sqlA
Read-onlyIdempotent

Run a single read-only SQL query against a PostgreSQL database.

Only SELECT, WITH and EXPLAIN are permitted. Writes and DDL (INSERT, UPDATE, DELETE, MERGE, COPY, CREATE, DROP, ALTER, TRUNCATE) are rejected before reaching the database, as are data-modifying CTEs.

Exactly one statement per call — do not send two statements separated by a semicolon. A single trailing semicolon is fine.

Results are capped at 1000 rows and 15s. The response reports "truncated": true when more rows matched than were returned, and "rowCount" is the number of rows returned, not the number matched — use a COUNT(*) query if you need the true total.

Pass literals through "values" as $1, $2 placeholders rather than building them into the SQL string.

bigint and numeric columns are returned as JSON strings, not numbers.

Use pg_describe to discover tables and columns instead of guessing at names.

EXPLAIN ANALYZE is rejected by default because it executes the statement it explains; plain EXPLAIN always works.

ParametersJSON Schema
NameRequiredDescriptionDefault
sqlYesA single SELECT, WITH or EXPLAIN statement. Use $1, $2 for values.
valuesNoPositional bind parameters for the $1, $2 placeholders in sql.

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Goes well beyond the readOnly/destructive annotations by disclosing the 1000-row and 15s caps, the 'truncated' flag semantics, that rowCount is returned not matched, and that bigint/numeric come back as JSON strings. The EXPLAIN ANALYZE rejection and pre-database write rejection are behavioral details annotations cannot express.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loads purpose and the allowlist before edge cases (trailing semicolon, truncation, type rendering), with zero filler sentences. Length is justified by the number of non-obvious behaviors it must convey.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

No output schema exists, yet the description covers return semantics (truncated, rowCount, string-typed numerics) and error behavior (rejected statements). An agent has everything needed to invoke this correctly and interpret results.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so the baseline is 3, but the description adds real value: it tells the agent to pass literals via 'values' as $1/$2 placeholders rather than string-building. That is usage guidance the schema alone only hints at.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Run a single read-only SQL query against a PostgreSQL database') and immediately constrains it to read-only. It also names the sibling pg_describe and its distinct role, letting an agent separate the two without opening schemas.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly enumerates what is permitted (SELECT, WITH, EXPLAIN) and what is rejected (writes, DDL, data-modifying CTEs, EXPLAIN ANALYZE). It also states the single-statement rule and routes schema discovery to pg_describe and true totals to COUNT(*).

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 2 tool updatesv0.3.0
    • First observedpg_describe
    • First observedpg_query_sql

TDQS

A4.3/5.0

Scored across 2 tools

Disambiguation5/5

The two tools have completely distinct purposes: pg_query_sql executes read-only queries while pg_describe inspects schema metadata. There is no realistic scenario where an agent would confuse the two, and the descriptions even cross-reference each other (use pg_describe before pg_query_sql).

Naming Consistency4/5

Both names use a consistent 'pg_' prefix and snake_case, which reads well as a set. The second segments differ structurally (query_sql is verb+format while describe is a bare verb), a minor deviation from a strict verb_noun pattern.

Tool Count4/5

Two tools is minimal but justified for a deliberately read-only server whose entire surface is 'run a query' and 'inspect the schema'. It does not feel arbitrarily thin given the narrowly scoped purpose, though there is little room to grow.

Completeness4/5

Query execution plus schema discovery covers the core read-only lifecycle (explore schema, then query it). Minor gaps remain, such as no dedicated pagination/offset helper or multi-statement/session tooling, but row caps and the 'truncated' flag give agents workable paths.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    A lightweight Postgres MCP server for safe database exploration and query analysis, read-only by default, with multi-database support.
    4
    12 PyPI
    4
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Read-only PostgreSQL MCP server that enables running SELECT queries, listing tables and schemas, and describing columns, with built-in protection against writes and malicious SQL attacks.
    529 npm
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Read-only MCP server for PostgreSQL, enabling schema discovery, table metadata, and safe SELECT queries via READ ONLY transactions.
    9 npm
    MIT