safe-postgres-mcp
v0.1.0
Published
Safety-first, read-only PostgreSQL MCP server. Read-only isn't a regex — it's enforced by Postgres itself (READ ONLY transactions, statement timeouts, row caps). One npx command to give AI agents safe access to your database.
Downloads
99
Maintainers
Readme
safe-postgres-mcp
The safe default for giving an AI agent your Postgres. A zero-config, read-only PostgreSQL MCP server where read-only isn't a regex you hope holds — it's enforced by Postgres itself. Every agent-supplied query runs inside a BEGIN TRANSACTION READ ONLY with a statement timeout and a row cap, and is sent over the extended wire protocol, which cannot carry a second command. One npx command: your agent can explore a schema and run SELECTs, but it physically cannot write, cannot stack a second statement, and a runaway query is cancelled within the statement timeout (default 5s) so it can't hang your database.
Why another Postgres MCP server?
Some "read-only" database tools enforce read-only by scanning the SQL text for scary keywords — a filter, and filters get bypassed. Others use the right primitive but stop too early: the original TypeScript reference Postgres MCP server (@modelcontextprotocol/server-postgres) wrapped each query in a BEGIN TRANSACTION READ ONLY and nothing more — no statement timeout, no row cap, no rejection of multi-statement input, and no bound on how much a single query could buffer into the agent’s context. It has since been archived in the official modelcontextprotocol/servers-archived repo with no maintained successor. Meanwhile popular DBA-oriented alternatives (e.g. Postgres MCP "Pro" / crystaldba) default to unrestricted read/write — read-only is an opt-in access-mode flag — and ship as a Python/Docker install that is friction for the Node/TS majority of MCP users. (Check each project's current docs; the ecosystem moves fast.)
This server inverts that. The guarantee doesn't live in a string matcher — it lives in the database engine.
Defense in depth, from the outside in:
| Layer | What it does | Is it the guarantee? |
|---|---|---|
| Keyword pre-check | Rejects obvious writes (DELETE FROM …) and stacked statements before a round-trip, with a clear error | No — it's fast-fail UX + a second line |
| SET LOCAL statement_timeout | Postgres cancels a runaway query instead of hanging your DB | Resource guardrail |
| Extended wire protocol | Agent SQL is sent as a prepared statement, which by construction carries exactly one command — a smuggled ; COMMIT is refused by the server, not by a parser of ours | Yes — for statement stacking |
| BEGIN TRANSACTION READ ONLY | The engine rejects any write (INSERT/UPDATE/DELETE/DDL/…) at execution time | Yes — this is the real guarantee |
| Row cap + ROLLBACK | Truncates oversized result sets; never commits anything | Resource guardrail |
The keyword check is deliberately a courtesy, not the wall. Casing tricks, comment smuggling, or a data-modifying CTE that slips past the text analysis still hit a Postgres READ ONLY transaction and fail with error 25006. Belt and suspenders — and the suspenders are bolted to the engine.
For your outermost layer, point DATABASE_URL at a dedicated least-privilege read-only role (see .env.example). This server is the second wall behind that role, not a replacement for it.
60-second quickstart
You need Node >= 20 and a Postgres connection string. Two of the most common clients:
Claude Code
claude mcp add --env DATABASE_URL=postgres://user:pass@host:5432/dbname \
--transport stdio safe-postgres -- npx -y safe-postgres-mcpThe order matters: keep another flag (
--transport stdio) between--env KEY=valueand the server name, otherwise the CLI parses the name as anotherKEY=valuepair. Everything after--is handed to the server untouched.
Verify it's connected:
claude mcp list
claude mcp get safe-postgresAdd --scope project to share it with your team via a checked-in .mcp.json, or --scope user to enable it across all your projects.
Claude Desktop
Open Settings → Developer → Edit Config (or edit the file directly):
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"safe-postgres": {
"command": "npx",
"args": ["-y", "safe-postgres-mcp"],
"env": {
"DATABASE_URL": "postgres://user:pass@host:5432/dbname"
}
}
}
}Fully quit and reopen Claude Desktop to load it. If it doesn't appear, check ~/Library/Logs/Claude/mcp-server-safe-postgres.log (the server logs all diagnostics to stderr; stdout is reserved for the JSON-RPC channel).
Running from a local build
No npm publish required — build once and point any client at the absolute path:
git clone https://github.com/samuel-cabral/safe-postgres-mcp.git
cd safe-postgres-mcp && npm ci && npm run buildclaude mcp add --env DATABASE_URL=postgres://user:pass@host:5432/dbname \
--transport stdio safe-postgres -- node /abs/path/to/safe-postgres-mcp/build/index.jsThe server fails fast: a missing/invalid DATABASE_URL or an unreachable database exits with an actionable message before the agent ever calls a tool.
Tools
Five small, curated tools — a focused read-only toolset, deliberately not a 14-tool management suite. Every agent-supplied query runs inside a READ ONLY transaction, so the engine refuses a write regardless of what the SQL says; the three introspection tools issue only fixed, parameterized system-catalog SELECTs.
| Tool | Description | Parameters | Access |
|---|---|---|---|
| query | Run a single read-only SQL statement inside a READ ONLY transaction. A LIMIT is injected if you omit one; returns rows, field types, and a truncated flag. | sql (string, required) | Executes SQL (read-only tx) |
| explain_query | Return the query plan via EXPLAIN (FORMAT JSON) without running the query. ANALYZE/ANALYSE is rejected (it would execute the target), and the statement is sent as a prepared statement inside a READ ONLY transaction. Inspect cost/joins/index usage before paying for the query. | sql (string, required) | Plans the query; never executes it |
| list_schemas | List all non-system schemas with their owner. | — | Catalog read |
| list_tables | List tables, views, and materialized views in a schema with approximate row counts (from planner stats — fast, no full scan) and on-disk size. | schema (string, default public) | Catalog read |
| describe_table | Full description of one table/view: columns (type, nullability, default), primary key, foreign keys, and indexes. | table (string, required), schema (string, default public) | Catalog read |
Introspection tools query the Postgres system catalogs with parameterized lookups — identifiers are never string-interpolated into SQL. Every tool returns both human-readable text and typed structuredContent matching its output schema, so an agent can consume either.
Safety model
Each row is a thing an agent (or a hostile prompt steering one) could try, and the mechanism that stops it.
| Threat | Mitigation |
|---|---|
| Write / DDL — INSERT, UPDATE, DELETE, DROP, TRUNCATE, GRANT, … | Rejected by the READ ONLY transaction at execution time (Postgres 25006); also fast-failed by the keyword pre-check |
| Stacked-query injection — a second, destructive statement smuggled after a semicolon | Agent SQL travels over the extended wire protocol (a prepared statement), which can hold only one command — Postgres itself answers a second one with 42601. A literal/comment/dollar-quote-aware statement splitter rejects multi-statement input first, for a clearer error; the protocol is the part that cannot be talked around |
| Data-modifying CTE — WITH x AS (DELETE … RETURNING *) SELECT … | Dedicated hidden-write scan of CTE bodies, backstopped by the READ ONLY transaction |
| Comment / casing smuggling — /* SELECT */ DELETE … | Comments stripped (respecting string and dollar-quoted literals) before the head check; real enforcement is in the engine, not the text |
| Runaway query hanging the DB — SELECT pg_sleep(3600) | statement_timeout (per-transaction SET LOCAL, applied inside the tx, + pool-level default) cancels it; default 5s |
| Oversized result set blowing up memory / agent context | The result is streamed through a server-side cursor that stops at maxRows + 1 rows, so node-pg never buffers more than the cap — even if the query carries its own larger LIMIT. Excess is truncated and truncated: true is returned; default 500 rows |
| Accidental persistence | Every transaction ends in ROLLBACK — the server never commits |
| Identifier injection via introspection | System-catalog lookups are parameterized; no identifier interpolation |
| Disabling a guardrail via a bad env var | Config is validated with hard ceilings (MAX_ROWS ≤ 10,000, QUERY_TIMEOUT_MS ≤ 120,000ms); garbage values refuse to start rather than silently weakening a limit |
What this does not do: it does not mask or redact PII in rows you are allowed to SELECT, and it does not substitute for database-level permissions. Grant the connecting role only what the agent should ever see; this server enforces read-only and bounded, not authorized.
Configuration
All configuration is environment variables — that's the whole point of zero-config. See .env.example.
| Variable | Required | Default | Max | Description |
|---|---|---|---|---|
| DATABASE_URL | Yes | — | — | Postgres connection string. Point it at a least-privilege read-only role. |
| POSTGRES_URL | — | — | — | Fallback used only when DATABASE_URL is unset. |
| QUERY_TIMEOUT_MS | No | 5000 | 120000 | Per-statement timeout in ms. A slow query is cancelled, not run forever. |
| MAX_ROWS | No | 500 | 10000 | Hard cap on rows returned by query. Excess rows are truncated and flagged. |
When to use this vs. alternatives
- Use this when you want an agent to safely read a plain Postgres — RDS, Neon, Supabase-as-plain-PG, or self-hosted — with a
READ ONLYguarantee enforced by the database, installed with onenpxcommand and no YAML, no Docker, no Go binary. - Reach for a DBA-oriented tool (index tuning, health checks, hypothetical indexes) when you're doing performance engineering rather than agent-safe reads — and you're comfortable running it write-enabled.
- Reach for a cloud-vendor server when you're fully inside that vendor's ecosystem (its auth, storage, and edge functions) and don't need neutral, portable Postgres access.
Development
npm ci
npm run build # tsc -> build/, chmod +x the bin
npm run typecheck # strict TS, no emit
npm test # vitest runThe suite runs 137 unit + MCP wiring tests with zero external dependencies. Safety parsing (comment stripping, statement splitting, literal-aware CTE-write detection, LIMIT/FETCH row-cap logic, dollar-quote tags) is exercised directly, and the MCP layer is tested end-to-end over an in-memory client/server transport — rejection paths run fully without a live database, because the safety check fires before the connection pool is ever touched.
A further 24 integration tests run against a real Postgres and are skipped automatically unless a DB is provided:
DATABASE_URL=postgres://user:pass@localhost:5432/db npm testAmong them is a block that tests the central claim without going through the text filter at all: it opens a READ ONLY transaction on a raw connection and hands Postgres a plain CREATE/INSERT/UPDATE/DELETE/TRUNCATE, asserting SQLSTATE 25006 each time. Those tests would still pass if the keyword layer had a hole — which is the point: they measure the engine, not the filter. A second block pins known injection payloads (a $-in-identifier fake dollar tag, an unterminated literal) as regressions.
The write attempts are refused by the engine before Postgres checks permissions, so a least-privilege read-only role — or a read-only replica — is still a perfectly safe target. CI (GitHub Actions) type-checks, builds, and tests on Node 20, 22, and 24 for every push and PR, with a postgres:16-alpine service container, so the integration suite runs in CI rather than skipping itself; a step fails the build if those tests ever report as skipped.
Built on the official @modelcontextprotocol/sdk (STDIO transport, protocol 2025-06-18) and pg. Written in strict TypeScript.
About
Built by Samuel Cabral — senior full-stack engineer (Node.js · TypeScript · NestJS · React · PostgreSQL). I build MCP servers and Claude Code / agent integrations, with a bias toward safety, tests, and tooling that a team can trust in production.
Available for MCP and Claude Code integration work.
- GitHub: github.com/samuel-cabral
- Email: [email protected]
Licensed under MIT.
