@yawlabs/postgres-mcp 0.3.3 → 0.4.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 ADDED
@@ -0,0 +1,306 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-04-25
11
+
12
+ ### Security
13
+ - User SQL fetches are now memory-bounded by Postgres, not by Node. The
14
+ prior flow had node-pg materialize the entire result set into memory and
15
+ then sliced down to `POSTGRES_MAX_ROWS` for output -- so a payload like
16
+ `SELECT * FROM big1 CROSS JOIN big2` could OOM the MCP process before
17
+ the 30 s `statement_timeout` fired. User SQL now runs through a
18
+ server-side `DECLARE ... NO SCROLL CURSOR FOR ...` + `FETCH MAX_ROWS+1`
19
+ pattern; only the response-sized batch is ever materialized in Node.
20
+ Non-cursorable statements (DDL, DML without RETURNING, utility commands)
21
+ fall back to a direct execute via `SAVEPOINT` so the outer transaction
22
+ stays alive -- those statements never produce a runaway result set
23
+ anyway.
24
+ - User SQL is now sent with `queryMode: 'extended'`, forcing pg to use the
25
+ extended query protocol regardless of whether `params` is empty. The
26
+ extended protocol restricts each request to a single statement, closing
27
+ the stacked-query injection pattern documented by
28
+ [Datadog Security Labs](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/)
29
+ against the now-archived `@modelcontextprotocol/server-postgres`. Without
30
+ this fix, a payload like `SELECT 1; COMMIT; DROP SCHEMA x CASCADE;` passed
31
+ to `pg_query` would escape the `BEGIN READ ONLY` wrapper and run DDL in
32
+ autocommit. Added an integration regression test that asserts the
33
+ multi-statement request is rejected by Postgres.
34
+ - `pg` minimum bumped from `^8.13.0` to `^8.14.0`. The `queryMode: 'extended'`
35
+ option that backs the stacked-query guard above is silently ignored on pg
36
+ 8.13.x -- a fresh `npm install` resolving to that range would have regressed
37
+ the security guard with no visible signal. Today's lockfile pins 8.20.0;
38
+ the range bump prevents future installs from sliding back.
39
+ - `@types/pg` bumped from `^8.11.10` to `^8.20.0` to track pg 8.20.0 runtime.
40
+
41
+ ### Added
42
+ - New `pg_advisor` tool: rolled-up DBA lints in one call. Returns three
43
+ categories of findings -- `sequence_exhaustion` (sequences whose
44
+ last_value is past `seqExhaustionThreshold` of max_value, default 50%;
45
+ the classic "BIGINT, eventually" incident class), `tables_without_primary_key`
46
+ (bloat candidates and a sign of design drift), and
47
+ `public_tables_without_rls` (default `public`; configurable via
48
+ `rlsSchemas`). Use as the "what should I be looking at?" starting
49
+ point and drill into `pg_unused_indexes` / `pg_table_bloat` /
50
+ `pg_seq_scan_tables` for the perf side.
51
+ - `pg_explain` accepts a `hypothetical_indexes` parameter -- list of
52
+ `{table, columns, using?}` -- which asks the planner "what would the
53
+ plan be if these indexes existed?". Requires the
54
+ [HypoPG](https://github.com/HypoPG/hypopg) extension
55
+ (`CREATE EXTENSION hypopg;`); the tool returns a friendly hint pointing
56
+ at that command if HypoPG isn't installed. Indexes are session-scoped
57
+ and torn down at the end of the call via `hypopg_reset()`, so they
58
+ never persist across MCP requests and never touch real disk. Closes
59
+ the biggest competitive gap vs. Crystal DBA's Postgres MCP Pro per
60
+ the comp-landscape audit. WSL setup script now installs the
61
+ `postgresql-${V}-hypopg` package opportunistically; the matrix test
62
+ runs against PG17 and PG18 and verifies the planner switches a Seq
63
+ Scan to an Index/Bitmap scan when a hypothetical index is supplied.
64
+ - `pg_describe_table` now returns four new fields:
65
+ - `referenced_by` -- incoming FKs (other tables whose foreign keys point at
66
+ this one). Answers "what depends on this table?" before a destructive
67
+ change. None of the surveyed competing Postgres MCP servers expose this;
68
+ in psql you'd run `\d+` on every candidate table and squint.
69
+ - `constraints` -- CHECK / UNIQUE non-PK / EXCLUDE constraints with each
70
+ constraint's full definition string from `pg_get_constraintdef()`. PK and
71
+ FK still live in their dedicated fields, so no double-listing.
72
+ - `partition_of` -- when the relation is a partition, the parent
73
+ schema / table.
74
+ - `partitions` -- when the relation is a `partitioned_table`, the list of
75
+ children with their `pg_get_expr()` partition bounds.
76
+ - `pg_query` result `fields` now include `dataTypeName` (e.g. `int4`, `text`,
77
+ `jsonb`) alongside `dataTypeID`. Previously LLMs saw only the OID and had
78
+ to map it themselves. Resolution is process-cached against `pg_type`, with
79
+ a single miss-fill query for any OID introduced by `CREATE TYPE` mid-session.
80
+ - npm tarball now ships `LICENSE`, `README.md`, and `CHANGELOG.md` alongside
81
+ the bundle. Previously the `files` allowlist was `["dist/index.js"]` only,
82
+ so `npm pack` produced a tarball with no docs and no license file -- bad
83
+ for downstream consumers and registry surfaces that read README from the
84
+ tarball rather than the repo.
85
+ - README workflow examples now show three multi-tool sequences (unstick a
86
+ hung app via `pg_inspect_locks` -> `pg_kill`, chase a slow page via
87
+ `pg_top_queries` -> `pg_explain` -> `pg_unused_indexes`, oncall triage via
88
+ `pg_health` -> `pg_inspect_locks` -> `pg_replication_status`). The previous
89
+ example list was single-tool only.
90
+ - New `pg_table_bloat` integration test asserts every returned `dead_ratio`
91
+ is a finite number in `[0, 1]`. Locks down the invariant that the recent
92
+ `dead / (live + dead)` formula change was meant to enforce.
93
+ - New `pg_query` integration tests for the `$1`-without-params error path
94
+ and the result-count-equals-`POSTGRES_MAX_ROWS` boundary (must NOT flag
95
+ `truncated: true`).
96
+ - New `pg_explain` integration tests for `analyze: true` on a SELECT in
97
+ read-only mode (works) and on an INSERT in read-only mode (errors with
98
+ the ALLOW_WRITES hint).
99
+ - npm `keywords` expanded with `agent`, `claude`, `claude-code`, `cursor`,
100
+ `llm` so registry / search engine queries for "postgres mcp claude code"
101
+ surface this package.
102
+ - `POSTGRES_CONNECTION_TIMEOUT_MS` env var (default `10000`). Without it, a
103
+ dead host hangs the first connection attempt until the OS times out
104
+ (~2 minutes on most platforms), and the agent waits the whole time before
105
+ surfacing an error.
106
+ - `pg_describe_table` now returns a `kind` field (`table` / `view` /
107
+ `materialized_view` / `partitioned_table` / `foreign_table`). Previously
108
+ the tool silently accepted views and matviews and returned columns with
109
+ empty `primary_key` / `foreign_keys` / `indexes` -- correct, but an LLM
110
+ couldn't tell whether the relation was writable.
111
+
112
+ ### Changed
113
+ - README now states the supported Postgres versions: tested on 17 and 18,
114
+ expected to work on 13+.
115
+
116
+ ### Changed
117
+ - `pg_top_queries` now returns `calls` and `rows` as JS numbers (cast to
118
+ `float8` in SQL) instead of strings. The previous `::text` cast forced
119
+ consumers to parse `"42"` to use it; the timing fields next to them were
120
+ already numbers, so the response shape was inconsistent. float8 is fine
121
+ -- 2^53 is well above any realistic per-query call/row count.
122
+ - `pg_health` partial-failure shape: failed sub-queries now contribute to a
123
+ top-level `_warnings: string[]` array, and the affected fields stay null
124
+ instead of becoming `{error: "..."}`. Previously a failure of the size
125
+ query made `data.database.size_bytes` resolve to `undefined` with no
126
+ signal -- LLMs couldn't tell "missing" from "errored". This matches the
127
+ `_warnings` convention `pg_describe_table` already uses.
128
+ - `shutdown()` now races `pool.end()` against a 5 s timer. `pool.end()`
129
+ waits for in-flight queries with no upper bound, so a wedged query
130
+ (frozen NFS, network hang) could leave the MCP server appearing stuck on
131
+ exit until the OS reaped the TCP sockets.
132
+ - `pg_table_privileges` description tightened to spell out that omitting
133
+ `table` returns privileges for every table in the schema.
134
+ - README now lists supported PG versions explicitly and groups workflow
135
+ examples by single-tool / multi-tool intent.
136
+
137
+ ### Fixed
138
+ - `pg_table_bloat` now uses `dead / (live + dead)` for `dead_ratio` instead of
139
+ `dead / live`. The previous formula reported `0` for tables with `live = 0`
140
+ even when `dead > 0`, hiding the most-bloated tables (an empty-shell table
141
+ full of dead tuples is the textbook VACUUM target). The new formula is
142
+ bounded `[0, 1]`, behaves correctly at edges, and the `WHERE` filter now
143
+ excludes tables with both counters at 0 entirely. The `minDeadRatio`
144
+ parameter description was updated to match.
145
+ - `npm test` now serializes test files with `--test-concurrency=1`. Unit tests
146
+ in `api.test.ts` and `tools/admin.test.ts` both mutate `process.env`
147
+ (`ALLOW_WRITES`, `POSTGRES_MAX_ROWS`, etc.); under Node's default parallel
148
+ test-file scheduling these races could flap on CI.
149
+ - README integration-suite paragraph corrected: the schema is named
150
+ `test_fixture`, not `postgres_mcp_integration`.
151
+ - `scripts/wsl-test-matrix.sh` derives `REPO_SRC` from its own location instead
152
+ of a hardcoded `/mnt/c/Users/jeff/...` path. Anyone other than the original
153
+ author can now run the matrix from their own clone.
154
+ - `release.sh` creates annotated tags (`git tag -a`) and pushes with
155
+ `--follow-tags` instead of `--tags`. The previous `--tags` form pushed every
156
+ local tag, including any unrelated experimental ones lying around;
157
+ `--follow-tags` only pushes the tag(s) reachable from the commits being
158
+ pushed -- but it ignores lightweight tags, so the tag-creation step had to
159
+ switch to annotated to keep working.
160
+
161
+ ## [0.3.2] - 2026-04-24
162
+
163
+ ### Fixed
164
+ - `pg_explain` with `analyze: true` and `ALLOW_WRITES=1` no longer persists
165
+ writes executed by `EXPLAIN ANALYZE`. Previously the write ran inside a
166
+ `BEGIN; ... COMMIT` transaction, so `pg_explain { analyze: true, sql:
167
+ "INSERT ..." }` would actually insert the row. Now writes run inside a
168
+ `BEGIN; ... ROLLBACK` transaction — the plan (with real row counts and
169
+ timing) comes back but the mutation is rolled back. This matches the user
170
+ expectation when asking for a plan, and the tool description has been
171
+ updated to reflect it.
172
+ - `pg_health` `table_count` now excludes `pg_temp_%` schemas, matching the
173
+ filter in `pg_list_schemas`. The `relkind` filter already masked most
174
+ divergence, but the two queries are now consistent.
175
+ - `pg_seq_scan_tables` ratio column simplified. The previous CASE had a
176
+ branch that only fired when `idx_scan = 0 AND seq_scan = 0` (practically
177
+ unreachable given the table was ordered by `seq_scan DESC`), returning
178
+ `0` and implying a distinction that didn't exist. Now returns `NULL`
179
+ whenever `idx_scan = 0`, which is the meaningful ratio-undefined case.
180
+
181
+ ### Added
182
+ - `process.stdin` `end` handler cleans up the pg pool when the MCP client
183
+ disconnects. Previously the server kept running for up to 60 seconds
184
+ (the pool's idle timeout) after the parent closed the pipe.
185
+ - Shared `src/tools/params.ts` for the `paramValue` zod schema, previously
186
+ duplicated verbatim in `query.ts` and `explain.ts`.
187
+
188
+ ### Infrastructure
189
+ - Integration test suites now share one `before(setupFixtures)` /
190
+ `after(teardownFixtures)` per file via an outer `describe`, instead of
191
+ running DROP/CREATE per inner `describe`. Each file previously reset
192
+ the fixture schema 3-4 times; now it resets once.
193
+
194
+ ## [0.3.1] - 2026-04-22
195
+
196
+ ### Fixed
197
+ - `pg_list_roles` with `includeSystem: false` (the default) now actually
198
+ excludes built-in `pg_*` roles. The previous `LIKE 'pg\_%' ESCAPE '\\'`
199
+ filter ended up as SQL `ESCAPE '\\'` (two backslashes), which Postgres
200
+ rejects since `ESCAPE` requires a single character — so the whole filter
201
+ was silently being dropped. Replaced with `starts_with(rolname, 'pg_')`.
202
+ - `pg_describe_table` foreign-key `columns` and `foreign_columns` are now
203
+ proper JSON arrays. They were previously returned as the raw postgres
204
+ text form (e.g. `"{user_id}"`) because `array_agg(name)` returns `name[]`,
205
+ which node-pg doesn't auto-parse. Cast to `text[]` so the driver parses.
206
+
207
+ ### Infrastructure (main branch CI hygiene; no user-facing changes)
208
+ - `.gitattributes` forces LF line endings in the working tree on every OS,
209
+ so biome's formatter doesn't reject every file on Windows runners after
210
+ git's auto-CRLF conversion.
211
+ - The integration CI job now starts postgres via `docker run` with
212
+ `-c shared_preload_libraries=pg_stat_statements` instead of the `services:`
213
+ block (which passes options to `docker create`, where `-c` means
214
+ --cpu-shares and collided with the postmaster flag).
215
+ - Cross-platform test discovery via `scripts/run-tests.mjs`. `node --test dist`
216
+ hangs on Windows; `dist/**/*.test.js` globs only expand in bash with
217
+ globstar. The wrapper uses `fs.readdirSync({ recursive: true })` (stdlib)
218
+ and passes explicit paths, plus `--test-concurrency=1` for the integration
219
+ suite so fixture-schema setup doesn't race.
220
+
221
+ ## [0.3.0] - 2026-04-22
222
+
223
+ ### Added
224
+ - `pg_list_views` — list views and materialized views with SQL definitions.
225
+ - `pg_list_functions` — list functions, procedures, and aggregates with signatures.
226
+ - `pg_list_extensions` — list installed extensions (pgvector, postgis, etc.) with versions.
227
+ - `pg_search_columns` — find columns by name pattern across all user schemas.
228
+ - `pg_top_queries` — top N queries by total/mean execution time from
229
+ `pg_stat_statements`. Detects extension version and picks the right column
230
+ names (v1.8+ uses `total_exec_time`, older uses `total_time`). Returns clear
231
+ setup instructions if the extension is not installed.
232
+ - `pg_list_tables` now accepts `limit` / `offset` for pagination on large schemas.
233
+ - `pg_health` now accepts `activeQueryLimit` (1–100) to override the default of 10.
234
+ - `pg_query` / `pg_explain` `params` now accept arrays and objects (for
235
+ postgres arrays, `ANY`, and json/jsonb columns) in addition to scalars.
236
+ - `POSTGRES_SSL_REJECT_UNAUTHORIZED` env var to disable TLS cert verification for
237
+ managed databases using private-CA certs (Supabase, Neon, RDS). Documented in
238
+ a new "Connecting to managed Postgres" README section.
239
+ - `pg_describe_table` now surfaces partial failures via a `_warnings` array
240
+ instead of silently collapsing FK/index fetch errors into empty lists.
241
+ - Troubleshooting section in README covering common failure modes (env vars,
242
+ auth, timeouts, write-blocked, pool exhaustion, cold-start latency).
243
+ - CHANGELOG.md.
244
+ - Dependabot config for npm + github-actions (weekly, grouped dev deps).
245
+ - Windows CI matrix (ubuntu + windows × Node 18/20/22).
246
+ - Integration test suite (`npm run test:integration`) that exercises every
247
+ tool against a real Postgres instance. Gated on `POSTGRES_MCP_INTEGRATION=1`
248
+ so local `npm test` stays fast with no DB required. CI runs it on Linux via
249
+ a `postgres:16` service container with `pg_stat_statements` preloaded.
250
+ - `pg_inspect_locks` — show current blocking locks (blocked PID, blocker PID,
251
+ relation, lock type, both queries). First tool to reach for when a session
252
+ hangs or the app feels stuck.
253
+ - `pg_list_roles` — database roles with login/superuser/createdb/createrole
254
+ flags and inherited group memberships.
255
+ - `pg_table_privileges` — who has SELECT/INSERT/UPDATE/DELETE/etc. on a table,
256
+ or on all tables in a schema. Useful for pre-migration audits.
257
+ - `pg_seq_scan_tables` — tables with heavy sequential scans relative to index
258
+ scans. Missing-index candidates.
259
+ - `pg_unused_indexes` — non-unique, non-primary indexes with low/zero scan
260
+ counts. Drop candidates (each unused index costs write amplification).
261
+ - `pg_kill` — cancel a running query or terminate a backend by PID. Requires
262
+ `ALLOW_WRITES=1` since it changes session state. Distinguishes `cancel`
263
+ (SIGINT-equivalent, graceful) from `terminate` (SIGTERM, forceful).
264
+ - `pg_table_bloat` — estimate dead tuples and vacuum-candidate tables from
265
+ `pg_stat_user_tables`. No extensions required.
266
+ - `pg_replication_status` — replication slots, connected replicas with lag,
267
+ and current WAL position. Returns empty arrays on a standalone DB rather
268
+ than erroring, so it's safe to call unconditionally.
269
+ - New "What can an agent do with this?" README section with concrete example
270
+ conversations mapped to tool calls.
271
+
272
+ ### Changed
273
+ - Loosened identifier validation on `pg_list_tables` and `pg_describe_table`.
274
+ Quoted identifiers (e.g. `"My Table"`) now work. Length capped at 63 bytes
275
+ (the postgres limit) via zod schema; the previous regex-based whitelist
276
+ blocked legitimate identifiers.
277
+ - Pool `idleTimeoutMillis` widened 10s → 60s. MCP sessions routinely have
278
+ minute-long gaps; the short timeout was forcing a reconnect on every tool
279
+ call.
280
+ - `pg_query` / `pg_explain` `sql` inputs now hard-capped at 1 MB.
281
+
282
+ ### Fixed
283
+ - `pg_explain` now rejects pre-wrapped SQL (e.g. `"EXPLAIN SELECT 1"`) with a
284
+ clear error instead of producing a `EXPLAIN (...) EXPLAIN SELECT 1` syntax
285
+ error. LLMs frequently make this mistake.
286
+
287
+ ## [0.1.1] - 2026-04-21
288
+
289
+ ### Changed
290
+ - Release workflow: widened npm registry propagation wait from 1 min to 10 min
291
+ to handle occasional stalls observed on initial publish.
292
+
293
+ ## [0.1.0] - 2026-04-21
294
+
295
+ Initial release.
296
+
297
+ ### Added
298
+ - `pg_query` — run SQL with read-only-by-default safety. Writes opt in via `ALLOW_WRITES=1`.
299
+ - `pg_list_schemas` — list non-system schemas.
300
+ - `pg_list_tables` — list tables (and optionally views) with estimated row counts.
301
+ - `pg_describe_table` — columns, PK, FKs, indexes.
302
+ - `pg_explain` — `EXPLAIN` / `EXPLAIN ANALYZE` with text or JSON output.
303
+ - `pg_health` — server version, db size, connections, active queries, table count.
304
+ - Single-file bundled distribution (zero runtime deps) for fast `npx` cold starts.
305
+ - Result row truncation at `POSTGRES_MAX_ROWS` (default 1000).
306
+ - Parameterized queries via `params` on `pg_query` and `pg_explain`.
package/README.md CHANGED
@@ -92,29 +92,34 @@ Prefer scoping this to dev/test databases — for production, leave writes off a
92
92
 
93
93
  ## What can an agent do with this?
94
94
 
95
- Once connected, the agent picks tools automatically based on what you ask. A few real examples:
95
+ Once connected, the agent picks tools automatically based on what you ask. A few single-tool examples:
96
96
 
97
- - **"Describe the users table"** -> `pg_describe_table` -> returns columns, PK, FKs, indexes.
97
+ - **"Describe the users table"** -> `pg_describe_table` -> returns kind, columns, PK, FKs, indexes.
98
98
  - **"Which tables have a `user_id` column?"** -> `pg_search_columns` with pattern `user_id` -> one call instead of iterating every table.
99
99
  - **"This query is slow, why?"** -> `pg_explain` with `analyze: true` -> returns the plan with actual row counts and timing.
100
100
  - **"What's the slowest query we run?"** -> `pg_top_queries` -> returns the top N from `pg_stat_statements` with mean/total/min/max times.
101
- - **"Why is my app hanging?"** -> `pg_inspect_locks` -> returns blocked PIDs and the queries holding their locks; follow up with `pg_kill` (with `ALLOW_WRITES=1`) to cancel the blocker.
102
101
  - **"Do we have any unused indexes?"** -> `pg_unused_indexes` -> returns non-unique, non-primary indexes with zero or low scan counts + their size.
103
102
  - **"Is `pgvector` installed?"** -> `pg_list_extensions` -> yes/no with version.
104
103
 
104
+ The bigger leverage is multi-tool reasoning. A few real workflows:
105
+
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."
107
+ - **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
+ - **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
+
105
110
  ## Tools
106
111
 
107
112
  | Tool | Description |
108
113
  |------|-------------|
109
- | `pg_query` | Run a SQL query. Read-only by default; writes require `ALLOW_WRITES=1`. Supports parameterized queries via `params`. |
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`. |
110
115
  | `pg_list_schemas` | List non-system schemas. |
111
116
  | `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
112
- | `pg_describe_table` | Columns, primary key, foreign keys, and indexes for a table. |
117
+ | `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
113
118
  | `pg_list_views` | List views and materialized views in a schema, including their SQL definitions. |
114
119
  | `pg_list_functions` | List functions, procedures, and aggregates in a schema with signatures and return types. |
115
120
  | `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
116
121
  | `pg_search_columns` | Find columns by name pattern across all user schemas. Case-insensitive, supports SQL LIKE wildcards. |
117
- | `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. |
122
+ | `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. |
118
123
  | `pg_health` | Server version, database size, connection count, active queries, table count. |
119
124
  | `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. |
120
125
  | `pg_seq_scan_tables` | Tables with heavy sequential scans — missing-index candidates. |
@@ -124,6 +129,7 @@ Once connected, the agent picks tools automatically based on what you ask. A few
124
129
  | `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
125
130
  | `pg_table_bloat` | Tables with high dead-tuple ratios — VACUUM candidates. |
126
131
  | `pg_replication_status` | Replication slots, connected replicas, and current WAL position. |
132
+ | `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. |
127
133
  | `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
128
134
 
129
135
  ## Configuration
@@ -135,10 +141,15 @@ All env vars are read from the MCP server's environment:
135
141
  | `DATABASE_URL` | (required) | PostgreSQL connection string. |
136
142
  | `ALLOW_WRITES` | unset | Set to `1` or `true` to allow DML/DDL via `pg_query` and `pg_explain` ANALYZE of writes. |
137
143
  | `POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-statement timeout. |
144
+ | `POSTGRES_CONNECTION_TIMEOUT_MS` | `10000` | TCP connect timeout. Without this, a dead host hangs until the OS gives up (~2 minutes). |
138
145
  | `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
139
146
  | `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
140
147
  | `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
141
148
 
149
+ ### Supported Postgres versions
150
+
151
+ Tested on **PostgreSQL 17 and 18** in CI. Should work on PG13+ -- a few tools (`pg_replication_status` reading `wal_status`, `pg_top_queries` reading `*_exec_time`) rely on columns that landed in PG13. PG12 and below are out of upstream support and not exercised here.
152
+
142
153
  ### Connecting to managed Postgres (Supabase, Neon, RDS, etc.)
143
154
 
144
155
  Most managed databases require TLS but serve certs signed by a private CA that Node's default trust store doesn't recognize. The symptom is one of:
@@ -174,6 +185,32 @@ This disables certificate chain verification only -- the TCP connection is still
174
185
 
175
186
  **First query is slow, subsequent queries are fast** — Expected. The pg driver lazily establishes the first connection; subsequent queries reuse the pool.
176
187
 
188
+ ## Development
189
+
190
+ Run the full suite (unit + integration) against a real Postgres:
191
+
192
+ ```bash
193
+ DATABASE_URL='postgres://user:pass@host:5432/db' POSTGRES_MCP_INTEGRATION=1 npm run test:integration
194
+ ```
195
+
196
+ The integration suite assumes a disposable database -- it creates and drops a `test_fixture` schema. Don't point it at anything you care about.
197
+
198
+ ### Windows: integration tests via WSL2
199
+
200
+ Native Postgres on Windows ARM64 is fragile (UCRT runtime gaps, missing ARM64 builds). The reliable path is a disposable Ubuntu under WSL2 with the integration suite running inside WSL (WSL2's NAT blocks the Windows host from reaching :5432, so don't try to run the tests from PowerShell):
201
+
202
+ ```powershell
203
+ wsl --install -d Ubuntu --no-launch
204
+ # reboot, then:
205
+ wsl -d Ubuntu -u root bash -c "apt-get update && apt-get install -y nodejs npm rsync"
206
+ wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-pg-setup.sh
207
+ wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-test-matrix.sh
208
+ ```
209
+
210
+ `wsl-pg-setup.sh` installs PG17 and PG18 from the PGDG apt repo on ports 5432 and 5433, sets the `postgres` password to `postgres`, and creates `postgres_mcp_test` in each. `wsl-test-matrix.sh` rsyncs the working tree into `/root/postgres-mcp`, runs `npm ci` once, and runs the integration suite against every cluster found via `pg_lsclusters`.
211
+
212
+ Tear down when finished: `wsl --unregister Ubuntu`.
213
+
177
214
  ## License
178
215
 
179
216
  MIT © 2026 YawLabs
package/dist/index.js CHANGED
@@ -35255,6 +35255,12 @@ function getStatementTimeoutMs() {
35255
35255
  const parsed = Number(raw);
35256
35256
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 3e4;
35257
35257
  }
35258
+ function getConnectionTimeoutMs() {
35259
+ const raw = process.env.POSTGRES_CONNECTION_TIMEOUT_MS;
35260
+ if (!raw) return 1e4;
35261
+ const parsed = Number(raw);
35262
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e4;
35263
+ }
35258
35264
  function getMaxRows() {
35259
35265
  const raw = process.env.POSTGRES_MAX_ROWS;
35260
35266
  if (!raw) return 1e3;
@@ -35284,6 +35290,7 @@ function getPool() {
35284
35290
  pool = new esm_default.Pool({
35285
35291
  connectionString: getDatabaseUrl(),
35286
35292
  statement_timeout: getStatementTimeoutMs(),
35293
+ connectionTimeoutMillis: getConnectionTimeoutMs(),
35287
35294
  max: getPoolMax(),
35288
35295
  // MCP sessions can have minutes-long gaps between tool calls. A short
35289
35296
  // idleTimeout forces a reconnect on every tool call. 60s keeps the pool
@@ -35296,6 +35303,29 @@ function getPool() {
35296
35303
  });
35297
35304
  return pool;
35298
35305
  }
35306
+ var typeNameCache = null;
35307
+ async function resolveTypeNames(client, oids) {
35308
+ if (oids.length === 0) return {};
35309
+ if (!typeNameCache) {
35310
+ typeNameCache = /* @__PURE__ */ new Map();
35311
+ const res = await client.query("SELECT oid, typname FROM pg_catalog.pg_type");
35312
+ for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
35313
+ }
35314
+ const missing = oids.filter((o) => !typeNameCache?.has(o));
35315
+ if (missing.length > 0) {
35316
+ const res = await client.query(
35317
+ "SELECT oid, typname FROM pg_catalog.pg_type WHERE oid = ANY($1)",
35318
+ [missing]
35319
+ );
35320
+ for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
35321
+ }
35322
+ const out = {};
35323
+ for (const oid of oids) {
35324
+ const n = typeNameCache.get(oid);
35325
+ if (n !== void 0) out[oid] = n;
35326
+ }
35327
+ return out;
35328
+ }
35299
35329
  function formatPgError(err) {
35300
35330
  if (!(err instanceof Error)) return String(err);
35301
35331
  const errObj = err;
@@ -35308,25 +35338,55 @@ function formatPgError(err) {
35308
35338
  }
35309
35339
  return parts.join(" ");
35310
35340
  }
35311
- function toQueryResult(result, maxRows) {
35341
+ async function runUserQueryBounded(client, sql, params, maxRows) {
35342
+ await client.query("SAVEPOINT __pgmcp_sp");
35343
+ try {
35344
+ await client.query({
35345
+ text: `DECLARE __pgmcp_cur NO SCROLL CURSOR FOR ${sql}`,
35346
+ values: params,
35347
+ queryMode: "extended"
35348
+ });
35349
+ const fetched = await client.query(`FETCH ${maxRows + 1} FROM __pgmcp_cur`);
35350
+ try {
35351
+ await client.query("CLOSE __pgmcp_cur");
35352
+ } catch {
35353
+ }
35354
+ await client.query("RELEASE SAVEPOINT __pgmcp_sp");
35355
+ return fetched;
35356
+ } catch {
35357
+ await client.query("ROLLBACK TO SAVEPOINT __pgmcp_sp");
35358
+ await client.query("RELEASE SAVEPOINT __pgmcp_sp");
35359
+ return client.query({
35360
+ text: sql,
35361
+ values: params,
35362
+ queryMode: "extended"
35363
+ });
35364
+ }
35365
+ }
35366
+ function toQueryResult(result, maxRows, typeNames = {}) {
35312
35367
  const truncated = result.rows.length > maxRows;
35313
35368
  const rows = truncated ? result.rows.slice(0, maxRows) : result.rows;
35314
35369
  return {
35315
35370
  rows,
35316
35371
  rowCount: result.rowCount,
35317
- fields: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
35372
+ fields: result.fields.map((f) => {
35373
+ const name = typeNames[f.dataTypeID];
35374
+ return name !== void 0 ? { name: f.name, dataTypeID: f.dataTypeID, dataTypeName: name } : { name: f.name, dataTypeID: f.dataTypeID };
35375
+ }),
35318
35376
  command: result.command,
35319
35377
  ...truncated ? { truncated: true } : {}
35320
35378
  };
35321
35379
  }
35322
- async function runReadOnly(sql, params = []) {
35380
+ async function runReadOnly(sql, params = [], hooks = {}) {
35323
35381
  const client = await getPool().connect();
35324
35382
  const maxRows = getMaxRows();
35325
35383
  try {
35326
35384
  await client.query("BEGIN READ ONLY");
35327
- const result = await client.query({ text: sql, values: params, queryMode: "extended" });
35385
+ if (hooks.setup) await hooks.setup(client);
35386
+ const result = await runUserQueryBounded(client, sql, params, maxRows);
35328
35387
  await client.query("ROLLBACK");
35329
- return { ok: true, data: toQueryResult(result, maxRows) };
35388
+ const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
35389
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
35330
35390
  } catch (err) {
35331
35391
  try {
35332
35392
  await client.query("ROLLBACK");
@@ -35334,6 +35394,12 @@ async function runReadOnly(sql, params = []) {
35334
35394
  }
35335
35395
  return { ok: false, error: formatPgError(err) };
35336
35396
  } finally {
35397
+ if (hooks.teardown) {
35398
+ try {
35399
+ await hooks.teardown(client);
35400
+ } catch {
35401
+ }
35402
+ }
35337
35403
  client.release();
35338
35404
  }
35339
35405
  }
@@ -35348,9 +35414,10 @@ async function runReadWrite(sql, params = []) {
35348
35414
  const maxRows = getMaxRows();
35349
35415
  try {
35350
35416
  await client.query("BEGIN");
35351
- const result = await client.query({ text: sql, values: params, queryMode: "extended" });
35417
+ const result = await runUserQueryBounded(client, sql, params, maxRows);
35352
35418
  await client.query("COMMIT");
35353
- return { ok: true, data: toQueryResult(result, maxRows) };
35419
+ const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
35420
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
35354
35421
  } catch (err) {
35355
35422
  try {
35356
35423
  await client.query("ROLLBACK");
@@ -35361,7 +35428,7 @@ async function runReadWrite(sql, params = []) {
35361
35428
  client.release();
35362
35429
  }
35363
35430
  }
35364
- async function runReadWriteRollback(sql, params = []) {
35431
+ async function runReadWriteRollback(sql, params = [], hooks = {}) {
35365
35432
  if (!isWritesAllowed()) {
35366
35433
  return {
35367
35434
  ok: false,
@@ -35372,9 +35439,11 @@ async function runReadWriteRollback(sql, params = []) {
35372
35439
  const maxRows = getMaxRows();
35373
35440
  try {
35374
35441
  await client.query("BEGIN");
35375
- const result = await client.query({ text: sql, values: params, queryMode: "extended" });
35442
+ if (hooks.setup) await hooks.setup(client);
35443
+ const result = await runUserQueryBounded(client, sql, params, maxRows);
35376
35444
  await client.query("ROLLBACK");
35377
- return { ok: true, data: toQueryResult(result, maxRows) };
35445
+ const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
35446
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
35378
35447
  } catch (err) {
35379
35448
  try {
35380
35449
  await client.query("ROLLBACK");
@@ -35382,6 +35451,12 @@ async function runReadWriteRollback(sql, params = []) {
35382
35451
  }
35383
35452
  return { ok: false, error: formatPgError(err) };
35384
35453
  } finally {
35454
+ if (hooks.teardown) {
35455
+ try {
35456
+ await hooks.teardown(client);
35457
+ } catch {
35458
+ }
35459
+ }
35385
35460
  client.release();
35386
35461
  }
35387
35462
  }
@@ -35394,9 +35469,16 @@ async function runInternal(sql, params = []) {
35394
35469
  }
35395
35470
  }
35396
35471
  async function shutdown() {
35397
- if (pool) {
35398
- await pool.end();
35399
- pool = null;
35472
+ typeNameCache = null;
35473
+ if (!pool) return;
35474
+ const ending = pool;
35475
+ pool = null;
35476
+ try {
35477
+ await Promise.race([
35478
+ ending.end(),
35479
+ new Promise((_, reject) => setTimeout(() => reject(new Error("pool shutdown timed out after 5s")), 5e3))
35480
+ ]);
35481
+ } catch {
35400
35482
  }
35401
35483
  }
35402
35484
 
@@ -35490,7 +35572,7 @@ var adminTools = [
35490
35572
  },
35491
35573
  {
35492
35574
  name: "pg_table_privileges",
35493
- description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on all tables in a schema. Use this to answer 'who can write to this table?' or to audit schema-wide access before a migration.",
35575
+ description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on every table in a schema. If `table` is omitted, the result spans every table in `schema`, ordered by table then grantee. Use this to answer 'who can write to this table?' or to audit schema-wide access before a migration.",
35494
35576
  annotations: {
35495
35577
  title: "Show table privileges",
35496
35578
  readOnlyHint: true,
@@ -35615,6 +35697,87 @@ var adminTools = [
35615
35697
  };
35616
35698
  }
35617
35699
  },
35700
+ {
35701
+ name: "pg_advisor",
35702
+ description: "Rolled-up DBA lint pass. One call returns three categories of findings:\n- sequence_exhaustion: SERIAL / BIGSERIAL / IDENTITY sequences whose `last_value` is above `seqExhaustionThreshold` of `max_value`. The classic incident class.\n- tables_without_primary_key: user tables with no PK. Bloat candidates and a sign of design drift; some replication setups also need PKs.\n- public_tables_without_rls: tables in `public` (or any schema in `rlsSchemas`) with row-level security disabled. Useful as a security baseline check.\nUse this as the 'what should I be looking at?' starting point, then drill into `pg_unused_indexes`, `pg_table_bloat`, `pg_seq_scan_tables` for the perf side.",
35703
+ annotations: {
35704
+ title: "Database advisor (DBA lints)",
35705
+ readOnlyHint: true,
35706
+ destructiveHint: false,
35707
+ idempotentHint: true,
35708
+ openWorldHint: true
35709
+ },
35710
+ inputSchema: external_exports3.object({
35711
+ seqExhaustionThreshold: external_exports3.number().min(0).max(1).default(0.5).describe("Minimum used-fraction (last_value / max_value) to flag a sequence (default 0.5 = 50%)."),
35712
+ rlsSchemas: external_exports3.array(external_exports3.string().min(1).max(63)).default(["public"]).describe("Schemas where RLS-missing should be flagged. Defaults to ['public']."),
35713
+ limit: external_exports3.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
35714
+ }),
35715
+ handler: async (input) => {
35716
+ const { seqExhaustionThreshold, rlsSchemas, limit } = input;
35717
+ const [seqRes, noPkRes, rlsRes] = await Promise.all([
35718
+ runInternal(
35719
+ // pg_sequences was added in PG10. last_value can be NULL on a never-
35720
+ // touched sequence; we filter those out (nothing to report yet).
35721
+ `SELECT
35722
+ schemaname AS schema,
35723
+ sequencename AS sequence,
35724
+ last_value::text AS last_value,
35725
+ max_value::text AS max_value,
35726
+ (last_value::float8 / NULLIF(max_value::float8, 0))::numeric(6, 4)::float8 AS pct_used
35727
+ FROM pg_catalog.pg_sequences
35728
+ WHERE last_value IS NOT NULL
35729
+ AND max_value > 0
35730
+ AND (last_value::float8 / max_value::float8) >= $1
35731
+ ORDER BY pct_used DESC NULLS LAST
35732
+ LIMIT $2`,
35733
+ [seqExhaustionThreshold, limit]
35734
+ ),
35735
+ runInternal(
35736
+ `SELECT
35737
+ n.nspname AS schema,
35738
+ c.relname AS "table"
35739
+ FROM pg_catalog.pg_class c
35740
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35741
+ WHERE c.relkind = 'r'
35742
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
35743
+ AND n.nspname NOT LIKE 'pg_%'
35744
+ AND NOT EXISTS (
35745
+ SELECT 1 FROM pg_catalog.pg_index i
35746
+ WHERE i.indrelid = c.oid AND i.indisprimary
35747
+ )
35748
+ ORDER BY n.nspname, c.relname
35749
+ LIMIT $1`,
35750
+ [limit]
35751
+ ),
35752
+ runInternal(
35753
+ `SELECT
35754
+ n.nspname AS schema,
35755
+ c.relname AS "table"
35756
+ FROM pg_catalog.pg_class c
35757
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35758
+ WHERE c.relkind IN ('r', 'p')
35759
+ AND n.nspname = ANY($1)
35760
+ AND NOT c.relrowsecurity
35761
+ ORDER BY n.nspname, c.relname
35762
+ LIMIT $2`,
35763
+ [rlsSchemas, limit]
35764
+ )
35765
+ ]);
35766
+ const warnings = [];
35767
+ if (!seqRes.ok) warnings.push(`sequence_exhaustion fetch failed: ${seqRes.error}`);
35768
+ if (!noPkRes.ok) warnings.push(`tables_without_primary_key fetch failed: ${noPkRes.error}`);
35769
+ if (!rlsRes.ok) warnings.push(`public_tables_without_rls fetch failed: ${rlsRes.error}`);
35770
+ return {
35771
+ ok: true,
35772
+ data: {
35773
+ sequence_exhaustion: seqRes.ok ? seqRes.data : [],
35774
+ tables_without_primary_key: noPkRes.ok ? noPkRes.data : [],
35775
+ public_tables_without_rls: rlsRes.ok ? rlsRes.data : [],
35776
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
35777
+ }
35778
+ };
35779
+ }
35780
+ },
35618
35781
  {
35619
35782
  name: "pg_table_bloat",
35620
35783
  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.",
@@ -35627,7 +35790,7 @@ var adminTools = [
35627
35790
  },
35628
35791
  inputSchema: external_exports3.object({
35629
35792
  schema: external_exports3.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
35630
- minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead/live ratio to include (default 0.1 = 10%)."),
35793
+ minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include \u2014 dead / (live + dead). Default 0.1 = 10%."),
35631
35794
  limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
35632
35795
  }),
35633
35796
  handler: async (input) => {
@@ -35636,22 +35799,23 @@ var adminTools = [
35636
35799
  const params = [minDeadRatio, limit];
35637
35800
  if (schema) params.push(schema);
35638
35801
  return runInternal(
35802
+ // dead_ratio = dead / (live + dead): bounded [0, 1]. A 100%-dead table
35803
+ // (live=0, dead>0) correctly reports 1.0 instead of 0. Tables with both
35804
+ // counters at 0 are filtered out -- nothing to report.
35639
35805
  `SELECT
35640
35806
  schemaname AS schema,
35641
35807
  relname AS "table",
35642
35808
  n_live_tup::text AS live_tuples,
35643
35809
  n_dead_tup::text AS dead_tuples,
35644
- CASE
35645
- WHEN n_live_tup = 0 THEN 0
35646
- ELSE (n_dead_tup::float8 / GREATEST(n_live_tup, 1))::numeric(6, 3)::float8
35647
- END AS dead_ratio,
35810
+ (n_dead_tup::float8 / (n_live_tup + n_dead_tup))::numeric(6, 3)::float8 AS dead_ratio,
35648
35811
  pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty,
35649
35812
  pg_total_relation_size(relid)::text AS size_bytes,
35650
35813
  last_vacuum::text AS last_vacuum,
35651
35814
  last_autovacuum::text AS last_autovacuum,
35652
35815
  last_analyze::text AS last_analyze
35653
35816
  FROM pg_catalog.pg_stat_user_tables
35654
- WHERE (n_dead_tup::float8 / GREATEST(n_live_tup, 1)) >= $1
35817
+ WHERE (n_live_tup + n_dead_tup) > 0
35818
+ AND (n_dead_tup::float8 / (n_live_tup + n_dead_tup)) >= $1
35655
35819
  ${schemaFilter}
35656
35820
  ORDER BY n_dead_tup DESC
35657
35821
  LIMIT $2`,
@@ -35667,10 +35831,43 @@ var paramValue = external_exports3.lazy(
35667
35831
  );
35668
35832
 
35669
35833
  // src/tools/explain.ts
35834
+ var indexAccessMethod = external_exports3.enum(["btree", "hash", "gin", "gist", "brin", "spgist"]);
35835
+ var hypotheticalIndex = external_exports3.object({
35836
+ table: external_exports3.string().min(1).max(127).describe("Target table. Use `schema.table` (e.g. `public.users`) or just `table` for the search_path."),
35837
+ columns: external_exports3.array(external_exports3.string().min(1).max(63)).min(1).describe("Column names in index order. Quoted identifiers are not supported here -- pass plain names."),
35838
+ using: indexAccessMethod.default("btree").describe("Index access method. btree is the right answer for almost every query.")
35839
+ });
35840
+ function quoteIdent(name) {
35841
+ return `"${name.replace(/"/g, '""')}"`;
35842
+ }
35843
+ function quoteQualifiedTable(name) {
35844
+ return name.split(".").map((p) => quoteIdent(p)).join(".");
35845
+ }
35846
+ function buildHypopgHooks(indexes) {
35847
+ return {
35848
+ setup: async (client) => {
35849
+ for (const idx of indexes) {
35850
+ const cols = idx.columns.map(quoteIdent).join(", ");
35851
+ const tbl = quoteQualifiedTable(idx.table);
35852
+ const createSql = `CREATE INDEX ON ${tbl} USING ${idx.using} (${cols})`;
35853
+ const r = await client.query(
35854
+ "SELECT (hypopg_create_index($1)).indexname AS indexname",
35855
+ [createSql]
35856
+ );
35857
+ if (!r.rows[0]?.indexname) {
35858
+ throw new Error(`hypopg_create_index returned no index for: ${createSql}`);
35859
+ }
35860
+ }
35861
+ },
35862
+ teardown: async (client) => {
35863
+ await client.query("SELECT hypopg_reset()");
35864
+ }
35865
+ };
35866
+ }
35670
35867
  var explainTools = [
35671
35868
  {
35672
35869
  name: "pg_explain",
35673
- 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).",
35870
+ 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.",
35674
35871
  annotations: {
35675
35872
  title: "Explain query plan",
35676
35873
  readOnlyHint: false,
@@ -35682,10 +35879,13 @@ var explainTools = [
35682
35879
  sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to explain. Do NOT prefix with EXPLAIN."),
35683
35880
  analyze: external_exports3.boolean().default(false).describe("Run EXPLAIN ANALYZE (actually executes the query)."),
35684
35881
  format: external_exports3.enum(["text", "json"]).default("text").describe("Output format."),
35685
- params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
35882
+ params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL."),
35883
+ hypothetical_indexes: external_exports3.array(hypotheticalIndex).optional().describe(
35884
+ "List of indexes the planner should pretend exist for this EXPLAIN. Requires the HypoPG extension. Indexes are session-scoped and reset at the end of the call."
35885
+ )
35686
35886
  }),
35687
35887
  handler: async (input) => {
35688
- const { sql, analyze, format, params } = input;
35888
+ const { sql, analyze, format, params, hypothetical_indexes } = input;
35689
35889
  if (/^\s*EXPLAIN\b/i.test(sql)) {
35690
35890
  return {
35691
35891
  ok: false,
@@ -35696,7 +35896,23 @@ var explainTools = [
35696
35896
  if (analyze) flags.push("ANALYZE");
35697
35897
  if (format === "json") flags.push("FORMAT JSON");
35698
35898
  const explainSql = flags.length > 0 ? `EXPLAIN (${flags.join(", ")}) ${sql}` : `EXPLAIN ${sql}`;
35699
- const result = analyze && isWritesAllowed() ? await runReadWriteRollback(explainSql, params ?? []) : await runReadOnly(explainSql, params ?? []);
35899
+ const hypoIndexes = hypothetical_indexes ?? [];
35900
+ const hooks = hypoIndexes.length > 0 ? buildHypopgHooks(hypoIndexes) : {};
35901
+ if (hypoIndexes.length > 0) {
35902
+ const check2 = await runInternal(
35903
+ `SELECT EXISTS (
35904
+ SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'hypopg'
35905
+ ) AS installed`
35906
+ );
35907
+ if (!check2.ok) return check2;
35908
+ if (!check2.data?.[0]?.installed) {
35909
+ return {
35910
+ ok: false,
35911
+ 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."
35912
+ };
35913
+ }
35914
+ }
35915
+ const result = analyze && isWritesAllowed() ? await runReadWriteRollback(explainSql, params ?? [], hooks) : await runReadOnly(explainSql, params ?? [], hooks);
35700
35916
  if (!result.ok) return result;
35701
35917
  const data = result.data;
35702
35918
  const rows = data?.rows ?? [];
@@ -35771,15 +35987,21 @@ var healthTools = [
35771
35987
  )
35772
35988
  ]);
35773
35989
  if (!versionRes.ok) return versionRes;
35990
+ const warnings = [];
35991
+ if (!sizeRes.ok) warnings.push(`database fetch failed: ${sizeRes.error}`);
35992
+ if (!connsRes.ok) warnings.push(`connections fetch failed: ${connsRes.error}`);
35993
+ if (!activeRes.ok) warnings.push(`active_queries fetch failed: ${activeRes.error}`);
35994
+ if (!tableCountRes.ok) warnings.push(`table_count fetch failed: ${tableCountRes.error}`);
35774
35995
  return {
35775
35996
  ok: true,
35776
35997
  data: {
35777
35998
  connected: true,
35778
35999
  version: versionRes.data?.[0]?.version,
35779
- database: sizeRes.ok ? sizeRes.data?.[0] : { error: sizeRes.error },
35780
- connections: connsRes.ok ? connsRes.data?.[0] : { error: connsRes.error },
36000
+ database: sizeRes.ok ? sizeRes.data?.[0] : null,
36001
+ connections: connsRes.ok ? connsRes.data?.[0] : null,
35781
36002
  active_queries: activeRes.ok ? activeRes.data : [],
35782
- table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null
36003
+ table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null,
36004
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
35783
36005
  }
35784
36006
  };
35785
36007
  }
@@ -35883,7 +36105,7 @@ var schemaTools = [
35883
36105
  },
35884
36106
  {
35885
36107
  name: "pg_describe_table",
35886
- description: "Describe a table: columns (name, type, nullable, default), primary key, foreign keys, and indexes.",
36108
+ description: "Describe a relation: kind (table / view / materialized_view / partitioned_table / foreign_table), columns (name, type, nullable, default), primary key, foreign keys (outgoing), `referenced_by` (other tables whose FKs point at this one), `constraints` (CHECK / UNIQUE non-PK / EXCLUDE), indexes, and partition info (`partition_of` parent, `partitions` children). Works on views and materialized views too -- PK/FK/constraint/index lists will simply be empty for a plain view. Use `kind` to disambiguate before assuming you can write to the relation.",
35887
36109
  annotations: {
35888
36110
  title: "Describe table",
35889
36111
  readOnlyHint: true,
@@ -35897,6 +36119,20 @@ var schemaTools = [
35897
36119
  }),
35898
36120
  handler: async (input) => {
35899
36121
  const { schema, table } = input;
36122
+ const kindQuery = `
36123
+ SELECT
36124
+ CASE c.relkind
36125
+ WHEN 'r' THEN 'table'
36126
+ WHEN 'p' THEN 'partitioned_table'
36127
+ WHEN 'v' THEN 'view'
36128
+ WHEN 'm' THEN 'materialized_view'
36129
+ WHEN 'f' THEN 'foreign_table'
36130
+ ELSE c.relkind::text
36131
+ END AS kind
36132
+ FROM pg_catalog.pg_class c
36133
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36134
+ WHERE n.nspname = $1 AND c.relname = $2
36135
+ `;
35900
36136
  const columnsQuery = `
35901
36137
  SELECT
35902
36138
  a.attname AS name,
@@ -35961,29 +36197,113 @@ var schemaTools = [
35961
36197
  AND c.relname = $2
35962
36198
  ORDER BY i.relname
35963
36199
  `;
35964
- const [cols, pk, fks, idxs] = await Promise.all([
36200
+ const constraintsQuery = `
36201
+ SELECT
36202
+ con.conname AS name,
36203
+ CASE con.contype
36204
+ WHEN 'c' THEN 'check'
36205
+ WHEN 'u' THEN 'unique'
36206
+ WHEN 'x' THEN 'exclude'
36207
+ ELSE con.contype::text
36208
+ END AS type,
36209
+ pg_catalog.pg_get_constraintdef(con.oid, true) AS definition
36210
+ FROM pg_catalog.pg_constraint con
36211
+ JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
36212
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36213
+ WHERE n.nspname = $1
36214
+ AND c.relname = $2
36215
+ AND con.contype IN ('c', 'u', 'x')
36216
+ ORDER BY con.contype, con.conname
36217
+ `;
36218
+ const referencedByQuery = `
36219
+ SELECT
36220
+ con.conname AS constraint_name,
36221
+ srcn.nspname AS schema,
36222
+ src.relname AS "table",
36223
+ array_agg(srcatt.attname::text ORDER BY u.attposition) AS columns,
36224
+ array_agg(refatt.attname::text ORDER BY u.attposition) AS referenced_columns
36225
+ FROM pg_catalog.pg_constraint con
36226
+ JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
36227
+ JOIN pg_catalog.pg_namespace srcn ON srcn.oid = src.relnamespace
36228
+ JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid
36229
+ JOIN pg_catalog.pg_namespace refn ON refn.oid = ref.relnamespace
36230
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
36231
+ JOIN pg_catalog.pg_attribute srcatt ON srcatt.attrelid = con.conrelid AND srcatt.attnum = u.attnum
36232
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
36233
+ JOIN pg_catalog.pg_attribute refatt ON refatt.attrelid = con.confrelid AND refatt.attnum = fu.attnum
36234
+ WHERE refn.nspname = $1
36235
+ AND ref.relname = $2
36236
+ AND con.contype = 'f'
36237
+ GROUP BY con.conname, srcn.nspname, src.relname
36238
+ ORDER BY srcn.nspname, src.relname, con.conname
36239
+ `;
36240
+ const partitionParentQuery = `
36241
+ SELECT
36242
+ parn.nspname AS schema,
36243
+ par.relname AS "table"
36244
+ FROM pg_catalog.pg_inherits i
36245
+ JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid
36246
+ JOIN pg_catalog.pg_namespace childn ON childn.oid = child.relnamespace
36247
+ JOIN pg_catalog.pg_class par ON par.oid = i.inhparent
36248
+ JOIN pg_catalog.pg_namespace parn ON parn.oid = par.relnamespace
36249
+ WHERE childn.nspname = $1
36250
+ AND child.relname = $2
36251
+ AND child.relispartition
36252
+ `;
36253
+ const partitionChildrenQuery = `
36254
+ SELECT
36255
+ childn.nspname AS schema,
36256
+ child.relname AS "table",
36257
+ pg_catalog.pg_get_expr(child.relpartbound, child.oid) AS bound
36258
+ FROM pg_catalog.pg_inherits i
36259
+ JOIN pg_catalog.pg_class par ON par.oid = i.inhparent
36260
+ JOIN pg_catalog.pg_namespace parn ON parn.oid = par.relnamespace
36261
+ JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid
36262
+ JOIN pg_catalog.pg_namespace childn ON childn.oid = child.relnamespace
36263
+ WHERE parn.nspname = $1
36264
+ AND par.relname = $2
36265
+ AND child.relispartition
36266
+ ORDER BY childn.nspname, child.relname
36267
+ `;
36268
+ const [kindRes, cols, pk, fks, idxs, constraints, referencedBy, partitionParent, partitionChildren] = await Promise.all([
36269
+ runInternal(kindQuery, [schema, table]),
35965
36270
  runInternal(columnsQuery, [schema, table]),
35966
36271
  runInternal(primaryKeyQuery, [schema, table]),
35967
36272
  runInternal(foreignKeysQuery, [schema, table]),
35968
- runInternal(indexesQuery, [schema, table])
36273
+ runInternal(indexesQuery, [schema, table]),
36274
+ runInternal(constraintsQuery, [schema, table]),
36275
+ runInternal(referencedByQuery, [schema, table]),
36276
+ runInternal(partitionParentQuery, [schema, table]),
36277
+ runInternal(partitionChildrenQuery, [schema, table])
35969
36278
  ]);
35970
36279
  if (!cols.ok) return cols;
35971
36280
  if (!cols.data || cols.data.length === 0) {
35972
36281
  return { ok: false, error: `Table "${schema}"."${table}" not found.` };
35973
36282
  }
36283
+ const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
35974
36284
  const warnings = [];
35975
36285
  if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
35976
36286
  if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
35977
36287
  if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
36288
+ if (!constraints.ok) warnings.push(`constraints fetch failed: ${constraints.error}`);
36289
+ if (!referencedBy.ok) warnings.push(`referenced_by fetch failed: ${referencedBy.error}`);
36290
+ if (!partitionParent.ok) warnings.push(`partition_of fetch failed: ${partitionParent.error}`);
36291
+ if (!partitionChildren.ok) warnings.push(`partitions fetch failed: ${partitionChildren.error}`);
36292
+ const parentRow = partitionParent.ok ? partitionParent.data?.[0] : void 0;
35978
36293
  return {
35979
36294
  ok: true,
35980
36295
  data: {
35981
36296
  schema,
35982
36297
  table,
36298
+ kind,
35983
36299
  columns: cols.data,
35984
36300
  primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
35985
36301
  foreign_keys: fks.ok ? fks.data : [],
36302
+ referenced_by: referencedBy.ok ? referencedBy.data : [],
36303
+ constraints: constraints.ok ? constraints.data : [],
35986
36304
  indexes: idxs.ok ? idxs.data : [],
36305
+ ...parentRow ? { partition_of: parentRow } : {},
36306
+ ...partitionChildren.ok && (partitionChildren.data ?? []).length > 0 ? { partitions: partitionChildren.data } : {},
35987
36307
  ...warnings.length > 0 ? { _warnings: warnings } : {}
35988
36308
  }
35989
36309
  };
@@ -36167,14 +36487,17 @@ var statsTools = [
36167
36487
  const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
36168
36488
  const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "calls";
36169
36489
  return runInternal(
36490
+ // calls and rows are bigint counters; cast to float8 so node-pg returns
36491
+ // them as JS numbers (matches the timing fields). Precision is fine --
36492
+ // 2^53 is ~9e15, well above any realistic call/row count.
36170
36493
  `SELECT
36171
36494
  query,
36172
- calls::text AS calls,
36495
+ calls::float8 AS calls,
36173
36496
  ${totalCol}::numeric(18, 2)::float8 AS total_time_ms,
36174
36497
  ${meanCol}::numeric(18, 2)::float8 AS mean_time_ms,
36175
36498
  ${minCol}::numeric(18, 2)::float8 AS min_time_ms,
36176
36499
  ${maxCol}::numeric(18, 2)::float8 AS max_time_ms,
36177
- rows::text AS rows,
36500
+ rows::float8 AS rows,
36178
36501
  CASE
36179
36502
  WHEN (shared_blks_hit + shared_blks_read) > 0
36180
36503
  THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
@@ -36283,7 +36606,7 @@ function compareVersions(a, b) {
36283
36606
  }
36284
36607
 
36285
36608
  // src/index.ts
36286
- var version2 = true ? "0.3.3" : (await null).createRequire(import.meta.url)("../package.json").version;
36609
+ var version2 = true ? "0.4.0" : (await null).createRequire(import.meta.url)("../package.json").version;
36287
36610
  var subcommand = process.argv[2];
36288
36611
  if (subcommand === "version" || subcommand === "--version") {
36289
36612
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
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>",
@@ -15,6 +15,11 @@
15
15
  "mcp",
16
16
  "model-context-protocol",
17
17
  "ai",
18
+ "agent",
19
+ "claude",
20
+ "claude-code",
21
+ "cursor",
22
+ "llm",
18
23
  "sql",
19
24
  "schema",
20
25
  "explain"
@@ -25,7 +30,10 @@
25
30
  "postgres-mcp": "dist/index.js"
26
31
  },
27
32
  "files": [
28
- "dist/index.js"
33
+ "dist/index.js",
34
+ "LICENSE",
35
+ "README.md",
36
+ "CHANGELOG.md"
29
37
  ],
30
38
  "scripts": {
31
39
  "build": "tsc && node build.mjs",
@@ -44,7 +52,7 @@
44
52
  "@types/node": "^25.6.0",
45
53
  "@types/pg": "^8.20.0",
46
54
  "esbuild": "^0.28.0",
47
- "pg": "^8.13.0",
55
+ "pg": "^8.14.0",
48
56
  "typescript": "^6.0.3",
49
57
  "zod": "^4.3.6"
50
58
  },