pg-mcp-guard
by Hu78945
README.md
# pg-mcp-guard
A security-first [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI assistants (Claude Desktop, Claude Code, Cursor, ...) explore and query a PostgreSQL database without being able to change it, read what they shouldn't, or tie it up.
Most "Postgres MCP" servers pass the model's SQL straight to the database, sometimes after a regex check. This one treats every query as untrusted input and puts four independent layers between it and your data:
1. **A real SQL parser** (`libpg-query`, PostgreSQL's own parser compiled to WASM) accepts only a single `SELECT` / `WITH ... SELECT`, built from an allowlist of parse-tree node types, with no dangerous functions.
2. **A rewrite step** pins every table to its schema, swaps tables that have masked or hidden columns for a masking subquery, and caps the row count with `LIMIT`.
3. **Postgres runs the query in `BEGIN READ ONLY`** with `SET LOCAL statement_timeout`, then always `ROLLBACK`. So even a query that got past the guard cannot write.
4. **Every attempt goes to an audit log** (JSON lines): the original SQL, the SQL that actually ran, the outcome, the row count and the duration.
## Measured results
| What | Result |
|---|---|
| Adversarial corpus (`test/attacks.test.ts`), run end to end through the MCP `run_query` tool | **205 / 205 blocked**: 170 by the guard, 5 by Postgres (hidden columns), 30 neutralized (masked output or capped rows) |
| Realistic analytical queries (`test/benign.test.ts`) | **30 / 30 allowed and executed correctly** after rewriting |
| Full test suite | **269 passed, 1 skipped** on in-process PGlite (PostgreSQL 18); **270 / 270** against a real PostgreSQL 16 server |
| Database state after the attack corpus | unchanged (an md5 snapshot of every table is compared before and after) |
| Guard overhead (`npm run bench`, Apple M3 Pro, Node 24) | p50 **0.04 ms** for a simple select, **0.18 ms** for a CTE + 4-table join over a masked table |
The skipped test checks that `statement_timeout` really cancels a runaway query. PGlite runs single-threaded in WASM and can't interrupt a running query, so that test runs only against a real server (`TEST_DATABASE_URL`, and in CI).
I also checked that the tests can fail: I disabled each defense in turn and confirmed that tests go red. Turning off CTE scoping fails 1 test, schema-aware CTE resolution 1, masking substitution 28, the `SELECT ... INTO` check 5, the node allowlist 9 (with `LockingClause`, `ParamRef`, `XmlExpr` and `RangeTableFunc` allowed), the single-statement check 11, the function denylist 37 (everything except `pg_sleep*`, which would hang the in-process test database) and the row cap 4. These were manual runs, not a scripted mutation-testing tool.
## Quickstart
```bash
npm install
npm run build
# Try it with no Postgres at all: an in-process demo e-commerce database (PGlite)
node dist/cli.js --demo
# Or point it at your own database
DATABASE_URL=postgres://mcp_reader:...@localhost:5432/shop \
POLICY_FILE=./policy.example.yaml \
AUDIT_LOG=./audit.jsonl \
node dist/cli.js
```
To load the demo schema into a real database: `psql "$DATABASE_URL" -f seed/ecommerce.sql`. It creates 200 customers, 50 products, 1,000 orders and 2,760 order items, plus an `admin_users` table that the demo policy hides.
### Claude Desktop
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"postgres": {
"command": "node",
"args": ["/absolute/path/to/pg-mcp-guard/dist/cli.js"],
"env": {
"DATABASE_URL": "postgres://mcp_reader:change-me@localhost:5432/shop",
"POLICY_FILE": "/absolute/path/to/pg-mcp-guard/policy.example.yaml",
"AUDIT_LOG": "/absolute/path/to/pg-mcp-guard/audit.jsonl"
}
}
}
}
```
For the zero-setup demo, use `"args": ["/absolute/path/to/pg-mcp-guard/dist/cli.js", "--demo"]` and leave out `env`.
### Claude Code
```bash
claude mcp add postgres \
-e DATABASE_URL=postgres://mcp_reader:change-me@localhost:5432/shop \
-e POLICY_FILE=$PWD/policy.example.yaml \
-- node $PWD/dist/cli.js
# or the demo
claude mcp add pg-demo -- node $PWD/dist/cli.js --demo
```
Cursor uses the same `mcpServers` JSON shape in `.cursor/mcp.json`.
## Tools and resources
| Tool | What it does |
|---|---|
| `list_schemas` | Lists the exposed schemas and tables, with row estimates (`pg_class.reltuples`) and comments |
| `describe_table` | Shows columns (type, nullability, comment, mask), foreign keys, row estimate and table comment. Hidden columns, and foreign keys that point at hidden tables, are left out |
| `run_query` | Runs one read-only `SELECT`. Rows come back as arrays plus a column list, so duplicate column names survive, with `truncated: true` when the row cap was hit |
| `explain_query` | Returns `EXPLAIN (FORMAT JSON)`, never `ANALYZE`, so the query is not executed. It goes through the same guard |
| `sample_rows` | Returns the first N rows of a table, built as SQL and sent through the same guard, so masking applies |
| Resource | Content |
|---|---|
| `pg-mcp-guard://schema` | Every exposed table and its columns, as JSON |
| `pg-mcp-guard://table/{schema}/{table}` | One table, as JSON (listable) |
All tools carry `readOnlyHint: true`.
## Request path
```mermaid
flowchart TD
A[AI assistant<br/>Claude Desktop / Code / Cursor] -->|MCP over stdio<br/>tools/call run_query| B[pg-mcp-guard]
B --> C{"Length ≤ 20k chars?"}
C -- no --> R[Rejected + audit]
C -- yes --> D["libpg-query parse<br/>(PostgreSQL's real parser)"]
D --> E{"Exactly 1 statement,<br/>and it's a SelectStmt?"}
E -- no --> R
E -- yes --> F["Walk the whole parse tree<br/>• node type allowlist<br/>• no INTO / FOR UPDATE / WITH TIES<br/>• function denylist<br/>• table allowlist, CTE-scope aware"]
F -- violation --> R
F --> G["Rewrite<br/>• pin tables to their schema<br/>• masked tables → masking subquery<br/>• clamp outer LIMIT to maxRows+1"]
G --> H["Deparse, then re-parse and re-validate<br/>(check what will actually run)"]
H -- violation --> R
H --> I["BEGIN READ ONLY<br/>SET LOCAL statement_timeout<br/>run query<br/>ROLLBACK (always)"]
I -- Postgres error --> X[Error + audit]
I --> J["Trim to maxRows,<br/>flag truncated, audit"] --> A
```
## Policy
Everything the policy doesn't allow is denied. See `policy.example.yaml`:
```yaml
schemas: [public]
maxRows: 200
statementTimeoutMs: 5000
tables: # table allowlist; omit to allow every table in `schemas`
public.customers:
columns: [id, full_name, email, phone, city, created_at] # password_hash is invisible
mask: { email: email, phone: last4 }
public.products: {}
public.orders: {}
public.order_items: {}
deniedFunctions: [] # extra regexes on top of the built-in denylist
```
Mask strategies: `redact` (`***`), `email` (`***@domain`), `last4` (`***1234`), `hash` (`md5`, which keeps values joinable).
### How masking works, and why it isn't post-processing
A common approach masks result columns by name after the query runs. That is easy to bypass with `SELECT email AS e`, `row_to_json(c)`, `upper(email)` or `string_agg(email, ',')`, and it does nothing about `WHERE email = 'alice@x.com'`, which leaks data through which rows match.
pg-mcp-guard instead **replaces the table itself**. `FROM customers c` becomes:
```sql
FROM (SELECT id, full_name,
regexp_replace(email::text, '^[^@]*', '***') AS email,
'***' || right(phone::text, 4) AS phone,
city, created_at
FROM public.customers) AS c
```
The real table can't be reached from the query, so every expression sees masked values. That covers `SELECT *`, whole-row references, JSON casts, aggregates and `WHERE`/`JOIN` predicates. An equality or `LIKE` probe against a real email matches nothing. Hidden columns don't exist in the subquery at all, so referencing them fails inside Postgres. The attack corpus tests 23 masking bypasses and 5 hidden-column probes, including those oracles.
## Threat model
**The adversary** is the model's SQL. The model may be confused or hallucinating, or it may be prompt-injected by content it read (a ticket, a web page, a row in your own database). Treat every `run_query` argument as hostile.
**Protected assets and how each is protected:**
| Asset | Threat | Defense |
|---|---|---|
| Data integrity | DML/DDL, writes hidden in CTEs, `SELECT INTO`, `COPY`, `nextval`, `FOR UPDATE` locks, `COMMIT; DELETE` stacking | parser (single `SelectStmt`, node allowlist, `intoClause` check, denylist) **and** a `READ ONLY` transaction that always rolls back |
| Confidential tables | Querying non-allowlisted tables, `pg_authid`/`pg_shadow`, `information_schema`, `pg_stat_activity` (other sessions' SQL) | table allowlist, applied wherever a table appears (subqueries, CTEs, LATERAL, UNION, EXISTS) |
| Confidential columns | Direct reads, aliasing, whole-row/JSON/aggregate tricks, `WHERE` oracles | relation substitution (see above) |
| Allowlist bypass via names | `WITH admin_users AS (...)` shadowing, forward references between CTEs, `public.<cte name>` | CTE scope tracking that follows Postgres's rules: non-recursive CTEs see only earlier siblings, and a schema-qualified name is never a CTE |
| Execution of unparsed SQL | `query_to_xml('...')`, `ts_stat('...')`, `dblink`, `cursor_to_xml` (they run SQL strings the guard never sees) | function denylist |
| Server/host | `pg_read_file`, `pg_ls_dir`, `lo_import/lo_export`, `COPY ... PROGRAM`, `pg_terminate_backend`, `pg_reload_conf`, replication slots | statement allowlist + function denylist |
| Availability | `pg_sleep`, advisory locks, huge result sets, cross-join explosions | denylist, `statement_timeout`, row cap enforced in SQL (`LIMIT`) and again in code |
| Accountability | Silent misuse | JSONL audit of every attempt (ok/rejected/error), with both the original and the executed SQL |
**Defense in depth is tested, not just claimed.** `test/server.test.ts` sends raw `INSERT`/`UPDATE`/`DELETE`/`CREATE`/`nextval`/`FOR UPDATE` straight to the read-only runner, skipping the guard, and checks that Postgres refuses every one.
**Out of scope / residual risk. Read this before production use:**
- **Use a least-privilege role.** The guard is not a substitute for `GRANT SELECT` on only the tables you mean to expose. Connect as a role without superuser, `pg_read_server_files`, `pg_execute_server_program` or write grants. If the database role is locked down, a bug in this project degrades to "the model saw something the policy meant to hide", never to "the model dropped a table".
- **Function denylist, not allowlist.** User-defined functions (and extension functions not on the list) are allowed. A `SECURITY DEFINER` function that reads a hidden table would bypass the table allowlist. The read-only transaction still stops it from writing. If your database has such functions, add them to `deniedFunctions`.
- **Masked values can still be inferred in aggregate.** For example, `count(*) GROUP BY city` over customers is allowed by design. `hash` masking is unsalted md5, so low-entropy values such as phone numbers can be brute-forced. Use `redact` for those.
- **Resource exhaustion inside the timeout.** A 5-second query can still be heavy (big sorts, hash joins). Tune `statementTimeoutMs`, and consider pointing the server at a read replica.
- **Postgres error messages** are returned to the model. They can echo identifiers from the query, but not data. A missing table and a disallowed table produce the same message, so the error doesn't reveal whether a table exists.
- **The catalog is read at startup.** Restart the server after schema changes.
## Design trade-offs
- **Parser over regex.** `SELECT $$; DROP TABLE orders; $$` is a harmless string, and `/* SELECT */ DELETE ...` is a delete. Only a real parser gets both right, and both cases are in the test suites. `libpg-query` is the actual PostgreSQL grammar, so there is no dialect gap between what the guard sees and what the server parses.
- **Allowlist of node types, denylist of functions.** Unfamiliar syntax (XMLTABLE, JSON_TABLE, locking clauses, bind parameters) is rejected by default. A function allowlist would be stricter, but it would also reject most of the useful analytical SQL an assistant writes. That's the documented residual risk above.
- **Rewrite, then validate the output.** The rewritten tree is turned back into SQL with `pgsql-deparser`, re-parsed and validated again, so the guard checks the SQL that actually runs. This caught a real issue during development: the deparser silently drops `WITH TIES`, which would have changed results. `FETCH ... WITH TIES` is now rejected with a clear message rather than quietly rewritten.
- **Row cap in SQL, not only in code.** The outer `LIMIT` is clamped to `maxRows + 1`. The extra row detects truncation, and Postgres can stop early instead of streaming a million rows to a process that throws them away. A `LIMIT (expression)` is kept and capped from an outer query, so its meaning doesn't change.
- **One connection, serialized.** A stdio MCP server has one client, and a single connection keeps the `BEGIN ... ROLLBACK` bracket simple and correct. Switch to a `pg.Pool` if many agents share one server.
- **stdio only.** Streamable HTTP would add authentication, CORS and DNS-rebinding concerns to a tool whose job is to reduce attack surface. Claude Desktop, Claude Code and Cursor all speak stdio.
- **PGlite for tests.** The whole suite runs in-process against real PostgreSQL 18 compiled to WASM, with no Docker, in about 2 seconds. CI also runs it against real PostgreSQL 16 and 17 service containers, which covers `statement_timeout`.
## Development
```bash
npm test # 270 tests, in-process PGlite; prints the attack-corpus tally
npm run typecheck
npm run build
npm run bench # guard latency (needs a build)
# Same suites against a real server:
psql "$TEST_DATABASE_URL" -f seed/ecommerce.sql
TEST_DATABASE_URL=postgres://... npm test
```
```
src/
cli.ts entry point: env/flags, --demo, stdio transport
server.ts MCP tools + resources; the one execute() path: guard → read-only txn → audit
guard.ts parse, validate, rewrite, deparse, re-validate
policy.ts policy schema (zod) + YAML loader
catalog.ts schema introspection; policy-filtered view of it
db.ts pg / PGlite adapters; read-only transaction runner
audit.ts JSONL audit log
test/
attacks.test.ts 205-case adversarial corpus, per-layer tally
benign.test.ts false-positive check: 30 realistic queries
guard.test.ts rewrite output and policy variants
server.test.ts MCP tools/resources, audit, read-only defense in depth
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues