@yawlabs/postgres-mcp 0.10.0 → 0.11.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 CHANGED
@@ -7,6 +7,200 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.1] - 2026-08-23
11
+
12
+ ### Fixed
13
+ - **Windows: the launcher no longer hard-kills the server on the first Ctrl-C.** There are no POSIX signals on Windows — `child.kill(sig)` ignores the name and calls `TerminateProcess`, an immediate hard kill (verified: a child with a `SIGTERM` handler never runs it and dies with `code=null`). The launcher forwarded anyway, on the stated assumption that this was a "no-op on Windows", so it aborted the graceful shutdown the console's own Ctrl-C had just started and skipped the server's `process.on("exit")` cleanup. The console already delivers the event to the whole process group, so on Windows the launcher now forwards nothing.
14
+ - **A wedged server no longer leaves the launcher hanging.** Forwarding was gated on `child.killed`, which records only that `kill()` was *called* — never that the child is gone — so every signal after the first was swallowed and there was no escape hatch. Escalation is now armed by a timer on the first signal: one press is enough, and a child still alive after a 2s grace window is killed. Using a timer rather than counting signals also stops the ordinary supervisor sequence (`SIGINT` then `SIGTERM` milliseconds apart) from being misread as impatience.
15
+
16
+ ### Documentation
17
+
18
+ - **The README now carries a "What's new in 0.11.0" section.** The three
19
+ breaking changes lived only in this file, where someone upgrading from
20
+ 0.10.x is unlikely to look before their first failing call -- and the
21
+ stats-envelope change fails at the call site rather than at install time,
22
+ since `data` becomes an object where it used to be an array.
23
+ - **Both WSL scripts and the README document the `MSYS_NO_PATHCONV=1` prefix
24
+ they require when invoked from Git Bash on Windows.** Without it, Git Bash
25
+ rewrites the `/mnt/c/...` argument into the Git install prefix before
26
+ `wsl.exe` sees it, and the script exits "No such file or directory" having
27
+ run no tests at all. Piping either script into `tail`/`head` is also called
28
+ out, because the pipeline's exit status is the last command's -- so a red
29
+ matrix reports success.
30
+
31
+ ## [0.11.0] - 2026-08-23
32
+
33
+ ### Added
34
+
35
+ - **`pg_io_stats`, a new tool for I/O observability.** `pg_stat_io` read /
36
+ write / extend / fsync counts, bytes and times per backend type and context
37
+ (PG16+), plus in-flight async I/O handles from `pg_aios` and the active
38
+ `io_method` (PG18+). Byte accounting differs by server: PG16-17 expose
39
+ `op_bytes` to multiply against operation counts, and PG18 removed it in favour
40
+ of direct `read_bytes` / `write_bytes` / `extend_bytes`. Both branches are
41
+ normalized to one figure, and neither ever names the other's column.
42
+
43
+ - **Server-version gating.** `getServerVersionNum()` in `api.ts` caches
44
+ `server_version_num` per process and every version-dependent column now routes
45
+ through it. A failed probe returns 0, the "assume oldest" sentinel, so an
46
+ unknown server falls through to the conservative query rather than emitting
47
+ SQL referencing a column it may not have. Probe failures are deliberately not
48
+ cached: one transient blip during the first tool call would otherwise pin the
49
+ process to degraded output for its whole lifetime.
50
+
51
+ - **`pg_describe_table` flags generated and identity columns.** `pg_attrdef`
52
+ stores generation expressions alongside plain defaults, so a generated
53
+ column's expression was surfacing as `default_value` with nothing marking it
54
+ -- an agent read that as "optional column, has a default" and wrote an INSERT
55
+ postgres rejects. Identity columns failed the other way: no `pg_attrdef` row
56
+ at all, so they reported `default_value: null, nullable: false` and the agent
57
+ supplied a value into `GENERATED ALWAYS AS IDENTITY`. Columns now carry
58
+ `generated`, `identity`, and `generation_expression`.
59
+
60
+ - **PG18 constraint metadata in `pg_describe_table`.** Constraints carry
61
+ `validated` on every version, plus `enforced` and `has_period` on PG18+. A
62
+ `NOT ENFORCED` foreign key looks present but validates nothing, and a temporal
63
+ `WITHOUT OVERLAPS` primary key previously rendered as an ordinary one. PG18
64
+ also made NOT NULL a real `pg_constraint` row that can be `NOT VALID` -- its
65
+ `attnotnull` docs now read "possibly invalid" -- so `not_null_validated` is
66
+ reported on PG18+, where the question can arise.
67
+
68
+ - **`pg_explain` gained the planner options that actually diagnose a slow
69
+ query**: `buffers`, `settings`, `verbose`, `wal`, `costs`, `timing`, plus
70
+ `generic_plan` (PG16+) for planning a parameterized query with no values, and
71
+ `memory` / `serialize` (PG17+). Options below the server's version are
72
+ rejected up front by name and required major, rather than as a parse error
73
+ from the server.
74
+
75
+ - **`pg_health` reports what a health check needs.** `max_connections` and a
76
+ used-fraction (so a connection count is readable), `wait_event_type` /
77
+ `wait_event` / `backend_type` / transaction age on active queries, and a
78
+ `pg_stat_database` rollup: deadlocks, temp files and bytes, conflicts, cache
79
+ hit ratio, `stats_reset`.
80
+
81
+ - **`pg_advisor` checks wraparound risk**, the classic pageable incident:
82
+ per-database and per-table `age(relfrozenxid)` measured against
83
+ `autovacuum_freeze_max_age`, with a `wraparoundThreshold` parameter mirroring
84
+ the existing sequence-exhaustion one. Freeze coverage via `relallfrozen` on
85
+ PG18+.
86
+
87
+ Multixact wraparound is checked alongside it, on the same rows and with no
88
+ extra round trip: `mxid_age(relminmxid)` against
89
+ `autovacuum_multixact_freeze_max_age`. Multixacts are consumed by row-level
90
+ locking (`SELECT ... FOR SHARE/UPDATE`, foreign-key checks), so a lock-heavy
91
+ workload can exhaust them while `relfrozenxid` still looks perfectly healthy
92
+ -- checking only xids reports such a cluster as clean. A row is flagged when
93
+ EITHER counter crosses the threshold, and `triggered_by` (`xid` /
94
+ `multixact` / `both`) says which, because the remediation differs.
95
+
96
+ - **`pg_health` connection buckets now reconcile against `total`.** Two
97
+ separate ways the old breakdown lied. First, `pg_stat_activity` does not hide
98
+ other users' sessions from an unprivileged role -- it returns the rows with
99
+ `state` NULL -- so `total` was complete while `active` / `idle` counted only
100
+ the caller's own sessions, and an operator could read `active: 0` on a busy
101
+ database. A `state_unavailable` counter now makes that shortfall visible.
102
+ Second, `idle in transaction (aborted)`, `starting`, `fastpath function
103
+ call`, and `disabled` matched no filter and vanished from the breakdown; the
104
+ aborted state is the one that holds locks and blocks vacuum, so it gets its
105
+ own field, and an `other` catch-all absorbs the rest (a `NOT IN` list, so a
106
+ future major's new state cannot silently disappear again).
107
+
108
+ - **`POSTGRES_APPLICATION_NAME`** (default `postgres-mcp`), so agent traffic is
109
+ identifiable in `pg_stat_activity` instead of anonymous -- while `pg_health`
110
+ itself reports `application_name` for every other session. An
111
+ `application_name` in `DATABASE_URL` still wins.
112
+
113
+ ### Changed
114
+
115
+ - **BREAKING: `pg_seq_scan_tables` and `pg_unused_indexes` return an envelope,
116
+ not a bare row array.** `data` is now
117
+ `{rows, stats_reset, stats_reset_age_seconds}`; callers read `data.rows`.
118
+ The reason is `pg_unused_indexes` could tell an agent to drop a load-bearing
119
+ index: a scan count is meaningless without knowing when the counters were
120
+ reset, and if that happened an hour ago every index looks unused. Both tools
121
+ also report `last_idx_scan` / `last_seq_scan` on PG16+, where "not scanned
122
+ since March" beats a bare counter.
123
+
124
+ - **BREAKING: `pg_top_queries` returns the same envelope**, for the same
125
+ reason -- it ranks cumulative `total_exec_time` / `calls`. Its clock is NOT
126
+ `pg_stat_database.stats_reset` but `pg_stat_statements_info.stats_reset`, a
127
+ genuinely independent reset point; using the wrong one would have been worse
128
+ than omitting it. The same view supplies `dealloc`, which is the subtler
129
+ trap: it counts how often entries for the least-executed statements were
130
+ evicted for exceeding `pg_stat_statements.max`, so a non-zero value means
131
+ the "top queries" ranking is drawn from an incomplete population. Both
132
+ require extension 1.9 (PostgreSQL 14) and are omitted below it rather than
133
+ returned as nulls that would read as "never reset".
134
+
135
+ - **BREAKING: `pg_explain` with `analyze: true` now emits `BUFFERS`.** PG18
136
+ turns it on by default server-side; on PG15-17 it had to be requested and was
137
+ absent. Plans gain buffer lines, so text plans roughly double in length and
138
+ `POSTGRES_MAX_ROWS` truncation can fire where it previously did not. Pass
139
+ `buffers: false` for the old output.
140
+
141
+ - **BREAKING: the Node floor is now 22.** Node 20 reached end of life; the
142
+ supported lines are 22, 24 and 26. The esbuild target deliberately stays at
143
+ `node20` -- it only controls syntax downleveling, so a lower floor keeps the
144
+ bundle runnable under alternate runtimes.
145
+
146
+ - **Tools register through `registerTool` instead of `server.tool`.** All six
147
+ `server.tool` overloads are deprecated as of SDK 1.30 and are gone in the v2
148
+ packages. `registerTool` is also the only form that can carry `outputSchema`,
149
+ so this is what makes structured tool output reachable later. Tools now
150
+ advertise a top-level `title` as well as `annotations.title`; both are emitted,
151
+ since dropping either regresses hosts that read only one.
152
+
153
+ - **`@modelcontextprotocol/sdk` 1.29.0 -> 1.30.0** (the final v1.x release) and
154
+ **`pg` ^8.14.0 -> ^8.23.0**. The pg bump makes `sslnegotiation=direct`
155
+ available in `DATABASE_URL`, which skips a round trip against PG17+ servers;
156
+ it stays opt-in because a PG16-or-older server rejects the connection.
157
+
158
+ - **Documented where PostgreSQL support actually sits.** PG13 reached end of
159
+ life on 2025-11-13 and PG14 does so on 2026-11-12. The integration matrix
160
+ remains 15 / 17 / 18.
161
+
162
+ ### Fixed
163
+
164
+ - **`POSTGRES_APPLICATION_NAME` was missing from the oam sandbox allowlist.**
165
+ Under `POSTGRES_MCP_SANDBOX=1`, oam removes an undeclared variable from
166
+ `process.env` rather than denying access, so the operator's configured name
167
+ would have silently vanished and the server would have reported the default
168
+ -- precisely the silent-misbehaviour failure the launcher's own comment warns
169
+ about. `PGAPPNAME` is now granted too, since the pg driver reads it as its
170
+ own fallback for the same setting. A new test scans the shipped bundle for
171
+ literal `process.env` reads and fails if any config variable is absent from
172
+ the allowlist, so the next one cannot ship silently.
173
+
174
+ - **`pg_io_stats` had no test coverage at all.** `tools.test.ts` builds its own
175
+ `allTools` array separate from `index.ts`, and the new tool was added to one
176
+ and not the other -- exempting it from every structural check, including the
177
+ duplicate-name guard. Both arrays now agree, and the tool has a unit suite
178
+ covering its version branches.
179
+
180
+ - **A version probe suspended across `shutdown()` could republish a stale
181
+ server version.** `shutdown()` clears the cache, but a probe already awaiting
182
+ its query would resolve afterwards and write the OLD server's version into
183
+ the cache the NEW pool uses -- gating catalog queries against the wrong
184
+ server, the exact failure the reset exists to prevent. A generation counter
185
+ now invalidates in-flight probes, matching the guard `resolveTypeNames`
186
+ already had.
187
+
188
+ - **Four `pg_explain` tests asserted the wrong thing whenever `DATABASE_URL`
189
+ was set.** Their helper cleared the env var to force the version probe's
190
+ "unknown" sentinel, but the pool and the version cache are module-scoped and
191
+ survive that, so on any machine with `DATABASE_URL` exported the probe never
192
+ re-ran. The suite passed or failed depending on the developer's environment.
193
+ The helper now calls `shutdown()` on both edges.
194
+
195
+ ### Security
196
+
197
+ - **`fast-uri` bumped past the host-confusion advisory (GHSA, high).** It
198
+ reaches the published artifact rather than staying a build-time concern: the
199
+ MCP SDK depends on `ajv`, which depends on `fast-uri`, and esbuild bundles the
200
+ whole graph into `dist/index.js`. "It is only a devDependency" is not the
201
+ right test for this package -- the bundle is what ships, and the dependency
202
+ tree is flattened into it. `npm audit` now reports zero vulnerabilities.
203
+
10
204
  ## [0.10.0] - 2026-08-08
11
205
 
12
206
  ### Added
package/README.md CHANGED
@@ -11,6 +11,23 @@ Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
12
12
  One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
13
13
 
14
+ ## What's new in 0.11.0
15
+
16
+ PostgreSQL 18 support, a new I/O observability tool, and version-gated catalog queries. Full detail in the [CHANGELOG](CHANGELOG.md).
17
+
18
+ **Three breaking changes if you are upgrading from 0.10.x:**
19
+
20
+ 1. **`pg_seq_scan_tables`, `pg_unused_indexes` and `pg_top_queries` return an envelope, not a bare row array.** Read `data.rows` where you used to read `data`. The envelope carries `stats_reset`, because a cumulative scan count means nothing without knowing when the counters were last reset -- if that happened an hour ago, every index looks unused, which is how a load-bearing index gets dropped.
21
+ 2. **`pg_explain` with `analyze: true` now emits `BUFFERS`**, matching what PostgreSQL 18 does server-side. Plans get longer; pass `buffers: false` for the old output.
22
+ 3. **Node 22 is the floor.** Node 20 reached end of life.
23
+
24
+ **Worth knowing even if you are not upgrading yet:**
25
+
26
+ - `pg_describe_table` now flags generated and identity columns. Previously a generated column's expression surfaced as `default_value` with nothing marking it, so an agent read the column as optional-with-a-default and wrote an `INSERT` that PostgreSQL rejects.
27
+ - New `pg_io_stats` exposes `pg_stat_io` (PG16+) plus in-flight async I/O from `pg_aios` and the active `io_method` (PG18+).
28
+ - `pg_advisor` checks multixact wraparound alongside transaction-ID wraparound. A lock-heavy workload can exhaust multixacts while `relfrozenxid` still looks healthy.
29
+ - Every version-dependent column is gated on `server_version_num`, so older servers get a thinner answer rather than an error.
30
+
14
31
  ## Backstory
15
32
 
16
33
  Anthropic's reference Postgres MCP server, `@modelcontextprotocol/server-postgres`, was [archived in May 2025](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) and [marked deprecated on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres) in July 2025. Anthropic has not shipped a replacement. Despite the deprecation, the last published version (v0.6.2) is still pulled ~20,000 times per week - a lot of agents are pointed at an unmaintained package.
@@ -167,22 +184,23 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
167
184
  | `pg_query` | Run a SQL query. Writes gated by the role in `DATABASE_URL` first, `ALLOW_WRITES` second. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
168
185
  | `pg_list_schemas` | List non-system schemas. |
169
186
  | `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
170
- | `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
187
+ | `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. Generated and identity columns are flagged (`generated`, `identity`, `generation_expression`) so an agent doesn't try to write to them. Constraints carry `validated`, plus `enforced` / `has_period` on PG18+. |
171
188
  | `pg_list_views` | List views and materialized views in a schema, including their SQL definitions. |
172
189
  | `pg_list_functions` | List functions, procedures, and aggregates in a schema with signatures and return types. |
173
190
  | `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
174
191
  | `pg_search_columns` | Find columns by name pattern across all user schemas. Case-insensitive, supports SQL LIKE wildcards. |
175
- | `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. |
176
- | `pg_health` | Server version, database size, connection count, active queries, table count. |
177
- | `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. |
178
- | `pg_seq_scan_tables` | Tables with heavy sequential scans - missing-index candidates. |
179
- | `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts - drop candidates. |
192
+ | `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. Planner options: `buffers` (on by default with `analyze`), `settings`, `verbose`, `wal`, `costs`, `timing`, plus `generic_plan` (PG16+, plan a parameterized query with no values) and `memory` / `serialize` (PG17+). 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. |
193
+ | `pg_health` | Server version, database size, connections against `max_connections`, active queries with wait events and transaction age, `pg_stat_database` rollup (deadlocks, temp files, cache hit ratio), table count. |
194
+ | `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. Returns `stats_reset` (from `pg_stat_statements_info`, a different clock from the other stats tools) and `dealloc` on extension 1.9+ - a non-zero `dealloc` means entries were evicted past `pg_stat_statements.max`, so the ranking is drawn from an incomplete population. |
195
+ | `pg_seq_scan_tables` | Tables with heavy sequential scans - missing-index candidates. Returns the `stats_reset` window alongside the rows, since the counters mean nothing without it. `last_seq_scan` / `last_idx_scan` on PG16+. |
196
+ | `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts - drop candidates. Also returns `stats_reset`: a recently reset counter makes every index look unused, which is how a load-bearing index gets dropped. `last_idx_scan` on PG16+. |
197
+ | `pg_io_stats` | I/O observability: `pg_stat_io` read/write/extend/fsync counts, bytes and times per backend type and context (PG16+), plus in-flight async I/O handles from `pg_aios` and the active `io_method` (PG18+). |
180
198
  | `pg_inspect_locks` | Who is blocking whom right now (blocked PID, blocker PID, lock type, queries). |
181
199
  | `pg_list_roles` | Database roles with login/superuser/createdb flags and group memberships. |
182
200
  | `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
183
201
  | `pg_table_bloat` | Tables with high dead-tuple ratios - VACUUM candidates. |
184
202
  | `pg_replication_status` | Replication slots, connected replicas, and current WAL position. |
185
- | `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. |
203
+ | `pg_advisor` | Rolled-up DBA lints in one call: sequence-exhaustion candidates, wraparound risk for both counters (per-database and per-table `age(relfrozenxid)` against `autovacuum_freeze_max_age`, and `mxid_age(relminmxid)` against `autovacuum_multixact_freeze_max_age` -- a lock-heavy workload can exhaust multixacts while xids look healthy; `triggered_by` says which), tables without a primary key, and (configurable) public tables with RLS disabled. The "what should I be looking at?" starting point. |
186
204
  | `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
187
205
 
188
206
  ## Configuration
@@ -198,12 +216,25 @@ All env vars are read from the MCP server's environment:
198
216
  | `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
199
217
  | `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
200
218
  | `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
219
+ | `POSTGRES_APPLICATION_NAME` | `postgres-mcp` | Value reported in `pg_stat_activity.application_name`, so agent traffic is identifiable to whoever is watching the database. An `application_name` in `DATABASE_URL` takes precedence over this. |
201
220
  | `POSTGRES_MCP_RUNTIME` | `auto` | Which JS runtime executes the server: `auto` (prefer [oam](https://oamjs.org), fall back to Node), `oam` (require oam, fail if absent), `node` (never use oam). See [Runtime](#runtime). |
202
221
  | `OAM_BIN` | unset | Explicit path to an `oam` binary, checked before PATH and the default install locations. |
203
222
 
204
223
  ### Supported Postgres versions
205
224
 
206
- Tested on **PostgreSQL 15, 17 and 18** in the integration matrix. 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.
225
+ Tested on **PostgreSQL 15, 17 and 18** in the integration matrix.
226
+
227
+ Works on PG13+, but note where upstream support actually sits: **PG13 reached end of life on 2025-11-13 and PG14 does so on 2026-11-12.** PG13/14 are not exercised here and are not a compatibility target going forward. PG12 and below are further out of support and some tools rely on columns that landed in PG13 (`pg_replication_status` reading `wal_status`, `pg_top_queries` reading `*_exec_time`).
228
+
229
+ Newer server versions unlock extra fields rather than being required. Every version-dependent column is gated on `server_version_num` and simply omitted on servers that predate it, so nothing errors -- you get a slightly thinner answer. The cut points that matter:
230
+
231
+ | Server | What it adds |
232
+ |--------|--------------|
233
+ | PG16+ | `last_idx_scan` / `last_seq_scan` in the stats tools (index/table staleness rather than a bare counter), `pg_explain` `generic_plan` |
234
+ | PG17+ | `pg_explain` `memory` and `serialize` |
235
+ | PG18+ | Generated-column form (`stored` vs `virtual`), NOT NULL constraint validity, `conenforced` / `conperiod` constraint metadata in `pg_describe_table`, `relallfrozen` freeze coverage in `pg_advisor`. `BUFFERS` is on by default with `EXPLAIN ANALYZE` server-side |
236
+
237
+ If the version probe fails, the server assumes the oldest supported shape rather than emitting SQL a server might reject.
207
238
 
208
239
  ### Runtime
209
240
 
@@ -211,7 +242,7 @@ The published `postgres-mcp` command is a small launcher that prefers the [oam](
211
242
 
212
243
  **If you do not have oam, nothing changes.** The fallback is not a re-exec: npm already started Node to run the launcher, so falling back is a plain `import()` of the server into that same process. It costs a few `existsSync` calls and no subprocess, and behaves identically to running `dist/index.js` under Node directly.
213
244
 
214
- **If you do have oam,** the server runs under it. Verified equivalent on both runtimes: all 21 tools register, queries return identical rows and `dataTypeName` values, and the error paths match. oam supplies every `node:` builtin the driver needs, including `net`, `tls`, `crypto`, and `dns` (SCRAM auth and the extended query protocol both work).
245
+ **If you do have oam,** the server runs under it. Verified equivalent on both runtimes: all 22 tools register, queries return identical rows and `dataTypeName` values, and the error paths match. oam supplies every `node:` builtin the driver needs, including `net`, `tls`, `crypto`, and `dns` (SCRAM auth and the extended query protocol both work).
215
246
 
216
247
  **Startup cost, measured.** windows-arm64, 1.4 MB bundle, `postgres-mcp version` (full module init), every binary warmed first, mean of 12 runs:
217
248
 
@@ -263,6 +294,14 @@ To allow the connection while keeping traffic encrypted, add `POSTGRES_SSL_REJEC
263
294
 
264
295
  This disables certificate chain verification only -- the TCP connection is still TLS-encrypted end-to-end. For production setups where you can install the CA, prefer putting the cert in the Node trust store (`NODE_EXTRA_CA_CERTS`) over disabling verification globally.
265
296
 
297
+ **Shaving a round trip on PG17+.** Postgres 17 added direct TLS negotiation, which skips the plaintext `SSLRequest` handshake before the TLS one. The bundled driver supports it, so append `sslnegotiation=direct` to your `DATABASE_URL`:
298
+
299
+ ```
300
+ postgres://user:pass@host:5432/db?sslmode=require&sslnegotiation=direct
301
+ ```
302
+
303
+ It is opt-in rather than a default because a PG16-or-older server will reject the connection outright, and the saving is one round trip per pooled connection -- worth it on a distant managed database, invisible on a local one.
304
+
266
305
  ## Troubleshooting
267
306
 
268
307
  **`DATABASE_URL is not set`** - Your MCP client is launching the server without the env var. On Windows especially, env vars set in bash / PowerShell profiles are not inherited by MCP servers launched via `cmd`. Put `DATABASE_URL` directly in the `env` block of `.mcp.json`.
@@ -307,7 +346,9 @@ wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-pg-setup.sh
307
346
  wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-test-matrix.sh
308
347
  ```
309
348
 
310
- `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`.
349
+ `wsl-pg-setup.sh` installs PG15, PG17 and PG18 from the PGDG apt repo (ports are auto-assigned by `pg_createcluster` -- typically 17 on 5432, 18 on 5433, 15 on 5434), 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`.
350
+
351
+ **Running these from Git Bash instead of PowerShell?** Prefix both script invocations with `MSYS_NO_PATHCONV=1`. Git Bash rewrites the `/mnt/c/...` argument before `wsl.exe` sees it, so the script arrives as `C:/Users/<you>/scoop/apps/git/<ver>/mnt/c/...` and bash exits with "No such file or directory" having run nothing. Also avoid piping either script into `tail`/`head` -- the pipeline's exit status is the last command's, so a failing matrix reports success.
311
352
 
312
353
  Tear down when finished: `wsl --unregister Ubuntu`.
313
354
 
@@ -179,7 +179,15 @@ function sandboxFlags() {
179
179
  }
180
180
  }
181
181
 
182
- const env = ["ALLOW_WRITES","DATABASE_URL","NODE_PG_FORCE_NATIVE","PGCONNECT_TIMEOUT","PGSSLMODE","POSTGRES_CONNECTION_TIMEOUT_MS","POSTGRES_MAX_ROWS","POSTGRES_POOL_MAX","POSTGRES_SSL_REJECT_UNAUTHORIZED","POSTGRES_STATEMENT_TIMEOUT_MS","USER","USERNAME"];
182
+ // Every variable the SHIPPED BUNDLE reads, including the pg driver's own
183
+ // lookups. Adding a config env var to src/ without adding it here is a silent
184
+ // regression under the sandbox, not a loud one: oam removes an undeclared var
185
+ // from process.env rather than denying access, so the server reads undefined
186
+ // and quietly takes its default. POSTGRES_APPLICATION_NAME is read by
187
+ // getApplicationName() in src/api.ts; PGAPPNAME is pg's own env fallback for
188
+ // the same setting (connection-parameters.js: val('application_name', config,
189
+ // 'PGAPPNAME')), so omitting it would drop a name set the driver's way.
190
+ const env = ["ALLOW_WRITES","DATABASE_URL","NODE_PG_FORCE_NATIVE","PGAPPNAME","PGCONNECT_TIMEOUT","PGSSLMODE","POSTGRES_APPLICATION_NAME","POSTGRES_CONNECTION_TIMEOUT_MS","POSTGRES_MAX_ROWS","POSTGRES_POOL_MAX","POSTGRES_SSL_REJECT_UNAUTHORIZED","POSTGRES_STATEMENT_TIMEOUT_MS","USER","USERNAME"];
183
191
 
184
192
  const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
185
193
  return flags;
@@ -260,16 +268,51 @@ if (mode === "node") {
260
268
  void runInProcess();
261
269
  });
262
270
 
263
- // Forward termination so the server's own SIGINT/SIGTERM cleanup (pool
264
- // drain) runs in the child instead of the child being orphaned. Signals
265
- // are a no-op on Windows but harmless to register.
271
+ // Forward termination so the server's own shutdown path runs in the child
272
+ // rather than the child being orphaned.
273
+ //
274
+ // Registering ANY handler for these suppresses Node's default
275
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
276
+ // `child.killed` only records that kill() was CALLED, never that the child
277
+ // is gone, so gating on it swallows every signal after the first and wedges
278
+ // the launcher with no escape hatch.
279
+ //
280
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
281
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
282
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
283
+ // "a second signal" as impatience hard-kills a child that is already
284
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
285
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
286
+ // a wall-clock step cannot mis-gate the window either.
287
+ //
288
+ // POSIX vs Windows, and why we do NOT forward on Windows.
289
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
290
+ // is what lets the child run its shutdown. On Windows there are no POSIX
291
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
292
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
293
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
294
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
295
+ // child's process.on("exit") cleanup. The console has already notified the
296
+ // child, so on Windows the timer below is the only kill we issue.
297
+ const ESCALATE_AFTER_MS = 2000;
298
+ let escalation = null;
266
299
  for (const sig of ["SIGINT", "SIGTERM"]) {
267
300
  process.on(sig, () => {
268
- if (!child.killed) child.kill(sig);
301
+ // No try/catch: kill() on an already-exited child returns false, it does
302
+ // not throw. It throws only for a signal the platform does not know,
303
+ // which SIGINT/SIGTERM/SIGKILL never are.
304
+ if (!isWin) child.kill(sig);
305
+ if (escalation) return; // already counting down; further signals are noise
306
+ escalation = setTimeout(() => {
307
+ // Still here after its grace window. Stop waiting on it.
308
+ child.kill("SIGKILL");
309
+ process.exit(128 + (constants.signals[sig] ?? 15));
310
+ }, ESCALATE_AFTER_MS);
269
311
  });
270
312
  }
271
313
 
272
314
  child.on("exit", (code, signal) => {
315
+ if (escalation) clearTimeout(escalation);
273
316
  // Mirror the child's fate: a signal death becomes 128+n so callers see a
274
317
  // conventional shell exit status rather than a bare 0.
275
318
  if (signal) {