@yawlabs/postgres-mcp 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -21
- package/README.md +81 -30
- package/dist/index.js +32 -13
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-05-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- New `pg_readonly` tool. Always runs inside `BEGIN READ ONLY` regardless of
|
|
14
|
+
`ALLOW_WRITES`, so postgres itself rejects any write attempt. The point is
|
|
15
|
+
to give hosts that gate tools individually (Claude Code permissions,
|
|
16
|
+
mcp.hosting per-tool toggles) a stable always-safe target to auto-allow,
|
|
17
|
+
independent of how the server is configured. Same input shape as `pg_query`
|
|
18
|
+
(`sql` + `params`).
|
|
19
|
+
- New "Configuring access" README section walking through the recommended
|
|
20
|
+
least-privileged-role posture: `CREATE ROLE mcp_reader ... GRANT
|
|
21
|
+
pg_read_all_data` for read-only agents, and a `mcp_writer` example with
|
|
22
|
+
table-level grants for scoped writes. The role is the primary access
|
|
23
|
+
control; `ALLOW_WRITES` is positioned as secondary belt-and-braces.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `pg_query` description leads with role-based access control. `ALLOW_WRITES`
|
|
27
|
+
is now framed as a secondary gate, with the role in `DATABASE_URL` as the
|
|
28
|
+
authoritative one. No behavior change.
|
|
29
|
+
- README "Why this one?" read-only bullet expanded: `pg_query` continues to
|
|
30
|
+
default to read-only via `BEGIN READ ONLY`, and `pg_readonly` is the new
|
|
31
|
+
unconditional read tool. Configuration table and troubleshooting entry
|
|
32
|
+
for `ALLOW_WRITES` updated to point at the Configuring access section.
|
|
33
|
+
|
|
10
34
|
## [0.5.3] - 2026-05-14
|
|
11
35
|
|
|
12
36
|
### Security
|
|
@@ -322,7 +346,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
322
346
|
writes executed by `EXPLAIN ANALYZE`. Previously the write ran inside a
|
|
323
347
|
`BEGIN; ... COMMIT` transaction, so `pg_explain { analyze: true, sql:
|
|
324
348
|
"INSERT ..." }` would actually insert the row. Now writes run inside a
|
|
325
|
-
`BEGIN; ... ROLLBACK` transaction
|
|
349
|
+
`BEGIN; ... ROLLBACK` transaction - the plan (with real row counts and
|
|
326
350
|
timing) comes back but the mutation is rolled back. This matches the user
|
|
327
351
|
expectation when asking for a plan, and the tool description has been
|
|
328
352
|
updated to reflect it.
|
|
@@ -354,7 +378,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
354
378
|
- `pg_list_roles` with `includeSystem: false` (the default) now actually
|
|
355
379
|
excludes built-in `pg_*` roles. The previous `LIKE 'pg\_%' ESCAPE '\\'`
|
|
356
380
|
filter ended up as SQL `ESCAPE '\\'` (two backslashes), which Postgres
|
|
357
|
-
rejects since `ESCAPE` requires a single character
|
|
381
|
+
rejects since `ESCAPE` requires a single character - so the whole filter
|
|
358
382
|
was silently being dropped. Replaced with `starts_with(rolname, 'pg_')`.
|
|
359
383
|
- `pg_describe_table` foreign-key `columns` and `foreign_columns` are now
|
|
360
384
|
proper JSON arrays. They were previously returned as the raw postgres
|
|
@@ -378,11 +402,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
378
402
|
## [0.3.0] - 2026-04-22
|
|
379
403
|
|
|
380
404
|
### Added
|
|
381
|
-
- `pg_list_views`
|
|
382
|
-
- `pg_list_functions`
|
|
383
|
-
- `pg_list_extensions`
|
|
384
|
-
- `pg_search_columns`
|
|
385
|
-
- `pg_top_queries`
|
|
405
|
+
- `pg_list_views` - list views and materialized views with SQL definitions.
|
|
406
|
+
- `pg_list_functions` - list functions, procedures, and aggregates with signatures.
|
|
407
|
+
- `pg_list_extensions` - list installed extensions (pgvector, postgis, etc.) with versions.
|
|
408
|
+
- `pg_search_columns` - find columns by name pattern across all user schemas.
|
|
409
|
+
- `pg_top_queries` - top N queries by total/mean execution time from
|
|
386
410
|
`pg_stat_statements`. Detects extension version and picks the right column
|
|
387
411
|
names (v1.8+ uses `total_exec_time`, older uses `total_time`). Returns clear
|
|
388
412
|
setup instructions if the extension is not installed.
|
|
@@ -404,23 +428,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
404
428
|
tool against a real Postgres instance. Gated on `POSTGRES_MCP_INTEGRATION=1`
|
|
405
429
|
so local `npm test` stays fast with no DB required. CI runs it on Linux via
|
|
406
430
|
a `postgres:16` service container with `pg_stat_statements` preloaded.
|
|
407
|
-
- `pg_inspect_locks`
|
|
431
|
+
- `pg_inspect_locks` - show current blocking locks (blocked PID, blocker PID,
|
|
408
432
|
relation, lock type, both queries). First tool to reach for when a session
|
|
409
433
|
hangs or the app feels stuck.
|
|
410
|
-
- `pg_list_roles`
|
|
434
|
+
- `pg_list_roles` - database roles with login/superuser/createdb/createrole
|
|
411
435
|
flags and inherited group memberships.
|
|
412
|
-
- `pg_table_privileges`
|
|
436
|
+
- `pg_table_privileges` - who has SELECT/INSERT/UPDATE/DELETE/etc. on a table,
|
|
413
437
|
or on all tables in a schema. Useful for pre-migration audits.
|
|
414
|
-
- `pg_seq_scan_tables`
|
|
438
|
+
- `pg_seq_scan_tables` - tables with heavy sequential scans relative to index
|
|
415
439
|
scans. Missing-index candidates.
|
|
416
|
-
- `pg_unused_indexes`
|
|
440
|
+
- `pg_unused_indexes` - non-unique, non-primary indexes with low/zero scan
|
|
417
441
|
counts. Drop candidates (each unused index costs write amplification).
|
|
418
|
-
- `pg_kill`
|
|
442
|
+
- `pg_kill` - cancel a running query or terminate a backend by PID. Requires
|
|
419
443
|
`ALLOW_WRITES=1` since it changes session state. Distinguishes `cancel`
|
|
420
444
|
(SIGINT-equivalent, graceful) from `terminate` (SIGTERM, forceful).
|
|
421
|
-
- `pg_table_bloat`
|
|
445
|
+
- `pg_table_bloat` - estimate dead tuples and vacuum-candidate tables from
|
|
422
446
|
`pg_stat_user_tables`. No extensions required.
|
|
423
|
-
- `pg_replication_status`
|
|
447
|
+
- `pg_replication_status` - replication slots, connected replicas with lag,
|
|
424
448
|
and current WAL position. Returns empty arrays on a standalone DB rather
|
|
425
449
|
than erroring, so it's safe to call unconditionally.
|
|
426
450
|
- New "What can an agent do with this?" README section with concrete example
|
|
@@ -452,12 +476,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
452
476
|
Initial release.
|
|
453
477
|
|
|
454
478
|
### Added
|
|
455
|
-
- `pg_query`
|
|
456
|
-
- `pg_list_schemas`
|
|
457
|
-
- `pg_list_tables`
|
|
458
|
-
- `pg_describe_table`
|
|
459
|
-
- `pg_explain`
|
|
460
|
-
- `pg_health`
|
|
479
|
+
- `pg_query` - run SQL with read-only-by-default safety. Writes opt in via `ALLOW_WRITES=1`.
|
|
480
|
+
- `pg_list_schemas` - list non-system schemas.
|
|
481
|
+
- `pg_list_tables` - list tables (and optionally views) with estimated row counts.
|
|
482
|
+
- `pg_describe_table` - columns, PK, FKs, indexes.
|
|
483
|
+
- `pg_explain` - `EXPLAIN` / `EXPLAIN ANALYZE` with text or JSON output.
|
|
484
|
+
- `pg_health` - server version, db size, connections, active queries, table count.
|
|
461
485
|
- Single-file bundled distribution (zero runtime deps) for fast `npx` cold starts.
|
|
462
486
|
- Result row truncation at `POSTGRES_MAX_ROWS` (default 1000).
|
|
463
487
|
- Parameterized queries via `params` on `pg_query` and `pg_explain`.
|
package/README.md
CHANGED
|
@@ -3,37 +3,42 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/postgres-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
**Query a PostgreSQL database from Claude Code, Cursor, and any MCP client.** Read-only by default
|
|
6
|
+
**Query a PostgreSQL database from Claude Code, Cursor, and any MCP client.** Read-only by default - writes opt in via a single env var - so an agent can't silently drop your tables.
|
|
7
7
|
|
|
8
8
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
9
|
|
|
10
|
+
[](https://mcp.hosting/install?name=Postgres&command=npx&args=-y%2C%40yawlabs%2Fpostgres-mcp&description=Query%20PostgreSQL%20-%20schema%20introspection%2C%20EXPLAIN%20plans%2C%20health%20diagnostics%2C%20read-only%20by%20default&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fpostgres-mcp)
|
|
11
|
+
|
|
12
|
+
One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
|
|
13
|
+
|
|
10
14
|
## Backstory
|
|
11
15
|
|
|
12
|
-
Anthropic's reference Postgres MCP server, `@modelcontextprotocol/server-postgres`, was [archived in May 2025](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) and [marked deprecated on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres) in July 2025. Anthropic has not shipped a replacement. Despite the deprecation, the last published version (v0.6.2) is still pulled ~20,000 times per week
|
|
16
|
+
Anthropic's reference Postgres MCP server, `@modelcontextprotocol/server-postgres`, was [archived in May 2025](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) and [marked deprecated on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres) in July 2025. Anthropic has not shipped a replacement. Despite the deprecation, the last published version (v0.6.2) is still pulled ~20,000 times per week - a lot of agents are pointed at an unmaintained package.
|
|
13
17
|
|
|
14
18
|
That unmaintained package also has a known, [publicly documented stacked-query SQL injection](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (Datadog Security Labs) that bypasses its `BEGIN READ ONLY` wrapper with input like `COMMIT; DROP SCHEMA public CASCADE;`. It has never been patched at npm.
|
|
15
19
|
|
|
16
20
|
A handful of community forks have appeared, but each fills a narrow slice:
|
|
17
21
|
|
|
18
|
-
- [`@zeddotdev/postgres-context-server`](https://www.npmjs.com/package/@zeddotdev/postgres-context-server)
|
|
19
|
-
- **Postgres MCP Pro** (Crystal DBA)
|
|
20
|
-
- **AWS Labs Postgres MCP**
|
|
22
|
+
- [`@zeddotdev/postgres-context-server`](https://www.npmjs.com/package/@zeddotdev/postgres-context-server) - Zed's fork, primarily a security patch on the original shape.
|
|
23
|
+
- **Postgres MCP Pro** (Crystal DBA) - focused on index tuning and hypothetical-index / buffer-cache diagnostics.
|
|
24
|
+
- **AWS Labs Postgres MCP** - tied to Aurora / RDS Data API + Secrets Manager.
|
|
21
25
|
|
|
22
26
|
None of them position themselves as a general-purpose daily driver you'd hand to Claude Code or Cursor against an arbitrary Postgres: modern introspection, perf helpers, role/privilege awareness, and a write-safety posture out of the box. That's the gap `@yawlabs/postgres-mcp` fills.
|
|
23
27
|
|
|
24
28
|
## Why this one?
|
|
25
29
|
|
|
26
|
-
- **Read-only by default**
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
-
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
30
|
+
- **Read-only by default, with an unconditional read-only tool too** - `pg_query` runs user SQL in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes; opt in to writes with `ALLOW_WRITES=1`. `pg_readonly` is a separate tool that stays read-only regardless of `ALLOW_WRITES`, so hosts that gate tools individually (Claude Code permissions, mcp.hosting) can safely auto-allow it.
|
|
31
|
+
- **Role-based access as the primary control** - the recommended posture is to use a least-privileged postgres role in `DATABASE_URL` (e.g. one with `GRANT pg_read_all_data`); postgres itself then enforces the boundary, no env var needed. See [Configuring access](#configuring-access).
|
|
32
|
+
- **Extended query protocol for all user SQL** - `pg_query` sends user input with `queryMode: 'extended'`, which restricts each request to a single statement. This closes the [stacked-query injection class](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (`COMMIT; DROP SCHEMA x CASCADE;`) that defeated the reference server's `BEGIN READ ONLY` wrapper. Integration test asserts the rejection.
|
|
33
|
+
- **Parameterized queries** - `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
|
|
34
|
+
- **Written from scratch, actively maintained** - not a fork of the deprecated code. Unit + integration tests (`npm test`, `npm run test:integration`) run against a real Postgres; releases cut via `release.sh`.
|
|
35
|
+
- **Schema introspection built in** - `pg_list_schemas`, `pg_list_tables`, `pg_describe_table` return columns, primary keys, foreign keys, and indexes without the agent having to remember `pg_catalog` joins.
|
|
36
|
+
- **`EXPLAIN` as a first-class tool** - text or JSON format, with optional `ANALYZE`. ANALYZE for non-SELECT statements requires `ALLOW_WRITES=1` and always rolls back, so the plan is real but the write doesn't persist.
|
|
37
|
+
- **Perf diagnostics the deprecated server never had** - `pg_top_queries` (from `pg_stat_statements`), `pg_seq_scan_tables`, `pg_unused_indexes`, `pg_table_bloat`, `pg_inspect_locks`, `pg_replication_status`. Answer "why is this slow?" in one tool call.
|
|
38
|
+
- **Health snapshot** - `pg_health` returns version, db size, connection counts, and the 10 longest-running active queries in one call.
|
|
39
|
+
- **Role and privilege awareness** - `pg_list_roles` and `pg_table_privileges` for the common "who can touch what?" questions.
|
|
40
|
+
- **Instant startup** - ships as a single bundled file with zero runtime dependencies. No multi-minute `node_modules` install on every `npx` cold start.
|
|
41
|
+
- **Result truncation** - large result sets are capped at `POSTGRES_MAX_ROWS` (default 1000) with a `truncated: true` flag, so a stray `SELECT * FROM events` doesn't blow out the model context.
|
|
37
42
|
|
|
38
43
|
## Quick start
|
|
39
44
|
|
|
@@ -88,7 +93,52 @@ Read-only is the default. If you want the agent to be able to `INSERT`, `UPDATE`
|
|
|
88
93
|
}
|
|
89
94
|
```
|
|
90
95
|
|
|
91
|
-
Prefer scoping this to dev/test databases
|
|
96
|
+
Prefer scoping this to dev/test databases - for production, leave writes off and use migration tools out-of-band.
|
|
97
|
+
|
|
98
|
+
## Configuring access
|
|
99
|
+
|
|
100
|
+
The role in `DATABASE_URL` is the primary access control. Postgres has had a battle-tested permission system for 30 years; lean on it instead of relying on `ALLOW_WRITES` alone. A least-privileged role makes writes server-rejected no matter what tools or env vars are configured.
|
|
101
|
+
|
|
102
|
+
**Read-only agent (recommended default):**
|
|
103
|
+
|
|
104
|
+
```sql
|
|
105
|
+
CREATE ROLE mcp_reader LOGIN PASSWORD 'change-me';
|
|
106
|
+
GRANT CONNECT ON DATABASE your_db TO mcp_reader;
|
|
107
|
+
GRANT USAGE ON SCHEMA public TO mcp_reader;
|
|
108
|
+
GRANT pg_read_all_data TO mcp_reader;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Point `DATABASE_URL` at `mcp_reader`. Postgres rejects every write, every DDL, every privilege change - regardless of `ALLOW_WRITES`. No app-level guard to bypass; the database is the boundary.
|
|
112
|
+
|
|
113
|
+
**Scoped write agent (dev/test or narrow production use):**
|
|
114
|
+
|
|
115
|
+
```sql
|
|
116
|
+
CREATE ROLE mcp_writer LOGIN PASSWORD 'change-me';
|
|
117
|
+
GRANT CONNECT ON DATABASE your_db TO mcp_writer;
|
|
118
|
+
GRANT USAGE ON SCHEMA public TO mcp_writer;
|
|
119
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mcp_writer;
|
|
120
|
+
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO mcp_writer;
|
|
121
|
+
-- DDL not granted -- the agent can change data but not schema.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Set `ALLOW_WRITES=1` so `pg_query` will issue writes, and rely on the role to keep the agent away from DDL and other schemas.
|
|
125
|
+
|
|
126
|
+
**Per-tool gating in the host:**
|
|
127
|
+
|
|
128
|
+
Tools split cleanly across two authority classes:
|
|
129
|
+
|
|
130
|
+
- **Auto-allow:** `pg_readonly` (server-side `BEGIN READ ONLY`, unconditional), plus the introspection tools (`pg_list_*`, `pg_describe_table`, `pg_search_columns`, `pg_explain` without ANALYZE-of-write, `pg_health`, `pg_inspect_locks`, `pg_table_bloat`, `pg_unused_indexes`, `pg_top_queries`, `pg_replication_status`, `pg_advisor`, `pg_table_privileges`, `pg_list_roles`).
|
|
131
|
+
- **Always prompt:** `pg_query` (can write when the role allows it), `pg_kill` (changes session state).
|
|
132
|
+
|
|
133
|
+
Claude Code's `permissions` block and mcp.hosting's per-tool toggle both honor this split.
|
|
134
|
+
|
|
135
|
+
**`ALLOW_WRITES` as defense-in-depth:**
|
|
136
|
+
|
|
137
|
+
`ALLOW_WRITES` is a secondary belt-and-braces gate. Useful when:
|
|
138
|
+
- You're on a managed database where creating a second role is awkward (some Supabase/Neon plans).
|
|
139
|
+
- You want a single role that can write, but want the MCP server to refuse writes anyway during normal operation.
|
|
140
|
+
|
|
141
|
+
Otherwise, configure the role and stop relying on `ALLOW_WRITES`.
|
|
92
142
|
|
|
93
143
|
## What can an agent do with this?
|
|
94
144
|
|
|
@@ -103,7 +153,7 @@ Once connected, the agent picks tools automatically based on what you ask. A few
|
|
|
103
153
|
|
|
104
154
|
The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
105
155
|
|
|
106
|
-
- **Unstick a hung app.** `pg_inspect_locks` returns blocked PID + blocking PID + the offending query, then `pg_kill` (`ALLOW_WRITES=1` required) cancels the blocker. The agent can run both in one turn
|
|
156
|
+
- **Unstick a hung app.** `pg_inspect_locks` returns blocked PID + blocking PID + the offending query, then `pg_kill` (`ALLOW_WRITES=1` required) cancels the blocker. The agent can run both in one turn - it's the fastest path from "the app is frozen" to "back up."
|
|
107
157
|
- **Chase a slow page.** `pg_top_queries` ranks the worst queries, `pg_explain` with `analyze: true` shows the plan for the top hit, `pg_seq_scan_tables` and `pg_unused_indexes` say whether the answer is "add an index here" or "drop a dead one there."
|
|
108
158
|
- **Oncall triage.** `pg_health` checks connectivity + active-query count + database size; `pg_inspect_locks` and `pg_replication_status` confirm whether contention or replication lag is in play before paging the on-call DBA.
|
|
109
159
|
|
|
@@ -111,7 +161,8 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
|
111
161
|
|
|
112
162
|
| Tool | Description |
|
|
113
163
|
|------|-------------|
|
|
114
|
-
| `
|
|
164
|
+
| `pg_readonly` | Run a SQL statement guaranteed read-only - always inside `BEGIN READ ONLY`, regardless of `ALLOW_WRITES`. The recommended tool for read access; safe for hosts to auto-allow. |
|
|
165
|
+
| `pg_query` | Run a SQL query. Writes gated by the role in `DATABASE_URL` first, `ALLOW_WRITES` second. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
|
|
115
166
|
| `pg_list_schemas` | List non-system schemas. |
|
|
116
167
|
| `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
|
|
117
168
|
| `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
|
|
@@ -122,12 +173,12 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
|
122
173
|
| `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. Optional `hypothetical_indexes` (requires the [HypoPG](https://github.com/HypoPG/hypopg) extension) lets you ask "what would the plan be with these indexes?" without creating them on disk. |
|
|
123
174
|
| `pg_health` | Server version, database size, connection count, active queries, table count. |
|
|
124
175
|
| `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. |
|
|
125
|
-
| `pg_seq_scan_tables` | Tables with heavy sequential scans
|
|
126
|
-
| `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts
|
|
176
|
+
| `pg_seq_scan_tables` | Tables with heavy sequential scans - missing-index candidates. |
|
|
177
|
+
| `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts - drop candidates. |
|
|
127
178
|
| `pg_inspect_locks` | Who is blocking whom right now (blocked PID, blocker PID, lock type, queries). |
|
|
128
179
|
| `pg_list_roles` | Database roles with login/superuser/createdb flags and group memberships. |
|
|
129
180
|
| `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
|
|
130
|
-
| `pg_table_bloat` | Tables with high dead-tuple ratios
|
|
181
|
+
| `pg_table_bloat` | Tables with high dead-tuple ratios - VACUUM candidates. |
|
|
131
182
|
| `pg_replication_status` | Replication slots, connected replicas, and current WAL position. |
|
|
132
183
|
| `pg_advisor` | Rolled-up DBA lints in one call: sequence-exhaustion candidates, tables without a primary key, and (configurable) public tables with RLS disabled. The "what should I be looking at?" starting point. |
|
|
133
184
|
| `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
|
|
@@ -139,7 +190,7 @@ All env vars are read from the MCP server's environment:
|
|
|
139
190
|
| Variable | Default | Purpose |
|
|
140
191
|
|----------|---------|---------|
|
|
141
192
|
| `DATABASE_URL` | (required) | PostgreSQL connection string. |
|
|
142
|
-
| `ALLOW_WRITES` | unset | Set to `1` or `true` to
|
|
193
|
+
| `ALLOW_WRITES` | unset | Secondary write gate for `pg_query` and `pg_explain` ANALYZE-of-writes. Set to `1` or `true` to lift the `BEGIN READ ONLY` wrapper. The role in `DATABASE_URL` is the primary control - see [Configuring access](#configuring-access). Does not affect `pg_readonly`, which is unconditional. |
|
|
143
194
|
| `POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-statement timeout. |
|
|
144
195
|
| `POSTGRES_CONNECTION_TIMEOUT_MS` | `10000` | TCP connect timeout. Without this, a dead host hangs until the OS gives up (~2 minutes). |
|
|
145
196
|
| `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
|
|
@@ -171,19 +222,19 @@ This disables certificate chain verification only -- the TCP connection is still
|
|
|
171
222
|
|
|
172
223
|
## Troubleshooting
|
|
173
224
|
|
|
174
|
-
**`DATABASE_URL is not set`**
|
|
225
|
+
**`DATABASE_URL is not set`** - Your MCP client is launching the server without the env var. On Windows especially, env vars set in bash / PowerShell profiles are not inherited by MCP servers launched via `cmd`. Put `DATABASE_URL` directly in the `env` block of `.mcp.json`.
|
|
175
226
|
|
|
176
|
-
**`password authentication failed`**
|
|
227
|
+
**`password authentication failed`** - Check the username, password, and that the user has `CONNECT` privilege on the database. URL-encode special characters in the password (`@` → `%40`, `#` → `%23`, `/` → `%2F`).
|
|
177
228
|
|
|
178
|
-
**`SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string`**
|
|
229
|
+
**`SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string`** - The password in your connection string is empty or became `null` after URL decoding. Re-check your connection string.
|
|
179
230
|
|
|
180
|
-
**`canceling statement due to statement timeout`**
|
|
231
|
+
**`canceling statement due to statement timeout`** - A single query exceeded `POSTGRES_STATEMENT_TIMEOUT_MS` (default 30s). Increase it, narrow the query with `WHERE`, or add an index. This is working as designed -- the timeout exists so a runaway query cannot hang the agent.
|
|
181
232
|
|
|
182
|
-
**`Write blocked: this server is in read-only mode`**
|
|
233
|
+
**`Write blocked: this server is in read-only mode`** - You asked the agent to write via `pg_query` but `ALLOW_WRITES` is not set. Either add `ALLOW_WRITES=1` to the `env` block of `.mcp.json` and restart your MCP client (dev/test DBs), or - cleaner for production - use a role with `INSERT/UPDATE/DELETE` grants in `DATABASE_URL` and keep `ALLOW_WRITES` unset. See [Configuring access](#configuring-access). Note that `pg_readonly` always rejects writes; if you want writes, the call has to go through `pg_query`.
|
|
183
234
|
|
|
184
|
-
**Connection pool exhaustion with PgBouncer transaction mode or pglite-socket**
|
|
235
|
+
**Connection pool exhaustion with PgBouncer transaction mode or pglite-socket** - These backends don't support concurrent queries on a single connection. Set `POSTGRES_POOL_MAX=1` in the env block.
|
|
185
236
|
|
|
186
|
-
**First query is slow, subsequent queries are fast**
|
|
237
|
+
**First query is slow, subsequent queries are fast** - Expected. The pg driver lazily establishes the first connection; subsequent queries reuse the pool.
|
|
187
238
|
|
|
188
239
|
## Development
|
|
189
240
|
|
package/dist/index.js
CHANGED
|
@@ -36350,7 +36350,7 @@ async function shutdown() {
|
|
|
36350
36350
|
var adminTools = [
|
|
36351
36351
|
{
|
|
36352
36352
|
name: "pg_inspect_locks",
|
|
36353
|
-
description: "Show current lock contention: which sessions are blocked and who is blocking them. Returns blocked PID, blocking PID, lock types, relation being contested, and the queries involved. Use this first when a tool call hangs or the app feels stuck
|
|
36353
|
+
description: "Show current lock contention: which sessions are blocked and who is blocking them. Returns blocked PID, blocking PID, lock types, relation being contested, and the queries involved. Use this first when a tool call hangs or the app feels stuck - it's the fastest way to identify a long-held transaction holding a lock.",
|
|
36354
36354
|
annotations: {
|
|
36355
36355
|
title: "Inspect blocking locks",
|
|
36356
36356
|
readOnlyHint: true,
|
|
@@ -36469,7 +36469,7 @@ var adminTools = [
|
|
|
36469
36469
|
},
|
|
36470
36470
|
{
|
|
36471
36471
|
name: "pg_kill",
|
|
36472
|
-
description: "Cancel a running query (SIGINT-equivalent) or terminate a backend connection (SIGTERM-equivalent) by PID. Find the PID via `pg_health` active_queries or `pg_inspect_locks`. Requires ALLOW_WRITES=1 since this changes database session state. The role in DATABASE_URL must have permission
|
|
36472
|
+
description: "Cancel a running query (SIGINT-equivalent) or terminate a backend connection (SIGTERM-equivalent) by PID. Find the PID via `pg_health` active_queries or `pg_inspect_locks`. Requires ALLOW_WRITES=1 since this changes database session state. The role in DATABASE_URL must have permission - cancelling another user's query needs the `pg_signal_backend` role or superuser. Cancel is graceful; terminate is forceful.",
|
|
36473
36473
|
annotations: {
|
|
36474
36474
|
title: "Cancel or terminate a backend",
|
|
36475
36475
|
readOnlyHint: false,
|
|
@@ -36499,7 +36499,7 @@ var adminTools = [
|
|
|
36499
36499
|
pid,
|
|
36500
36500
|
mode,
|
|
36501
36501
|
signaled,
|
|
36502
|
-
note: signaled ? `Sent ${mode === "terminate" ? "SIGTERM" : "SIGINT"} to backend ${pid}.` : `Signal returned false
|
|
36502
|
+
note: signaled ? `Sent ${mode === "terminate" ? "SIGTERM" : "SIGINT"} to backend ${pid}.` : `Signal returned false - PID ${pid} may not exist, may already be gone, or the current role lacks permission.`
|
|
36503
36503
|
}
|
|
36504
36504
|
};
|
|
36505
36505
|
}
|
|
@@ -36654,7 +36654,7 @@ var adminTools = [
|
|
|
36654
36654
|
},
|
|
36655
36655
|
{
|
|
36656
36656
|
name: "pg_table_bloat",
|
|
36657
|
-
description: "Estimate table bloat (dead tuples + free space) for tables in a schema. Returns live tuples, dead tuples, dead-tuple ratio, last_vacuum / last_autovacuum timestamps, and total relation size. A high dead_ratio with a stale last_autovacuum is a sign a table needs VACUUM. Cheap
|
|
36657
|
+
description: "Estimate table bloat (dead tuples + free space) for tables in a schema. Returns live tuples, dead tuples, dead-tuple ratio, last_vacuum / last_autovacuum timestamps, and total relation size. A high dead_ratio with a stale last_autovacuum is a sign a table needs VACUUM. Cheap - uses `pg_stat_user_tables`, no extensions required.",
|
|
36658
36658
|
annotations: {
|
|
36659
36659
|
title: "Estimate table bloat",
|
|
36660
36660
|
readOnlyHint: true,
|
|
@@ -36664,7 +36664,7 @@ var adminTools = [
|
|
|
36664
36664
|
},
|
|
36665
36665
|
inputSchema: external_exports.object({
|
|
36666
36666
|
schema: external_exports.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36667
|
-
minDeadRatio: external_exports.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include
|
|
36667
|
+
minDeadRatio: external_exports.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include - dead / (live + dead). Default 0.1 = 10%."),
|
|
36668
36668
|
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
36669
36669
|
}),
|
|
36670
36670
|
handler: async (input) => {
|
|
@@ -36755,7 +36755,7 @@ function buildHypopgHooks(indexes) {
|
|
|
36755
36755
|
var explainTools = [
|
|
36756
36756
|
{
|
|
36757
36757
|
name: "pg_explain",
|
|
36758
|
-
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE
|
|
36758
|
+
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE - for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Writes executed during EXPLAIN ANALYZE are always rolled back, so you can inspect a plan for an INSERT/UPDATE/DELETE without persisting the mutation. Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement). Set `hypothetical_indexes` to a list of `{table, columns, using?}` to ask the planner 'what would the plan be if these indexes existed?' -- requires the HypoPG extension (`CREATE EXTENSION hypopg`). The hypothetical indexes are torn down at the end of the call, never touching real disk.",
|
|
36759
36759
|
annotations: {
|
|
36760
36760
|
title: "Explain query plan",
|
|
36761
36761
|
readOnlyHint: false,
|
|
@@ -36800,7 +36800,7 @@ var explainTools = [
|
|
|
36800
36800
|
if (!check2.data?.[0]?.installed) {
|
|
36801
36801
|
return {
|
|
36802
36802
|
ok: false,
|
|
36803
|
-
error: "hypothetical_indexes requires the HypoPG extension. Install with `CREATE EXTENSION hypopg;` (a superuser-equivalent role usually). HypoPG is read-only at the disk level
|
|
36803
|
+
error: "hypothetical_indexes requires the HypoPG extension. Install with `CREATE EXTENSION hypopg;` (a superuser-equivalent role usually). HypoPG is read-only at the disk level - it lives entirely in shared memory."
|
|
36804
36804
|
};
|
|
36805
36805
|
}
|
|
36806
36806
|
}
|
|
@@ -36904,13 +36904,32 @@ var healthTools = [
|
|
|
36904
36904
|
|
|
36905
36905
|
// src/tools/query.ts
|
|
36906
36906
|
var queryTools = [
|
|
36907
|
+
{
|
|
36908
|
+
name: "pg_readonly",
|
|
36909
|
+
description: "Run a SQL statement guaranteed read-only. Always executes inside a `BEGIN READ ONLY` transaction regardless of `ALLOW_WRITES`, so postgres itself rejects any write attempt. Use this whenever the goal is to read - SELECT, EXPLAIN, SHOW, VALUES, WITH ... SELECT, etc. Hosts that gate tools individually (Claude Code permissions, mcp.hosting) can safely auto-allow this one. Use `params` for parameterized queries to avoid SQL injection. Params can be strings, numbers, booleans, null, arrays (for postgres arrays / ANY), or objects (for json/jsonb columns). Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
|
|
36910
|
+
annotations: {
|
|
36911
|
+
title: "Run read-only SQL",
|
|
36912
|
+
readOnlyHint: true,
|
|
36913
|
+
destructiveHint: false,
|
|
36914
|
+
idempotentHint: true,
|
|
36915
|
+
openWorldHint: true
|
|
36916
|
+
},
|
|
36917
|
+
inputSchema: external_exports.object({
|
|
36918
|
+
sql: external_exports.string().min(1).max(1e6).describe("The SQL statement to execute. Hard cap of 1 MB."),
|
|
36919
|
+
params: external_exports.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
36920
|
+
}),
|
|
36921
|
+
handler: async (input) => {
|
|
36922
|
+
const { sql, params } = input;
|
|
36923
|
+
return runReadOnly(sql, params ?? []);
|
|
36924
|
+
}
|
|
36925
|
+
},
|
|
36907
36926
|
{
|
|
36908
36927
|
name: "pg_query",
|
|
36909
|
-
description: "Run a SQL query against the configured PostgreSQL database.
|
|
36928
|
+
description: "Run a SQL query against the configured PostgreSQL database. Writes are gated by the role in `DATABASE_URL` first and `ALLOW_WRITES` second: a role created with `GRANT pg_read_all_data` makes writes server-rejected regardless of `ALLOW_WRITES`, and is the recommended way to scope agent access. `ALLOW_WRITES=1` is a secondary belt-and-braces gate - useful for managed databases where creating a second role is awkward. For read-only access where you want the guarantee in the tool name, prefer `pg_readonly`. Use `params` for parameterized queries to avoid SQL injection. Params can be strings, numbers, booleans, null, arrays (for postgres arrays / ANY), or objects (for json/jsonb columns). Dates and UUIDs can be passed as ISO strings. Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
|
|
36910
36929
|
annotations: {
|
|
36911
36930
|
title: "Run SQL query",
|
|
36912
36931
|
readOnlyHint: false,
|
|
36913
|
-
// conditionally destructive based on ALLOW_WRITES
|
|
36932
|
+
// conditionally destructive based on role + ALLOW_WRITES
|
|
36914
36933
|
destructiveHint: true,
|
|
36915
36934
|
idempotentHint: false,
|
|
36916
36935
|
openWorldHint: true
|
|
@@ -36958,7 +36977,7 @@ var schemaTools = [
|
|
|
36958
36977
|
},
|
|
36959
36978
|
{
|
|
36960
36979
|
name: "pg_list_tables",
|
|
36961
|
-
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`; approximate
|
|
36980
|
+
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`; approximate - 0 until ANALYZE runs). Paginate via `limit`/`offset` on very large schemas.",
|
|
36962
36981
|
annotations: {
|
|
36963
36982
|
title: "List tables in a schema",
|
|
36964
36983
|
readOnlyHint: true,
|
|
@@ -37413,7 +37432,7 @@ var statsTools = [
|
|
|
37413
37432
|
},
|
|
37414
37433
|
{
|
|
37415
37434
|
name: "pg_seq_scan_tables",
|
|
37416
|
-
description: "Tables with high sequential-scan counts relative to index scans
|
|
37435
|
+
description: "Tables with high sequential-scan counts relative to index scans - the first place to look for missing-index candidates. Returns seq_scans, idx_scans, live tuples, and the ratio. A high ratio on a large table usually means a query is reading the whole table where an index would suffice. Pair with `pg_top_queries` to find which query is doing it.",
|
|
37417
37436
|
annotations: {
|
|
37418
37437
|
title: "Find tables with heavy sequential scans",
|
|
37419
37438
|
readOnlyHint: true,
|
|
@@ -37454,7 +37473,7 @@ var statsTools = [
|
|
|
37454
37473
|
},
|
|
37455
37474
|
{
|
|
37456
37475
|
name: "pg_unused_indexes",
|
|
37457
|
-
description: "Indexes that have never been scanned or have very low usage. Each unused index costs write amplification (every INSERT/UPDATE maintains it) and disk space. Excludes primary keys and unique constraints (which are load-bearing even with zero scans). Use this before adding new indexes
|
|
37476
|
+
description: "Indexes that have never been scanned or have very low usage. Each unused index costs write amplification (every INSERT/UPDATE maintains it) and disk space. Excludes primary keys and unique constraints (which are load-bearing even with zero scans). Use this before adding new indexes - sometimes the fix is to drop a dead one.",
|
|
37458
37477
|
annotations: {
|
|
37459
37478
|
title: "Find unused indexes",
|
|
37460
37479
|
readOnlyHint: true,
|
|
@@ -37510,7 +37529,7 @@ function compareVersions(a, b) {
|
|
|
37510
37529
|
}
|
|
37511
37530
|
|
|
37512
37531
|
// src/index.ts
|
|
37513
|
-
var version2 = true ? "0.
|
|
37532
|
+
var version2 = true ? "0.6.0" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
37514
37533
|
var subcommand = process.argv[2];
|
|
37515
37534
|
if (subcommand === "version" || subcommand === "--version") {
|
|
37516
37535
|
console.log(version2);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/postgres-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "PostgreSQL MCP server
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "PostgreSQL MCP server - query, schema introspection, explain, and health checks for AI assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "YawLabs <contact@yaw.sh>",
|
|
7
7
|
"repository": {
|