@yawlabs/postgres-mcp 0.11.0 → 0.11.2

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,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.2] - 2026-08-23
11
+
12
+ ### Fixed
13
+ - **The launcher no longer dies with a raw stack trace when `spawn` fails.** Node throws synchronously rather than emitting `error` for some unexecutable targets — notably a `.cmd`/`.bat` on Windows — and the `error` listener is registered *after* the `spawn` call, so it could never observe that throw. Both failure modes now route through one handler.
14
+ - **Windows `PATH` discovery accepts `oam.exe` only**, instead of walking every `PATHEXT` entry and returning an `oam.cmd` Node cannot execute. A skipped shim is still **named** in the diagnostic, so an npm-style install no longer reports as "no oam binary was found".
15
+ - **A failing in-process fallback no longer escapes as an unhandled rejection.** `void runInProcess()` discarded the promise, replacing the launcher's own diagnostic with a raw stack trace.
16
+ - **Diagnostics that precede `process.exit` are written synchronously.** stderr is async for TTYs and pipes on Windows, so the exit could truncate them. They route through one helper that also handles short writes and macOS `EAGAIN` on a non-blocking piped stderr.
17
+ - Removed a literal backspace byte (`U+0008`) from the runtime-discovery comment, which made git treat the file as binary so its diff could not be reviewed.
18
+ - **An oam that cannot be *run* is no longer reported as an *outdated* one.** The version probe returns null for several distinct causes — not executable, wrong architecture, a shim Node refuses, deleted since the stat, unparseable `--version` output — and every one produced "older than oam 0.9.0 … run `oam self-update`", pointing at the single cause it definitely was not. The two cases now carry separate wording and remedies, and the outdated message reports the version actually detected.
19
+
20
+ ## [0.11.1] - 2026-08-23
21
+
22
+ ### Fixed
23
+ - **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.
24
+ - **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.
25
+
26
+ ### Documentation
27
+
28
+ - **The README now carries a "What's new in 0.11.0" section.** The three
29
+ breaking changes lived only in this file, where someone upgrading from
30
+ 0.10.x is unlikely to look before their first failing call -- and the
31
+ stats-envelope change fails at the call site rather than at install time,
32
+ since `data` becomes an object where it used to be an array.
33
+ - **Both WSL scripts and the README document the `MSYS_NO_PATHCONV=1` prefix
34
+ they require when invoked from Git Bash on Windows.** Without it, Git Bash
35
+ rewrites the `/mnt/c/...` argument into the Git install prefix before
36
+ `wsl.exe` sees it, and the script exits "No such file or directory" having
37
+ run no tests at all. Piping either script into `tail`/`head` is also called
38
+ out, because the pipeline's exit status is the last command's -- so a red
39
+ matrix reports success.
40
+
10
41
  ## [0.11.0] - 2026-08-23
11
42
 
12
43
  ### 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.
@@ -97,13 +97,16 @@ function findOam() {
97
97
 
98
98
  // 2. PATH. Resolved manually rather than by spawning `which`/`where`, which
99
99
  // would cost a subprocess on every launch just to decide whether to spawn.
100
- const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
100
+ // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
101
+ // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
102
+ // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
103
+ // the full PATHEXT list would hand back a path this launcher cannot execute.
104
+ // Discovery has to agree with execution. A skipped shim is still reported --
105
+ // see findOamShim.
101
106
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
102
107
  if (!dir) continue;
103
- for (const ext of isWin ? pathExt : [""]) {
104
- const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
105
- if (existsSync(candidate)) return candidate;
106
- }
108
+ const candidate = join(dir, exe);
109
+ if (existsSync(candidate)) return candidate;
107
110
  }
108
111
 
109
112
  // 3. The per-user locations oamjs.org's installers write to. Checked because
@@ -193,8 +196,61 @@ function sandboxFlags() {
193
196
  return flags;
194
197
  }
195
198
 
199
+ /**
200
+ * Write a diagnostic to stderr synchronously, so a following process.exit
201
+ * cannot truncate it.
202
+ *
203
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
204
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
205
+ * there rather than blocking the write. Loop over the remaining bytes, and if
206
+ * stderr turns out to be unusable give up quietly -- failing to print a
207
+ * diagnostic is not worth crashing a stdio server over.
208
+ */
209
+ async function errSync(message) {
210
+ const { writeSync } = await import("node:fs");
211
+ const buf = Buffer.from(message);
212
+ let off = 0;
213
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
214
+ try {
215
+ off += writeSync(2, buf, off, buf.length - off);
216
+ } catch (err) {
217
+ if (err?.code !== "EAGAIN") return;
218
+ // Pipe is full and the reader has not drained yet -- retry.
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
225
+ * cannot spawn. Reported rather than ignored, because "no oam binary was found"
226
+ * reads as "install oam" -- the one thing that will not help. Windows only;
227
+ * there is no such shim concept on POSIX.
228
+ */
229
+ function findOamShim() {
230
+ if (!isWin) return null;
231
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
232
+ if (!dir) continue;
233
+ for (const ext of [".cmd", ".bat"]) {
234
+ const candidate = join(dir, `oam${ext}`);
235
+ if (existsSync(candidate)) return candidate;
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+
196
241
  /** Run the server in THIS process. The zero-overhead fallback. */
197
242
  async function runInProcess() {
243
+ // A server may gate its bootstrap on being the process ENTRY POINT --
244
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
245
+ // test file can import the module for unit tests without connecting a stdio
246
+ // transport. Importing the server here would leave argv[1] pointing at THIS
247
+ // launcher, the guard would read false, and the server would load but never
248
+ // serve: the MCP handshake just hangs.
249
+ //
250
+ // Point argv[1] at the server first, so the in-process path is
251
+ // indistinguishable from having executed the file directly. The spawn path
252
+ // needs no equivalent -- there argv[1] is already the server.
253
+ process.argv[1] = SERVER_ENTRY;
198
254
  await import(SERVER_URL.href);
199
255
  }
200
256
 
@@ -204,8 +260,21 @@ if (mode === "node") {
204
260
  await runInProcess();
205
261
  } else {
206
262
  const oam = findOam();
263
+ // Read the version ONCE, and only when discovery found something: the
264
+ // gate below has to tell "too old" apart from "could not be read at all",
265
+ // and re-probing inside the branch would cost a second subprocess.
266
+ const found = oam ? oamVersion(oam) : null;
207
267
 
208
268
  if (!oam) {
269
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this
270
+ // launcher cannot spawn. Naming it turns "no oam binary was found" --
271
+ // which reads as "install oam", the one thing that will not help --
272
+ // into something the user can act on.
273
+ const oamShim = findOamShim();
274
+ const shimNote = oamShim
275
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
276
+ "Install the native oam binary, or point OAM_BIN at one.\n"
277
+ : "";
209
278
  if (mode === "oam") {
210
279
  // Explicitly demanded, so this is a real misconfiguration -- do not
211
280
  // silently do something else. writeSync because stderr is async for
@@ -213,77 +282,154 @@ if (mode === "node") {
213
282
  const { writeSync } = await import("node:fs");
214
283
  writeSync(
215
284
  2,
216
- "postgres-mcp: POSTGRES_MCP_RUNTIME=oam but no oam binary was found.\n" +
285
+ "postgres-mcp: POSTGRES_MCP_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
217
286
  "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use POSTGRES_MCP_RUNTIME=node.\n",
218
287
  );
219
288
  process.exit(1);
220
289
  }
290
+ // auto: falling back is correct, but silence is how someone never learns
291
+ // their oam install is a shape this launcher skips.
292
+ if (oamShim) await errSync(`postgres-mcp: ${shimNote}Using Node instead.\n`);
221
293
  await runInProcess();
222
- } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
223
- // Discovery itself stays stat-only; this is the first subprocess, and it
224
- // runs only once we have already decided to spawn oam anyway. Measured 26ms
225
- // median (n=12, windows-arm64), paid once per MCP session.
294
+ } else if (!atLeast(found, OAM_MIN)) {
226
295
  const min = OAM_MIN.join(".");
296
+ // Two different causes reach this branch and they need different
297
+ // remedies. `found === null` is NOT "old": oamVersion returns null when
298
+ // the binary could not be run at all (not executable, wrong arch, a
299
+ // .cmd/.bat Node refuses, deleted between the stat and the probe) or
300
+ // when its --version output did not parse. Telling that user to
301
+ // `oam self-update` sends them after the one cause it definitely is not.
302
+ const detail = found
303
+ ? `${oam} is oam ${found.join(".")}, older than ${min}`
304
+ : `${oam} could not be run, or did not report a version this launcher understands`;
305
+ const remedy = found
306
+ ? "Run \`oam self-update\`, or use POSTGRES_MCP_RUNTIME=node.\n"
307
+ : "Check that it is an executable oam binary for this platform, or use POSTGRES_MCP_RUNTIME=node.\n";
227
308
  if (mode === "oam") {
228
- const { writeSync } = await import("node:fs");
229
- writeSync(
230
- 2,
231
- `postgres-mcp: POSTGRES_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
232
- `Run \`oam self-update\`, or use POSTGRES_MCP_RUNTIME=node.\n`,
233
- );
309
+ await errSync(`postgres-mcp: POSTGRES_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
234
310
  process.exit(1);
235
311
  }
236
- // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
237
- // a silent downgrade is how someone keeps running an oam they meant to
238
- // update. stderr is safe -- MCP frames travel on stdout.
239
- process.stderr.write(`postgres-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
312
+ // auto: neither cause is worth failing over -- prefer Node. Say so,
313
+ // because a silent downgrade is how someone keeps running an oam they
314
+ // meant to update, or never learns their oam is unexecutable.
315
+ await errSync(`postgres-mcp: ${detail}; using Node instead.\n`);
240
316
  await runInProcess();
241
317
  } else {
242
318
  // `--` separates oam's own flags from the script's argv. Everything after
243
319
  // it lands in process.argv for the server, so `postgres-mcp version` and
244
320
  // any host-supplied flags survive the hop unchanged.
245
- const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
246
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
247
- // stdin/stdout is untouched and the host's stdin-close still reaches the
248
- // server's shutdown path.
249
- stdio: "inherit",
250
- env: process.env,
251
- windowsHide: true,
252
- });
253
-
254
- // If oam cannot be executed at all (deleted between the stat and the
255
- // spawn, wrong arch, permission), fall back rather than failing the whole
256
- // server. `spawned` guards against falling back AFTER the child has begun
257
- // running, which would double-start the server.
258
- let spawned = false;
259
- child.on("spawn", () => {
260
- spawned = true;
261
- });
262
- child.on("error", (err) => {
263
- if (spawned) return;
321
+ // Every "oam could not be executed" outcome lands here: the synchronous
322
+ // throw from spawn() and the async 'error' event mean the same thing and
323
+ // must degrade the same way, so the handling lives in one place.
324
+ // errSync rather than process.stderr.write because stderr is async for
325
+ // TTYs and pipes on Windows and the process.exit below truncates pending
326
+ // writes.
327
+ const launchFailed = async (err) => {
264
328
  if (mode === "oam") {
265
- process.stderr.write(`postgres-mcp: failed to launch oam (${err.message})\n`);
329
+ await errSync(`postgres-mcp: failed to launch oam (${err?.message ?? err})\n`);
266
330
  process.exit(1);
267
331
  }
268
- void runInProcess();
269
- });
332
+ await runInProcess();
333
+ };
334
+
335
+ // ONE reporter shared by both launchFailed call sites, so the sync-throw
336
+ // path and the 'error'-event path cannot drift apart. Either can reject:
337
+ // runInProcess() is a bare import() that rejects when dist/index.js is
338
+ // missing, and at ESM top level an unhandled rejection is an uncaught
339
+ // exception -- the exact failure this handling exists to prevent.
340
+ const fallbackFailed = (e) => {
341
+ process.stderr.write(`postgres-mcp: fallback to Node failed (${e?.message ?? e})\n`);
342
+ process.exitCode = 1;
343
+ };
270
344
 
271
- // Forward termination so the server's own SIGINT/SIGTERM cleanup (pool
272
- // drain) runs in the child instead of the child being orphaned. Signals
273
- // are a no-op on Windows but harmless to register.
274
- for (const sig of ["SIGINT", "SIGTERM"]) {
275
- process.on(sig, () => {
276
- if (!child.killed) child.kill(sig);
345
+ let child = null;
346
+ try {
347
+ child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
348
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
349
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
350
+ // server's shutdown path.
351
+ stdio: "inherit",
352
+ env: process.env,
353
+ windowsHide: true,
277
354
  });
355
+ } catch (err) {
356
+ // spawn() THROWS for some failures instead of emitting 'error', and the
357
+ // 'error' listener is registered AFTER this call, so it can never observe
358
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
359
+ // instead of falling back to Node.
360
+ await launchFailed(err).catch(fallbackFailed);
278
361
  }
279
362
 
280
- child.on("exit", (code, signal) => {
281
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
282
- // conventional shell exit status rather than a bare 0.
283
- if (signal) {
284
- process.exit(128 + (constants.signals[signal] ?? 15));
363
+ if (child) {
364
+
365
+ // If oam cannot be executed at all (deleted between the stat and the
366
+ // spawn, wrong arch, permission), fall back rather than failing the whole
367
+ // server. `spawned` guards against falling back AFTER the child has begun
368
+ // running, which would double-start the server.
369
+ let spawned = false;
370
+ child.on("spawn", () => {
371
+ spawned = true;
372
+ });
373
+ child.on("error", (err) => {
374
+ if (spawned) return;
375
+ // Handle the rejection instead of discarding it: a failing in-process
376
+ // fallback would otherwise escape as an unhandled rejection, replacing
377
+ // this launcher's diagnostic with a raw stack trace.
378
+ launchFailed(err).catch(fallbackFailed);
379
+ });
380
+
381
+ // Forward termination so the server's own shutdown path runs in the child
382
+ // rather than the child being orphaned.
383
+ //
384
+ // Registering ANY handler for these suppresses Node's default
385
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
386
+ // `child.killed` only records that kill() was CALLED, never that the child
387
+ // is gone, so gating on it swallows every signal after the first and wedges
388
+ // the launcher with no escape hatch.
389
+ //
390
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
391
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
392
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
393
+ // "a second signal" as impatience hard-kills a child that is already
394
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
395
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
396
+ // a wall-clock step cannot mis-gate the window either.
397
+ //
398
+ // POSIX vs Windows, and why we do NOT forward on Windows.
399
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
400
+ // is what lets the child run its shutdown. On Windows there are no POSIX
401
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
402
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
403
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
404
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
405
+ // child's process.on("exit") cleanup. The console has already notified the
406
+ // child, so on Windows the timer below is the only kill we issue.
407
+ const ESCALATE_AFTER_MS = 2000;
408
+ let escalation = null;
409
+ for (const sig of ["SIGINT", "SIGTERM"]) {
410
+ process.on(sig, () => {
411
+ // No try/catch: kill() on an already-exited child returns false, it does
412
+ // not throw. It throws only for a signal the platform does not know,
413
+ // which SIGINT/SIGTERM/SIGKILL never are.
414
+ if (!isWin) child.kill(sig);
415
+ if (escalation) return; // already counting down; further signals are noise
416
+ escalation = setTimeout(() => {
417
+ // Still here after its grace window. Stop waiting on it.
418
+ child.kill("SIGKILL");
419
+ process.exit(128 + (constants.signals[sig] ?? 15));
420
+ }, ESCALATE_AFTER_MS);
421
+ });
285
422
  }
286
- process.exit(code ?? 0);
287
- });
423
+
424
+ child.on("exit", (code, signal) => {
425
+ if (escalation) clearTimeout(escalation);
426
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
427
+ // conventional shell exit status rather than a bare 0.
428
+ if (signal) {
429
+ process.exit(128 + (constants.signals[signal] ?? 15));
430
+ }
431
+ process.exit(code ?? 0);
432
+ });
433
+ }
288
434
  }
289
435
  }
package/dist/index.js CHANGED
@@ -39017,7 +39017,7 @@ function compareVersions(a, b) {
39017
39017
  }
39018
39018
 
39019
39019
  // src/index.ts
39020
- var version2 = true ? "0.11.0" : await readPackageVersion();
39020
+ var version2 = true ? "0.11.2" : await readPackageVersion();
39021
39021
  var subcommand = process.argv[2];
39022
39022
  if (subcommand === "version" || subcommand === "--version") {
39023
39023
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "mcpName": "io.github.YawLabs/postgres-mcp",
5
5
  "description": "PostgreSQL MCP server - query, schema introspection, explain, and health checks for AI assistants",
6
6
  "license": "MIT",