@yawlabs/postgres-mcp 0.3.3 → 0.4.1

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,361 @@
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.1] - 2026-05-04
11
+
12
+ ### Changed
13
+ - Multi-query handlers (`pg_describe_table`, `pg_health`, `pg_advisor`,
14
+ `pg_replication_status`) now share a single connection across their
15
+ internal catalog fan-out via a new `withSharedClient` helper, so one
16
+ tool call's 3-9 query fan-out can no longer saturate the pool (default
17
+ max 5) and starve concurrent calls. Previously, a single
18
+ `pg_describe_table` issued 9 parallel `Promise.all` queries against the
19
+ pool; under concurrent load this could block other tool calls until
20
+ the describe drained.
21
+ - `pg_top_queries` now returns `calls` and `rows` as text strings,
22
+ matching the bigint serialization of `pg_seq_scan_tables`,
23
+ `pg_unused_indexes`, and `pg_table_bloat`. Timing fields stay as JSON
24
+ numbers since they are inherently fractional milliseconds.
25
+
26
+ ### Fixed
27
+ - `runUserQueryBounded` now distinguishes "DECLARE failed" (re-running
28
+ on the direct-exec path is safe -- the user SQL never executed) from
29
+ "FETCH/CLOSE/RELEASE failed" (re-running could double-execute side
30
+ effects). Previously, a transient FETCH-time failure on a
31
+ RETURNING-DML statement would silently re-run the mutation.
32
+ - `pg_top_queries` `orderBy: "calls"` now sorts numerically rather than
33
+ lexically. The 0.4.1 change to return `calls` as `text` shadowed the
34
+ source bigint column with a text output alias in the ORDER BY, so
35
+ "9" beat "10". The fix qualifies the ORDER BY expression with the
36
+ source table. Caught in the post-implementation review before tagging.
37
+ - `pg_describe_table` "not found" error message now escapes user-supplied
38
+ schema/table names via `JSON.stringify`, so a name containing `"` no
39
+ longer renders as broken-looking nested quotes.
40
+ - `pg_explain` `hypothetical_indexes` now pre-flight rejects pre-quoted
41
+ identifiers (`"odd.name"`, `weird"col`) with a clear validation error
42
+ before opening a database connection, instead of producing a confusing
43
+ planner error after the fact.
44
+
45
+ ### Added
46
+ - New CI release plumbing mirroring `@yawlabs/tailscale-mcp`:
47
+ `.github/workflows/ci.yml` (lint + build + test on push/PR across
48
+ Node 18/20/22), `.github/workflows/integration.yml` (PG17 + PG18
49
+ service-container matrix, scheduled nightly + on-demand), and
50
+ `.github/workflows/release.yml` (tag-pushes-trigger-publish gated
51
+ on integration). `npm publish` now runs in CI with `--provenance`
52
+ via the org-level `NPM_TOKEN`; `release.sh` retains a local-dev
53
+ path that runs the WSL integration matrix as a pre-flight when
54
+ cutting a release from a workstation.
55
+ - New regression tests:
56
+ - `compareVersions` unit tests covering pre-release tags,
57
+ missing/longer segments, and the actual `1.8` boundary used by
58
+ `pg_top_queries`.
59
+ - Integration guard that partition children inheriting a parent's
60
+ primary key are correctly excluded from `pg_advisor`'s
61
+ `tables_without_primary_key` list.
62
+ - Integration coverage for the cursor-fallback path on both DDL
63
+ (`CREATE TABLE`) and DML-without-RETURNING (`INSERT`).
64
+
65
+ ## [0.4.0] - 2026-04-25
66
+
67
+ ### Security
68
+ - User SQL fetches are now memory-bounded by Postgres, not by Node. The
69
+ prior flow had node-pg materialize the entire result set into memory and
70
+ then sliced down to `POSTGRES_MAX_ROWS` for output -- so a payload like
71
+ `SELECT * FROM big1 CROSS JOIN big2` could OOM the MCP process before
72
+ the 30 s `statement_timeout` fired. User SQL now runs through a
73
+ server-side `DECLARE ... NO SCROLL CURSOR FOR ...` + `FETCH MAX_ROWS+1`
74
+ pattern; only the response-sized batch is ever materialized in Node.
75
+ Non-cursorable statements (DDL, DML without RETURNING, utility commands)
76
+ fall back to a direct execute via `SAVEPOINT` so the outer transaction
77
+ stays alive -- those statements never produce a runaway result set
78
+ anyway.
79
+ - User SQL is now sent with `queryMode: 'extended'`, forcing pg to use the
80
+ extended query protocol regardless of whether `params` is empty. The
81
+ extended protocol restricts each request to a single statement, closing
82
+ the stacked-query injection pattern documented by
83
+ [Datadog Security Labs](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/)
84
+ against the now-archived `@modelcontextprotocol/server-postgres`. Without
85
+ this fix, a payload like `SELECT 1; COMMIT; DROP SCHEMA x CASCADE;` passed
86
+ to `pg_query` would escape the `BEGIN READ ONLY` wrapper and run DDL in
87
+ autocommit. Added an integration regression test that asserts the
88
+ multi-statement request is rejected by Postgres.
89
+ - `pg` minimum bumped from `^8.13.0` to `^8.14.0`. The `queryMode: 'extended'`
90
+ option that backs the stacked-query guard above is silently ignored on pg
91
+ 8.13.x -- a fresh `npm install` resolving to that range would have regressed
92
+ the security guard with no visible signal. Today's lockfile pins 8.20.0;
93
+ the range bump prevents future installs from sliding back.
94
+ - `@types/pg` bumped from `^8.11.10` to `^8.20.0` to track pg 8.20.0 runtime.
95
+
96
+ ### Added
97
+ - New `pg_advisor` tool: rolled-up DBA lints in one call. Returns three
98
+ categories of findings -- `sequence_exhaustion` (sequences whose
99
+ last_value is past `seqExhaustionThreshold` of max_value, default 50%;
100
+ the classic "BIGINT, eventually" incident class), `tables_without_primary_key`
101
+ (bloat candidates and a sign of design drift), and
102
+ `public_tables_without_rls` (default `public`; configurable via
103
+ `rlsSchemas`). Use as the "what should I be looking at?" starting
104
+ point and drill into `pg_unused_indexes` / `pg_table_bloat` /
105
+ `pg_seq_scan_tables` for the perf side.
106
+ - `pg_explain` accepts a `hypothetical_indexes` parameter -- list of
107
+ `{table, columns, using?}` -- which asks the planner "what would the
108
+ plan be if these indexes existed?". Requires the
109
+ [HypoPG](https://github.com/HypoPG/hypopg) extension
110
+ (`CREATE EXTENSION hypopg;`); the tool returns a friendly hint pointing
111
+ at that command if HypoPG isn't installed. Indexes are session-scoped
112
+ and torn down at the end of the call via `hypopg_reset()`, so they
113
+ never persist across MCP requests and never touch real disk. Closes
114
+ the biggest competitive gap vs. Crystal DBA's Postgres MCP Pro per
115
+ the comp-landscape audit. WSL setup script now installs the
116
+ `postgresql-${V}-hypopg` package opportunistically; the matrix test
117
+ runs against PG17 and PG18 and verifies the planner switches a Seq
118
+ Scan to an Index/Bitmap scan when a hypothetical index is supplied.
119
+ - `pg_describe_table` now returns four new fields:
120
+ - `referenced_by` -- incoming FKs (other tables whose foreign keys point at
121
+ this one). Answers "what depends on this table?" before a destructive
122
+ change. None of the surveyed competing Postgres MCP servers expose this;
123
+ in psql you'd run `\d+` on every candidate table and squint.
124
+ - `constraints` -- CHECK / UNIQUE non-PK / EXCLUDE constraints with each
125
+ constraint's full definition string from `pg_get_constraintdef()`. PK and
126
+ FK still live in their dedicated fields, so no double-listing.
127
+ - `partition_of` -- when the relation is a partition, the parent
128
+ schema / table.
129
+ - `partitions` -- when the relation is a `partitioned_table`, the list of
130
+ children with their `pg_get_expr()` partition bounds.
131
+ - `pg_query` result `fields` now include `dataTypeName` (e.g. `int4`, `text`,
132
+ `jsonb`) alongside `dataTypeID`. Previously LLMs saw only the OID and had
133
+ to map it themselves. Resolution is process-cached against `pg_type`, with
134
+ a single miss-fill query for any OID introduced by `CREATE TYPE` mid-session.
135
+ - npm tarball now ships `LICENSE`, `README.md`, and `CHANGELOG.md` alongside
136
+ the bundle. Previously the `files` allowlist was `["dist/index.js"]` only,
137
+ so `npm pack` produced a tarball with no docs and no license file -- bad
138
+ for downstream consumers and registry surfaces that read README from the
139
+ tarball rather than the repo.
140
+ - README workflow examples now show three multi-tool sequences (unstick a
141
+ hung app via `pg_inspect_locks` -> `pg_kill`, chase a slow page via
142
+ `pg_top_queries` -> `pg_explain` -> `pg_unused_indexes`, oncall triage via
143
+ `pg_health` -> `pg_inspect_locks` -> `pg_replication_status`). The previous
144
+ example list was single-tool only.
145
+ - New `pg_table_bloat` integration test asserts every returned `dead_ratio`
146
+ is a finite number in `[0, 1]`. Locks down the invariant that the recent
147
+ `dead / (live + dead)` formula change was meant to enforce.
148
+ - New `pg_query` integration tests for the `$1`-without-params error path
149
+ and the result-count-equals-`POSTGRES_MAX_ROWS` boundary (must NOT flag
150
+ `truncated: true`).
151
+ - New `pg_explain` integration tests for `analyze: true` on a SELECT in
152
+ read-only mode (works) and on an INSERT in read-only mode (errors with
153
+ the ALLOW_WRITES hint).
154
+ - npm `keywords` expanded with `agent`, `claude`, `claude-code`, `cursor`,
155
+ `llm` so registry / search engine queries for "postgres mcp claude code"
156
+ surface this package.
157
+ - `POSTGRES_CONNECTION_TIMEOUT_MS` env var (default `10000`). Without it, a
158
+ dead host hangs the first connection attempt until the OS times out
159
+ (~2 minutes on most platforms), and the agent waits the whole time before
160
+ surfacing an error.
161
+ - `pg_describe_table` now returns a `kind` field (`table` / `view` /
162
+ `materialized_view` / `partitioned_table` / `foreign_table`). Previously
163
+ the tool silently accepted views and matviews and returned columns with
164
+ empty `primary_key` / `foreign_keys` / `indexes` -- correct, but an LLM
165
+ couldn't tell whether the relation was writable.
166
+
167
+ ### Changed
168
+ - README now states the supported Postgres versions: tested on 17 and 18,
169
+ expected to work on 13+.
170
+
171
+ ### Changed
172
+ - `pg_top_queries` now returns `calls` and `rows` as JS numbers (cast to
173
+ `float8` in SQL) instead of strings. The previous `::text` cast forced
174
+ consumers to parse `"42"` to use it; the timing fields next to them were
175
+ already numbers, so the response shape was inconsistent. float8 is fine
176
+ -- 2^53 is well above any realistic per-query call/row count.
177
+ - `pg_health` partial-failure shape: failed sub-queries now contribute to a
178
+ top-level `_warnings: string[]` array, and the affected fields stay null
179
+ instead of becoming `{error: "..."}`. Previously a failure of the size
180
+ query made `data.database.size_bytes` resolve to `undefined` with no
181
+ signal -- LLMs couldn't tell "missing" from "errored". This matches the
182
+ `_warnings` convention `pg_describe_table` already uses.
183
+ - `shutdown()` now races `pool.end()` against a 5 s timer. `pool.end()`
184
+ waits for in-flight queries with no upper bound, so a wedged query
185
+ (frozen NFS, network hang) could leave the MCP server appearing stuck on
186
+ exit until the OS reaped the TCP sockets.
187
+ - `pg_table_privileges` description tightened to spell out that omitting
188
+ `table` returns privileges for every table in the schema.
189
+ - README now lists supported PG versions explicitly and groups workflow
190
+ examples by single-tool / multi-tool intent.
191
+
192
+ ### Fixed
193
+ - `pg_table_bloat` now uses `dead / (live + dead)` for `dead_ratio` instead of
194
+ `dead / live`. The previous formula reported `0` for tables with `live = 0`
195
+ even when `dead > 0`, hiding the most-bloated tables (an empty-shell table
196
+ full of dead tuples is the textbook VACUUM target). The new formula is
197
+ bounded `[0, 1]`, behaves correctly at edges, and the `WHERE` filter now
198
+ excludes tables with both counters at 0 entirely. The `minDeadRatio`
199
+ parameter description was updated to match.
200
+ - `npm test` now serializes test files with `--test-concurrency=1`. Unit tests
201
+ in `api.test.ts` and `tools/admin.test.ts` both mutate `process.env`
202
+ (`ALLOW_WRITES`, `POSTGRES_MAX_ROWS`, etc.); under Node's default parallel
203
+ test-file scheduling these races could flap on CI.
204
+ - README integration-suite paragraph corrected: the schema is named
205
+ `test_fixture`, not `postgres_mcp_integration`.
206
+ - `scripts/wsl-test-matrix.sh` derives `REPO_SRC` from its own location instead
207
+ of a hardcoded `/mnt/c/Users/jeff/...` path. Anyone other than the original
208
+ author can now run the matrix from their own clone.
209
+ - `release.sh` creates annotated tags (`git tag -a`) and pushes with
210
+ `--follow-tags` instead of `--tags`. The previous `--tags` form pushed every
211
+ local tag, including any unrelated experimental ones lying around;
212
+ `--follow-tags` only pushes the tag(s) reachable from the commits being
213
+ pushed -- but it ignores lightweight tags, so the tag-creation step had to
214
+ switch to annotated to keep working.
215
+
216
+ ## [0.3.2] - 2026-04-24
217
+
218
+ ### Fixed
219
+ - `pg_explain` with `analyze: true` and `ALLOW_WRITES=1` no longer persists
220
+ writes executed by `EXPLAIN ANALYZE`. Previously the write ran inside a
221
+ `BEGIN; ... COMMIT` transaction, so `pg_explain { analyze: true, sql:
222
+ "INSERT ..." }` would actually insert the row. Now writes run inside a
223
+ `BEGIN; ... ROLLBACK` transaction — the plan (with real row counts and
224
+ timing) comes back but the mutation is rolled back. This matches the user
225
+ expectation when asking for a plan, and the tool description has been
226
+ updated to reflect it.
227
+ - `pg_health` `table_count` now excludes `pg_temp_%` schemas, matching the
228
+ filter in `pg_list_schemas`. The `relkind` filter already masked most
229
+ divergence, but the two queries are now consistent.
230
+ - `pg_seq_scan_tables` ratio column simplified. The previous CASE had a
231
+ branch that only fired when `idx_scan = 0 AND seq_scan = 0` (practically
232
+ unreachable given the table was ordered by `seq_scan DESC`), returning
233
+ `0` and implying a distinction that didn't exist. Now returns `NULL`
234
+ whenever `idx_scan = 0`, which is the meaningful ratio-undefined case.
235
+
236
+ ### Added
237
+ - `process.stdin` `end` handler cleans up the pg pool when the MCP client
238
+ disconnects. Previously the server kept running for up to 60 seconds
239
+ (the pool's idle timeout) after the parent closed the pipe.
240
+ - Shared `src/tools/params.ts` for the `paramValue` zod schema, previously
241
+ duplicated verbatim in `query.ts` and `explain.ts`.
242
+
243
+ ### Infrastructure
244
+ - Integration test suites now share one `before(setupFixtures)` /
245
+ `after(teardownFixtures)` per file via an outer `describe`, instead of
246
+ running DROP/CREATE per inner `describe`. Each file previously reset
247
+ the fixture schema 3-4 times; now it resets once.
248
+
249
+ ## [0.3.1] - 2026-04-22
250
+
251
+ ### Fixed
252
+ - `pg_list_roles` with `includeSystem: false` (the default) now actually
253
+ excludes built-in `pg_*` roles. The previous `LIKE 'pg\_%' ESCAPE '\\'`
254
+ filter ended up as SQL `ESCAPE '\\'` (two backslashes), which Postgres
255
+ rejects since `ESCAPE` requires a single character — so the whole filter
256
+ was silently being dropped. Replaced with `starts_with(rolname, 'pg_')`.
257
+ - `pg_describe_table` foreign-key `columns` and `foreign_columns` are now
258
+ proper JSON arrays. They were previously returned as the raw postgres
259
+ text form (e.g. `"{user_id}"`) because `array_agg(name)` returns `name[]`,
260
+ which node-pg doesn't auto-parse. Cast to `text[]` so the driver parses.
261
+
262
+ ### Infrastructure (main branch CI hygiene; no user-facing changes)
263
+ - `.gitattributes` forces LF line endings in the working tree on every OS,
264
+ so biome's formatter doesn't reject every file on Windows runners after
265
+ git's auto-CRLF conversion.
266
+ - The integration CI job now starts postgres via `docker run` with
267
+ `-c shared_preload_libraries=pg_stat_statements` instead of the `services:`
268
+ block (which passes options to `docker create`, where `-c` means
269
+ --cpu-shares and collided with the postmaster flag).
270
+ - Cross-platform test discovery via `scripts/run-tests.mjs`. `node --test dist`
271
+ hangs on Windows; `dist/**/*.test.js` globs only expand in bash with
272
+ globstar. The wrapper uses `fs.readdirSync({ recursive: true })` (stdlib)
273
+ and passes explicit paths, plus `--test-concurrency=1` for the integration
274
+ suite so fixture-schema setup doesn't race.
275
+
276
+ ## [0.3.0] - 2026-04-22
277
+
278
+ ### Added
279
+ - `pg_list_views` — list views and materialized views with SQL definitions.
280
+ - `pg_list_functions` — list functions, procedures, and aggregates with signatures.
281
+ - `pg_list_extensions` — list installed extensions (pgvector, postgis, etc.) with versions.
282
+ - `pg_search_columns` — find columns by name pattern across all user schemas.
283
+ - `pg_top_queries` — top N queries by total/mean execution time from
284
+ `pg_stat_statements`. Detects extension version and picks the right column
285
+ names (v1.8+ uses `total_exec_time`, older uses `total_time`). Returns clear
286
+ setup instructions if the extension is not installed.
287
+ - `pg_list_tables` now accepts `limit` / `offset` for pagination on large schemas.
288
+ - `pg_health` now accepts `activeQueryLimit` (1–100) to override the default of 10.
289
+ - `pg_query` / `pg_explain` `params` now accept arrays and objects (for
290
+ postgres arrays, `ANY`, and json/jsonb columns) in addition to scalars.
291
+ - `POSTGRES_SSL_REJECT_UNAUTHORIZED` env var to disable TLS cert verification for
292
+ managed databases using private-CA certs (Supabase, Neon, RDS). Documented in
293
+ a new "Connecting to managed Postgres" README section.
294
+ - `pg_describe_table` now surfaces partial failures via a `_warnings` array
295
+ instead of silently collapsing FK/index fetch errors into empty lists.
296
+ - Troubleshooting section in README covering common failure modes (env vars,
297
+ auth, timeouts, write-blocked, pool exhaustion, cold-start latency).
298
+ - CHANGELOG.md.
299
+ - Dependabot config for npm + github-actions (weekly, grouped dev deps).
300
+ - Windows CI matrix (ubuntu + windows × Node 18/20/22).
301
+ - Integration test suite (`npm run test:integration`) that exercises every
302
+ tool against a real Postgres instance. Gated on `POSTGRES_MCP_INTEGRATION=1`
303
+ so local `npm test` stays fast with no DB required. CI runs it on Linux via
304
+ a `postgres:16` service container with `pg_stat_statements` preloaded.
305
+ - `pg_inspect_locks` — show current blocking locks (blocked PID, blocker PID,
306
+ relation, lock type, both queries). First tool to reach for when a session
307
+ hangs or the app feels stuck.
308
+ - `pg_list_roles` — database roles with login/superuser/createdb/createrole
309
+ flags and inherited group memberships.
310
+ - `pg_table_privileges` — who has SELECT/INSERT/UPDATE/DELETE/etc. on a table,
311
+ or on all tables in a schema. Useful for pre-migration audits.
312
+ - `pg_seq_scan_tables` — tables with heavy sequential scans relative to index
313
+ scans. Missing-index candidates.
314
+ - `pg_unused_indexes` — non-unique, non-primary indexes with low/zero scan
315
+ counts. Drop candidates (each unused index costs write amplification).
316
+ - `pg_kill` — cancel a running query or terminate a backend by PID. Requires
317
+ `ALLOW_WRITES=1` since it changes session state. Distinguishes `cancel`
318
+ (SIGINT-equivalent, graceful) from `terminate` (SIGTERM, forceful).
319
+ - `pg_table_bloat` — estimate dead tuples and vacuum-candidate tables from
320
+ `pg_stat_user_tables`. No extensions required.
321
+ - `pg_replication_status` — replication slots, connected replicas with lag,
322
+ and current WAL position. Returns empty arrays on a standalone DB rather
323
+ than erroring, so it's safe to call unconditionally.
324
+ - New "What can an agent do with this?" README section with concrete example
325
+ conversations mapped to tool calls.
326
+
327
+ ### Changed
328
+ - Loosened identifier validation on `pg_list_tables` and `pg_describe_table`.
329
+ Quoted identifiers (e.g. `"My Table"`) now work. Length capped at 63 bytes
330
+ (the postgres limit) via zod schema; the previous regex-based whitelist
331
+ blocked legitimate identifiers.
332
+ - Pool `idleTimeoutMillis` widened 10s → 60s. MCP sessions routinely have
333
+ minute-long gaps; the short timeout was forcing a reconnect on every tool
334
+ call.
335
+ - `pg_query` / `pg_explain` `sql` inputs now hard-capped at 1 MB.
336
+
337
+ ### Fixed
338
+ - `pg_explain` now rejects pre-wrapped SQL (e.g. `"EXPLAIN SELECT 1"`) with a
339
+ clear error instead of producing a `EXPLAIN (...) EXPLAIN SELECT 1` syntax
340
+ error. LLMs frequently make this mistake.
341
+
342
+ ## [0.1.1] - 2026-04-21
343
+
344
+ ### Changed
345
+ - Release workflow: widened npm registry propagation wait from 1 min to 10 min
346
+ to handle occasional stalls observed on initial publish.
347
+
348
+ ## [0.1.0] - 2026-04-21
349
+
350
+ Initial release.
351
+
352
+ ### Added
353
+ - `pg_query` — run SQL with read-only-by-default safety. Writes opt in via `ALLOW_WRITES=1`.
354
+ - `pg_list_schemas` — list non-system schemas.
355
+ - `pg_list_tables` — list tables (and optionally views) with estimated row counts.
356
+ - `pg_describe_table` — columns, PK, FKs, indexes.
357
+ - `pg_explain` — `EXPLAIN` / `EXPLAIN ANALYZE` with text or JSON output.
358
+ - `pg_health` — server version, db size, connections, active queries, table count.
359
+ - Single-file bundled distribution (zero runtime deps) for fast `npx` cold starts.
360
+ - Result row truncation at `POSTGRES_MAX_ROWS` (default 1000).
361
+ - 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