@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 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 the plan (with real row counts and
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 so the whole filter
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` list views and materialized views with SQL definitions.
382
- - `pg_list_functions` list functions, procedures, and aggregates with signatures.
383
- - `pg_list_extensions` list installed extensions (pgvector, postgis, etc.) with versions.
384
- - `pg_search_columns` find columns by name pattern across all user schemas.
385
- - `pg_top_queries` top N queries by total/mean execution time from
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` show current blocking locks (blocked PID, blocker PID,
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` database roles with login/superuser/createdb/createrole
434
+ - `pg_list_roles` - database roles with login/superuser/createdb/createrole
411
435
  flags and inherited group memberships.
412
- - `pg_table_privileges` who has SELECT/INSERT/UPDATE/DELETE/etc. on a table,
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` tables with heavy sequential scans relative to index
438
+ - `pg_seq_scan_tables` - tables with heavy sequential scans relative to index
415
439
  scans. Missing-index candidates.
416
- - `pg_unused_indexes` non-unique, non-primary indexes with low/zero scan
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` cancel a running query or terminate a backend by PID. Requires
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` estimate dead tuples and vacuum-candidate tables from
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` replication slots, connected replicas with lag,
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` run SQL with read-only-by-default safety. Writes opt in via `ALLOW_WRITES=1`.
456
- - `pg_list_schemas` list non-system schemas.
457
- - `pg_list_tables` list tables (and optionally views) with estimated row counts.
458
- - `pg_describe_table` columns, PK, FKs, indexes.
459
- - `pg_explain` `EXPLAIN` / `EXPLAIN ANALYZE` with text or JSON output.
460
- - `pg_health` server version, db size, connections, active queries, table count.
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
  [![npm version](https://img.shields.io/npm/v/@yawlabs/postgres-mcp)](https://www.npmjs.com/package/@yawlabs/postgres-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
 
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.
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
+ [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](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 a lot of agents are pointed at an unmaintained package.
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) Zed's fork, primarily a security patch on the original shape.
19
- - **Postgres MCP Pro** (Crystal DBA) focused on index tuning and hypothetical-index / buffer-cache diagnostics.
20
- - **AWS Labs Postgres MCP** tied to Aurora / RDS Data API + Secrets Manager.
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** user SQL runs in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes. Opt in with `ALLOW_WRITES=1`.
27
- - **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.
28
- - **Parameterized queries** `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
29
- - **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`.
30
- - **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.
31
- - **`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.
32
- - **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.
33
- - **Health snapshot** `pg_health` returns version, db size, connection counts, and the 10 longest-running active queries in one call.
34
- - **Role and privilege awareness** `pg_list_roles` and `pg_table_privileges` for the common "who can touch what?" questions.
35
- - **Instant startup** ships as a single bundled file with zero runtime dependencies. No multi-minute `node_modules` install on every `npx` cold start.
36
- - **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.
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 for production, leave writes off and use migration tools out-of-band.
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 it's the fastest path from "the app is frozen" to "back up."
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
- | `pg_query` | Run a SQL query. Read-only by default; writes require `ALLOW_WRITES=1`. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
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 missing-index candidates. |
126
- | `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts drop candidates. |
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 VACUUM candidates. |
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 allow DML/DDL via `pg_query` and `pg_explain` ANALYZE of writes. |
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`** 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`.
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`** Check the username, password, and that the user has `CONNECT` privilege on the database. URL-encode special characters in the password (`@` → `%40`, `#` → `%23`, `/` → `%2F`).
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`** The password in your connection string is empty or became `null` after URL decoding. Re-check your connection 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`** 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.
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`** You asked the agent to write but `ALLOW_WRITES` is not set. Add `ALLOW_WRITES=1` to the `env` block of `.mcp.json` and restart your MCP client. Only do this for dev/test DBs.
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** These backends don't support concurrent queries on a single connection. Set `POSTGRES_POOL_MAX=1` in the env block.
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** Expected. The pg driver lazily establishes the first connection; subsequent queries reuse the pool.
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 \u2014 it's the fastest way to identify a long-held transaction holding a lock.",
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 \u2014 cancelling another user's query needs the `pg_signal_backend` role or superuser. Cancel is graceful; terminate is forceful.",
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 \u2014 PID ${pid} may not exist, may already be gone, or the current role lacks permission.`
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 \u2014 uses `pg_stat_user_tables`, no extensions required.",
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 \u2014 dead / (live + dead). Default 0.1 = 10%."),
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 \u2014 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.",
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 \u2014 it lives entirely in shared memory."
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. Read-only by default (query runs in a READ ONLY transaction). Set ALLOW_WRITES=1 in the MCP server env to enable DML/DDL. 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.",
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 \u2014 0 until ANALYZE runs). Paginate via `limit`/`offset` on very large schemas.",
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 \u2014 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.",
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 \u2014 sometimes the fix is to drop a dead one.",
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.5.3" : (await null).createRequire(import.meta.url)("../package.json").version;
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.5.3",
4
- "description": "PostgreSQL MCP server query, schema introspection, explain, and health checks for AI assistants",
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": {