pg-sentinel
A PostgreSQL MCP server with three independent safety layers that allows LLMs to explore and query databases safely via read-only SELECT operations, with full audit logging and query plan intelligence.
README
pg-sentinel
A PostgreSQL MCP server built like production infrastructure: three independent
safety layers, full query auditing, and query-plan intelligence. Let an LLM
explore and query your database without losing sleep — every statement is parsed
and proven read-only before it executes, inside a READ ONLY transaction, as a
role that couldn't write even if it wanted to.
<!-- Demo: ask "which product category had the highest revenue last quarter?", then "why is that query slow?" — see demo/DEMO.md for the recording script. GitHub plays the MP4 inline; the GIF is the fallback for renderers that strip <video> (PyPI, npm, editors). --> <video src="https://github.com/anshujod/postgres_mcp_server/raw/main/demo/demo.mp4" autoplay loop muted playsinline width="960"> <img src="demo/demo.gif" alt="pg-sentinel demo" width="960"> </video>
Architecture
flowchart LR
subgraph client["MCP client (Claude Desktop, …)"]
LLM
end
LLM -- "tools & resources (stdio)" --> S
subgraph server["pg-sentinel"]
S[FastMCP server] --> A["Layer 1 · SQL analyzer<br/>(pglast parse tree)"]
A -- "verdict + policy" --> E["Layer 2 · executor<br/>READ ONLY txn, always rolled back<br/>timeout + row cap"]
S -. "audit log (structlog)" .-> L[(JSON logs)]
end
E -- "Layer 3 · read-only role<br/>(SELECT-only grants)" --> PG[(PostgreSQL)]
Quick start
Try the demo (one command)
git clone https://github.com/anshujod/postgres_mcp_server && cd postgres_mcp_server/demo
docker compose up --build
This starts Postgres 16 seeded with an e-commerce dataset (10k customers, 50k
orders, ~125k line items) and builds the pg-sentinel image connected as the
read-only sentinel_ro role. Postgres listens on host port 5433 (override
with PG_SENTINEL_DEMO_PORT if you like).
Run against your own database
PG_SENTINEL_DATABASE_URL=postgresql://user:pass@host:5432/mydb uvx pg-sentinel
Best practice: create a dedicated read-only role first (layer 3), and connect as that:
CREATE ROLE sentinel_ro LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE mydb TO sentinel_ro;
GRANT USAGE ON SCHEMA public TO sentinel_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO sentinel_ro;
Claude Desktop setup
-
Open your config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
- macOS:
-
Add pg-sentinel under
mcpServers(uvx form shown; a docker form is indemo/claude_desktop_config.json):{ "mcpServers": { "pg-sentinel": { "command": "uvx", "args": ["pg-sentinel"], "env": { "PG_SENTINEL_DATABASE_URL": "postgresql://sentinel_ro:sentinel_ro_demo@localhost:5433/demo" } } } } -
Restart Claude Desktop. Ask something like “which product category had the highest revenue last quarter?” and watch it write and run the SQL.
The safety model: three independent layers
Any single defense can have a hole. pg-sentinel stacks three, each of which alone blocks writes — an attacker (or a confused LLM) has to get through all of them at once.
| Layer | Mechanism | Catches |
|---|---|---|
| 1 — SQL analyzer | Every statement is parsed with pglast (PostgreSQL's own parser). Only a single top-level SELECT passes; the whole tree is walked to reject embedded writes, SELECT … INTO, LOCK/COPY, and ~20 dangerous functions (pg_sleep, pg_read_file, dblink*, …), even schema-qualified. |
Writable CTEs (WITH x AS (DELETE …) SELECT …), multi-statement injection (SELECT 1; DROP TABLE users), comment tricks, EXPLAIN ANALYZE DELETE … |
| 2 — READ ONLY transaction | Every query runs inside SET TRANSACTION READ ONLY and is always rolled back, never committed, with a statement timeout and row cap. |
Anything that somehow slips past the analyzer — Postgres itself rejects the write. Verified by test: an INSERT handed directly to the executor raises ReadOnlySQLTransactionError. |
| 3 — Read-only DB role | The server connects as a role with only SELECT granted (sentinel_ro in the demo). |
Bugs in pg-sentinel itself. Even with layers 1–2 gone, DELETE → permission denied. |
An optional policy layer adds table allow/denylists (glob patterns like
public.*, deny auth.*) and per-query limits. Every call is logged as
structured JSON — SQL, verdict, duration, row count — for a full audit trail.
Tool reference
| Tool | Arguments | What it does |
|---|---|---|
query |
sql, limit=100, format=markdown|json |
Run a read-only SELECT. Rejections return Query rejected: <reason> instead of raising, so the LLM can relay and adapt. |
explain_query |
sql, analyze=false |
Query plan with a human summary: total cost, actual time (if analyzed), most expensive node, join strategies, and seq scans on large tables flagged as possible missing indexes. |
list_tables |
schema="public" |
Tables with approximate row counts and comments. |
describe_table |
table, schema="public" |
Columns, primary key, foreign keys, indexes, comment. |
find_relevant_tables |
question, limit=5 |
Shortlist the tables most relevant to a natural-language question (ranked by name/column/comment overlap), so large schemas don't flood the context. |
sample_rows |
table, schema="public", n=5 |
A few example rows; uses TABLESAMPLE on large tables. Identifiers are validated against the catalog, never interpolated. |
Write mode (opt-in)
Off by default. Set PG_SENTINEL_WRITE_MODE=1 (and connect as a role that can
actually write) to expose a preview-and-confirm write path. The read tools stay
read-only regardless.
| Tool | Arguments | What it does |
|---|---|---|
preview_write |
sql |
Runs a single INSERT/UPDATE/DELETE in an open transaction and returns a diff-style preview of the affected rows plus a token. Nothing is committed. |
confirm_write |
token |
Commits the exact transaction that was previewed. |
cancel_write |
token |
Rolls back a pending preview. |
Unconfirmed previews auto-roll-back after PG_SENTINEL_WRITE_PREVIEW_TTL_SECONDS
(Postgres' own idle_in_transaction_session_timeout enforces it), and only a
handful may be pending at once.
Resources: schema://tables (all tables) and schema://tables/{schema}/{table}
(one table in detail) expose the same introspection for resource-aware clients.
Configuration
All settings are environment variables with the PG_SENTINEL_ prefix:
| Variable | Default | Meaning |
|---|---|---|
PG_SENTINEL_DATABASE_URL |
(required) | Postgres DSN. |
PG_SENTINEL_QUERY_TIMEOUT_SECONDS |
10 |
Per-query timeout. |
PG_SENTINEL_MAX_ROWS |
500 |
Hard cap on returned rows (results are marked truncated). |
PG_SENTINEL_READ_ONLY |
true |
Safety switch; the server refuses to run queries if disabled. |
PG_SENTINEL_WRITE_MODE |
false |
Opt-in preview/confirm write tools (see above). |
PG_SENTINEL_WRITE_PREVIEW_TTL_SECONDS |
120 |
How long an unconfirmed write preview is held before auto-rollback. |
PG_SENTINEL_WRITE_MAX_PREVIEW_ROWS |
50 |
Max affected rows shown in a write preview. |
PG_SENTINEL_POOL_MIN_SIZE / _MAX_SIZE |
1 / 5 |
Connection pool bounds. |
PG_SENTINEL_LOG_LEVEL |
INFO |
Log level. |
PG_SENTINEL_DEV |
unset | 1 = pretty console logs instead of JSON. |
Design decisions
Why a real parser (pglast) instead of regex or keyword filtering?
Regexes cannot see structure. WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x
contains a destructive write with no leading DELETE; a dollar-quoted string
SELECT $$DROP TABLE users$$ contains scary keywords but is harmless data.
pglast wraps PostgreSQL's actual parser, so pg-sentinel makes decisions on the
same syntax tree the database itself would execute — no false negatives from
clever encodings, no false positives from string contents. The adversarial test
suite (50+ cases) encodes exactly these attacks.
Why do rejections return messages instead of raising errors?
The consumer is an LLM. A raised exception surfaces as an opaque protocol error;
a returned Query rejected: only SELECT statements are allowed, got DELETE is
something the model can read, relay to the user, and act on — usually by
rewriting the query correctly on the next attempt.
The EXPLAIN ANALYZE rule.
Plain EXPLAIN only plans a statement, but EXPLAIN ANALYZE executes it —
EXPLAIN ANALYZE DELETE FROM users deletes your users. pg-sentinel therefore
never accepts EXPLAIN as raw SQL; the explain_query tool analyzes the inner
statement with the same layer-1 rules, so anything reaching ANALYZE true is
already a proven-safe SELECT — which layers 2 and 3 then guard anyway.
Why fetch max_rows + 1 through a cursor?
Fetching one row past the cap distinguishes "exactly 500 rows" from "truncated
at 500" without COUNT(*) overhead, and the cursor keeps a SELECT * FROM huge_table from ever materializing in server memory.
Development
uv sync # install everything
uv run pytest -m "not integration" # unit tests (no Docker needed)
uv run pytest # full suite (spins up Postgres 16 in Docker)
uv run ruff check src tests && uv run mypy src
Further reading: the blog-post outline covers the threat model and what the adversarial suite caught during development.
License
MIT
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。