safe-postgres-mcp
An MCP server for safe read-only PostgreSQL exploration and querying, enforced by three independent layers: startup privilege audit, lexical guard, and read-only transactions. Offers tools for querying, table listing, schema description, relationship mapping, and query explanation.
README
safe-postgres-mcp
An MCP server that lets a model explore and query PostgreSQL, built so that it cannot modify anything — not by convention, but because three independent layers each have to fail before a write becomes possible.
Runs locally over stdio (Claude Desktop today), with the transport isolated in a single file so an HTTP transport can be added later for a ChatGPT connector.
The three layers
1. Startup privilege audit. Before a single tool is registered, the server interrogates the connected role. If the role is too powerful, the process exits non-zero and the tools never exist. There is no degraded mode.
2. Lexical guard. Every statement must be a single read-only command. Comments and literals are
stripped before analysis, so a semicolon hidden inside 'a;b' is not a statement separator and a
DROP hidden behind a -- comment is still caught. Queries also travel over the extended query
protocol (queryMode: 'extended'), under which the backend refuses to parse more than one command —
a protocol-level defence that does not depend on our lexer being correct.
Passing
values: []is not enough to get that protection.pg'srequiresPreparation()returnsvalues.length > 0, so an empty array falls back to the simple query protocol, which acceptsSELECT 1; SELECT 2.queryModemust be set explicitly — seeextended()insrc/db.ts.
3. Read-only transaction. Every query runs inside BEGIN TRANSACTION READ ONLY with a statement
timeout, and is always rolled back — including on success. The session additionally sets
default_transaction_read_only=on.
Why layer 1 is not redundant
A read-only transaction is strong but, in PostgreSQL's own words, "does not prevent all writes to
disk". It reliably blocks INSERT/UPDATE/DELETE, all DDL, and those statements nested inside
functions, all with SQLSTATE 25006. It does not block:
| Escape | Why it works | Required privilege |
|---|---|---|
dblink / postgres_fdw |
Opens a separate connection with its own read-write transaction | EXECUTE on the extension |
COPY (SELECT …) TO PROGRAM |
Runs a shell command; never writes to a table | superuser or pg_execute_server_program |
plpython3u, plperlu |
Arbitrary filesystem and network I/O inside a SELECT |
USAGE on the language |
pg_terminate_backend() |
Not a write, so read-only does not apply | pg_signal_backend |
Every one of those needs a privilege the audit refuses. The audit is what turns the read-only transaction from a strong default into a guarantee.
What the audit checks
Always fatal — these let a query escape the read-only transaction:
SUPERUSER,BYPASSRLS,REPLICATION- membership in
pg_execute_server_program,pg_write_server_files,pg_read_server_files - a reachable
dblink,postgres_fdw,file_fdw, or untrusted procedural language
Fatal by default, downgraded to a warning by ALLOW_WRITABLE_ROLE=true — the read-only transaction
does block these, so overriding is defensible:
INSERT/UPDATE/DELETE/TRUNCATEon any reachable tableCREATEon any reachable schema or databaseCREATEDB,CREATEROLE
Privileges are resolved with has_table_privilege() and friends, which account for inheritance
through role membership and through PUBLIC — something scanning information_schema misses.
Setup
npm install
Create a read-only role
CREATE ROLE mcp_reader LOGIN PASSWORD 'choose-a-strong-password';
GRANT USAGE ON SCHEMA tenant_acme TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA tenant_acme TO mcp_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA tenant_acme GRANT SELECT ON TABLES TO mcp_reader;
-- PostgreSQL 14 and older grant CREATE on the public schema to PUBLIC by default:
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
If you skip this, the server tells you exactly which privilege blocked it and prints this script scoped to your schema.
Claude Desktop (Windows + WSL)
Dependencies are installed under WSL, so node_modules holds Linux binaries (@esbuild/linux-x64).
Claude Desktop runs on Windows and would use node.exe, which cannot load them — so the config
invokes the server through WSL rather than directly.
The config file lives in different places depending on how Claude Desktop was installed:
| Install | Path |
|---|---|
| Regular installer | %APPDATA%\Claude\claude_desktop_config.json |
| Microsoft Store (MSIX) | %LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json |
MSIX packages virtualise %APPDATA%, so the Store build never creates %APPDATA%\Claude. On this
machine the real path is:
C:\Users\lucas\AppData\Local\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\
That file also holds the app's own preferences, so add mcpServers to it — do not overwrite it.
{
"mcpServers": {
"postgres-safe": {
"command": "wsl.exe",
"args": [
"-d", "Ubuntu", "--",
"bash", "-lc",
"cd /mnt/c/Users/lucas/Desktop/mcpYt && exec npx tsx src/index.ts"
],
"env": {
"DATABASE_URL": "postgres://mcp_reader:password@host:5432/mydb",
"WSLENV": "DATABASE_URL/u:PG_SCHEMA/u:ALLOW_WRITABLE_ROLE/u:MAX_ROWS/u:STATEMENT_TIMEOUT_MS/u"
}
}
}
}
WSLENV is what carries the variables across the Windows/Linux boundary — the /u suffix means
"forward this from Win32 into WSL". Keeping the URL in the env block rather than inline in the
shell command means passwords containing quotes or $ need no escaping.
If you would rather run entirely on Windows, run npm install from a Windows shell so npm fetches
@esbuild/win32-x64, then use "command": "npx" with a Windows path. Note that the two installs
overwrite each other in the same node_modules, so pick one.
The connection string lives in the client config, so it never enters the conversation.
Claude Code
.mcp.json in the project root already configures the server for Claude Code running inside WSL —
no wsl.exe wrapper needed. Fill in DATABASE_URL and it is picked up on the next session.
Configuration
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
(required) | Connection string |
PG_SCHEMA |
auto-detected | Tenant schema. Required only when the role can reach more than one |
ALLOW_WRITABLE_ROLE |
false |
Downgrade the write-grant checks to warnings |
MAX_ROWS |
200 |
Row cap per query |
STATEMENT_TIMEOUT_MS |
10000 |
Per-query timeout |
Multi-tenant scoping
The server anchors to exactly one schema and every tool operates inside it, so unqualified table
names resolve there. The role's USAGE grants are the source of truth: if it can reach exactly one
non-system schema, that schema is detected automatically; if it can reach several, PG_SCHEMA
picks between them. The tenant boundary is enforced by PostgreSQL's grants, not by parsing SQL for
cross-schema references.
Tools
| Tool | Purpose |
|---|---|
query |
Run one read-only statement |
list_tables |
Tables, views and matviews with estimated rows, size and comments |
describe_table |
Columns, types, defaults, PK, outbound and inbound FKs, constraints, indexes |
list_relationships |
The schema's whole foreign-key graph, for writing correct JOINs |
explain_query |
Execution plan, never ANALYZE |
get_database_info |
Connection details and which safety checks passed |
Tests
npm run test:unit # lexical guard, no database needed
TEST_ADMIN_URL=postgres://owner:pw@host:5432/db npm run test:integration
The integration suite creates a throwaway mcp_test schema with a read-only role and a writable
role, then drops both. Its load-bearing test issues an INSERT with a role that genuinely holds
INSERT, bypassing the lexical guard entirely, and asserts PostgreSQL rejects it with 25006 —
proving layer 3 works on its own rather than assuming it.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.