@yawlabs/postgres-mcp 0.11.1 → 0.12.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 +89 -0
- package/README.md +1 -0
- package/bin/postgres-mcp.mjs +209 -91
- package/dist/index.js +20813 -21401
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-08-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`pg_index_advisor`, an index RECOMMENDATION tool.** The server already had
|
|
15
|
+
every input -- HypoPG for hypothetical indexes, `pg_stat_statements` via
|
|
16
|
+
`pg_top_queries`, and `pg_seq_scan_tables` -- and lacked only the search layer.
|
|
17
|
+
Candidates are costed by creating hypothetical indexes, re-running EXPLAIN, and
|
|
18
|
+
keeping what actually lowers cost, under a caller-set bound on candidates and
|
|
19
|
+
EXPLAIN round trips. Hypothetical indexes are session-scoped, so `hypopg_reset()`
|
|
20
|
+
runs in a teardown that fires even on error; leaking them would poison every
|
|
21
|
+
later plan in the session.
|
|
22
|
+
|
|
23
|
+
Encoded correctness note: PostgreSQL 18's skip scan means a multi-column btree
|
|
24
|
+
with an unconstrained LEADING column can now be used, so the classic "leading
|
|
25
|
+
column never filtered = dead index" heuristic is wrong on PG18+. That reasoning
|
|
26
|
+
is version-gated.
|
|
27
|
+
|
|
28
|
+
- **Structured tool output.** Every tool now declares an `outputSchema` and
|
|
29
|
+
returns `structuredContent` alongside the existing serialized text block.
|
|
30
|
+
Tools that return a bare array wrap it as `{ rows: [...] }`, because
|
|
31
|
+
`structuredContent` must be a JSON object; the `content` block is unchanged, so
|
|
32
|
+
nothing that reads it today breaks. Version-gated fields are modelled as
|
|
33
|
+
OPTIONAL rather than nullable -- absence is a deliberate signal in this codebase
|
|
34
|
+
and a nullable schema would erase the distinction.
|
|
35
|
+
|
|
36
|
+
- **Opt-in audit logging of agent SQL** via `POSTGRES_AUDIT_LOG` /
|
|
37
|
+
`POSTGRES_AUDIT_LOG_FILE` / `POSTGRES_AUDIT_REDACT`, off by default. One JSON
|
|
38
|
+
line per statement: timestamp, tool, SQL, parameter COUNT, duration, rows, and
|
|
39
|
+
ok/SQLSTATE. Parameter VALUES are never logged -- they routinely carry PII and
|
|
40
|
+
credentials. A redacting mode logs only the leading keyword plus a hash for
|
|
41
|
+
operators who want the trail without the content. An unopenable sink fails
|
|
42
|
+
LOUDLY at startup rather than degrading to no trail, since an audit control that
|
|
43
|
+
quietly disables itself is worse than none.
|
|
44
|
+
|
|
45
|
+
- **PostgreSQL 19 forward-compatibility.** PG19 renames
|
|
46
|
+
`pg_stat_subscription_stats.sync_error_count` to `sync_table_error_count` and
|
|
47
|
+
wait event type `BUFFERPIN` to `BUFFER`. Wait-event values are asserted to pass
|
|
48
|
+
through untouched, so a future filter on the old literal -- which would silently
|
|
49
|
+
match nothing on PG19 -- cannot be added without failing a test.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **Dual-era MCP protocol support.** The server was legacy-only: it pinned SDK
|
|
54
|
+
v1, which tops out at protocol revision 2025-11-25, and the current revision's
|
|
55
|
+
compatibility matrix states that a modern client talking to a legacy server
|
|
56
|
+
FAILS. It now serves both eras via SDK v2's `serveStdio`. The existing
|
|
57
|
+
process-level tests still drive a real `initialize` handshake against the
|
|
58
|
+
emitted bundle, which is what proves the legacy path still works.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **`release.sh` could report a failed release as a success.** The status lived
|
|
63
|
+
in a value a caller could easily discard, and a pipeline returns its LAST
|
|
64
|
+
command's status -- so a release that died at the push step read as exit 0. The
|
|
65
|
+
failure banner is now the final thing written, on both streams, naming the step
|
|
66
|
+
and its remedy. Re-entry is also guarded: a local tag that does not match the
|
|
67
|
+
commit being released now fails loudly instead of building a GitHub release
|
|
68
|
+
from an orphaned tag.
|
|
69
|
+
|
|
70
|
+
- **`wsl-test-matrix.sh` tested against stale dependencies.** It ran `npm ci`
|
|
71
|
+
only when `node_modules` was ABSENT, so a branch that changed a dependency was
|
|
72
|
+
silently tested against the previous branch's tree. It reinstalls on a
|
|
73
|
+
lockfile-hash change, and a failing install aborts instead of proceeding. This
|
|
74
|
+
produced a red matrix on all three majors during this very change set.
|
|
75
|
+
|
|
76
|
+
- **The oam sandbox allowlist dropped the new audit variables.** oam removes an
|
|
77
|
+
undeclared variable rather than denying it, so `POSTGRES_MCP_SANDBOX=1` would
|
|
78
|
+
have silently disabled the audit trail. Caught by the bundle-scanning test
|
|
79
|
+
added in 0.11.0. Also documented that the sandbox denies the filesystem, so a
|
|
80
|
+
FILE audit sink cannot open under it -- use the stderr sink there.
|
|
81
|
+
|
|
82
|
+
- **Integration tests called handlers with arguments no MCP client could send.**
|
|
83
|
+
Several call sites passed a `limit` above the declared schema maximum; direct
|
|
84
|
+
handler calls bypass Zod, so they silently worked and a cap regression would
|
|
85
|
+
not have been caught. Bounds are now respected, and a schema-level test pins
|
|
86
|
+
the cap.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## [0.11.2] - 2026-08-23
|
|
90
|
+
|
|
91
|
+
### Fixed
|
|
92
|
+
- **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.
|
|
93
|
+
- **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".
|
|
94
|
+
- **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.
|
|
95
|
+
- **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.
|
|
96
|
+
- 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.
|
|
97
|
+
- **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.
|
|
98
|
+
|
|
10
99
|
## [0.11.1] - 2026-08-23
|
|
11
100
|
|
|
12
101
|
### Fixed
|
package/README.md
CHANGED
|
@@ -190,6 +190,7 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
|
190
190
|
| `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
|
|
191
191
|
| `pg_search_columns` | Find columns by name pattern across all user schemas. Case-insensitive, supports SQL LIKE wildcards. |
|
|
192
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_index_advisor` | Recommend indexes for a workload and prove each one pays for itself first. Takes `statements` you pass or the top N from `pg_stat_statements`, harvests candidate columns from what the **planner** reports as filters / join keys / sort keys (no SQL parser - every token is intersected with the real `pg_attribute` column list), then costs each candidate with [HypoPG](https://github.com/HypoPG/hypopg) hypothetical indexes and keeps only what measurably lowers estimated cost. Greedy and bounded via `max_candidates` / `max_explains`, so a big workload cannot run away; `budget_exhausted` flags a truncated search. Returns the `CREATE INDEX` (plus a `CONCURRENTLY` form), cost before/after, which statements each index helps, and the estimated size. **PG18-aware:** PG18 added B-tree skip scan, so a multi-column index whose leading column is never filtered is no longer useless - that classic prune is gated on the server version rather than applied blindly. Requires HypoPG; indexes are session-scoped and reset on every exit path. |
|
|
193
194
|
| `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
195
|
| `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
196
|
| `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+. |
|
package/bin/postgres-mcp.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
@@ -187,14 +190,74 @@ function sandboxFlags() {
|
|
|
187
190
|
// getApplicationName() in src/api.ts; PGAPPNAME is pg's own env fallback for
|
|
188
191
|
// the same setting (connection-parameters.js: val('application_name', config,
|
|
189
192
|
// 'PGAPPNAME')), so omitting it would drop a name set the driver's way.
|
|
190
|
-
|
|
193
|
+
// POSTGRES_AUDIT_LOG_FILE is granted as a VARIABLE here, but the sandbox
|
|
194
|
+
// still denies the filesystem, so the file sink cannot actually open its
|
|
195
|
+
// target under POSTGRES_MCP_SANDBOX=1. The audit module fails loudly on an
|
|
196
|
+
// unopenable sink rather than silently dropping the trail, so the combination
|
|
197
|
+
// refuses to start -- which is the correct outcome (an audit control that
|
|
198
|
+
// quietly disables itself is worse than none), but it is a surprising one to
|
|
199
|
+
// hit at runtime. Use the stderr sink under the sandbox.
|
|
200
|
+
const env = ["ALLOW_WRITES","DATABASE_URL","NODE_PG_FORCE_NATIVE","PGAPPNAME","PGCONNECT_TIMEOUT","PGSSLMODE","POSTGRES_APPLICATION_NAME","POSTGRES_AUDIT_LOG","POSTGRES_AUDIT_LOG_FILE","POSTGRES_AUDIT_REDACT","POSTGRES_CONNECTION_TIMEOUT_MS","POSTGRES_MAX_ROWS","POSTGRES_POOL_MAX","POSTGRES_SSL_REJECT_UNAUTHORIZED","POSTGRES_STATEMENT_TIMEOUT_MS","USER","USERNAME"];
|
|
191
201
|
|
|
192
202
|
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
193
203
|
return flags;
|
|
194
204
|
}
|
|
195
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Write a diagnostic to stderr synchronously, so a following process.exit
|
|
208
|
+
* cannot truncate it.
|
|
209
|
+
*
|
|
210
|
+
* Not a bare writeSync: that call can short-write (it returns a byte count) and
|
|
211
|
+
* on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
|
|
212
|
+
* there rather than blocking the write. Loop over the remaining bytes, and if
|
|
213
|
+
* stderr turns out to be unusable give up quietly -- failing to print a
|
|
214
|
+
* diagnostic is not worth crashing a stdio server over.
|
|
215
|
+
*/
|
|
216
|
+
async function errSync(message) {
|
|
217
|
+
const { writeSync } = await import("node:fs");
|
|
218
|
+
const buf = Buffer.from(message);
|
|
219
|
+
let off = 0;
|
|
220
|
+
for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
|
|
221
|
+
try {
|
|
222
|
+
off += writeSync(2, buf, off, buf.length - off);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err?.code !== "EAGAIN") return;
|
|
225
|
+
// Pipe is full and the reader has not drained yet -- retry.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
|
|
232
|
+
* cannot spawn. Reported rather than ignored, because "no oam binary was found"
|
|
233
|
+
* reads as "install oam" -- the one thing that will not help. Windows only;
|
|
234
|
+
* there is no such shim concept on POSIX.
|
|
235
|
+
*/
|
|
236
|
+
function findOamShim() {
|
|
237
|
+
if (!isWin) return null;
|
|
238
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
239
|
+
if (!dir) continue;
|
|
240
|
+
for (const ext of [".cmd", ".bat"]) {
|
|
241
|
+
const candidate = join(dir, `oam${ext}`);
|
|
242
|
+
if (existsSync(candidate)) return candidate;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
196
248
|
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
197
249
|
async function runInProcess() {
|
|
250
|
+
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
251
|
+
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
252
|
+
// test file can import the module for unit tests without connecting a stdio
|
|
253
|
+
// transport. Importing the server here would leave argv[1] pointing at THIS
|
|
254
|
+
// launcher, the guard would read false, and the server would load but never
|
|
255
|
+
// serve: the MCP handshake just hangs.
|
|
256
|
+
//
|
|
257
|
+
// Point argv[1] at the server first, so the in-process path is
|
|
258
|
+
// indistinguishable from having executed the file directly. The spawn path
|
|
259
|
+
// needs no equivalent -- there argv[1] is already the server.
|
|
260
|
+
process.argv[1] = SERVER_ENTRY;
|
|
198
261
|
await import(SERVER_URL.href);
|
|
199
262
|
}
|
|
200
263
|
|
|
@@ -204,8 +267,21 @@ if (mode === "node") {
|
|
|
204
267
|
await runInProcess();
|
|
205
268
|
} else {
|
|
206
269
|
const oam = findOam();
|
|
270
|
+
// Read the version ONCE, and only when discovery found something: the
|
|
271
|
+
// gate below has to tell "too old" apart from "could not be read at all",
|
|
272
|
+
// and re-probing inside the branch would cost a second subprocess.
|
|
273
|
+
const found = oam ? oamVersion(oam) : null;
|
|
207
274
|
|
|
208
275
|
if (!oam) {
|
|
276
|
+
// An oam-named .cmd/.bat on PATH is a real install in a shape this
|
|
277
|
+
// launcher cannot spawn. Naming it turns "no oam binary was found" --
|
|
278
|
+
// which reads as "install oam", the one thing that will not help --
|
|
279
|
+
// into something the user can act on.
|
|
280
|
+
const oamShim = findOamShim();
|
|
281
|
+
const shimNote = oamShim
|
|
282
|
+
? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
|
|
283
|
+
"Install the native oam binary, or point OAM_BIN at one.\n"
|
|
284
|
+
: "";
|
|
209
285
|
if (mode === "oam") {
|
|
210
286
|
// Explicitly demanded, so this is a real misconfiguration -- do not
|
|
211
287
|
// silently do something else. writeSync because stderr is async for
|
|
@@ -213,112 +289,154 @@ if (mode === "node") {
|
|
|
213
289
|
const { writeSync } = await import("node:fs");
|
|
214
290
|
writeSync(
|
|
215
291
|
2,
|
|
216
|
-
"postgres-mcp: POSTGRES_MCP_RUNTIME=oam but no oam binary was found.\n" +
|
|
292
|
+
"postgres-mcp: POSTGRES_MCP_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
|
|
217
293
|
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use POSTGRES_MCP_RUNTIME=node.\n",
|
|
218
294
|
);
|
|
219
295
|
process.exit(1);
|
|
220
296
|
}
|
|
297
|
+
// auto: falling back is correct, but silence is how someone never learns
|
|
298
|
+
// their oam install is a shape this launcher skips.
|
|
299
|
+
if (oamShim) await errSync(`postgres-mcp: ${shimNote}Using Node instead.\n`);
|
|
221
300
|
await runInProcess();
|
|
222
|
-
} else if (!atLeast(
|
|
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.
|
|
301
|
+
} else if (!atLeast(found, OAM_MIN)) {
|
|
226
302
|
const min = OAM_MIN.join(".");
|
|
303
|
+
// Two different causes reach this branch and they need different
|
|
304
|
+
// remedies. `found === null` is NOT "old": oamVersion returns null when
|
|
305
|
+
// the binary could not be run at all (not executable, wrong arch, a
|
|
306
|
+
// .cmd/.bat Node refuses, deleted between the stat and the probe) or
|
|
307
|
+
// when its --version output did not parse. Telling that user to
|
|
308
|
+
// `oam self-update` sends them after the one cause it definitely is not.
|
|
309
|
+
const detail = found
|
|
310
|
+
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
311
|
+
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
312
|
+
const remedy = found
|
|
313
|
+
? "Run \`oam self-update\`, or use POSTGRES_MCP_RUNTIME=node.\n"
|
|
314
|
+
: "Check that it is an executable oam binary for this platform, or use POSTGRES_MCP_RUNTIME=node.\n";
|
|
227
315
|
if (mode === "oam") {
|
|
228
|
-
|
|
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
|
-
);
|
|
316
|
+
await errSync(`postgres-mcp: POSTGRES_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
234
317
|
process.exit(1);
|
|
235
318
|
}
|
|
236
|
-
// auto:
|
|
237
|
-
// a silent downgrade is how someone keeps running an oam they
|
|
238
|
-
//
|
|
239
|
-
|
|
319
|
+
// auto: neither cause is worth failing over -- prefer Node. Say so,
|
|
320
|
+
// because a silent downgrade is how someone keeps running an oam they
|
|
321
|
+
// meant to update, or never learns their oam is unexecutable.
|
|
322
|
+
await errSync(`postgres-mcp: ${detail}; using Node instead.\n`);
|
|
240
323
|
await runInProcess();
|
|
241
324
|
} else {
|
|
242
325
|
// `--` separates oam's own flags from the script's argv. Everything after
|
|
243
326
|
// it lands in process.argv for the server, so `postgres-mcp version` and
|
|
244
327
|
// any host-supplied flags survive the hop unchanged.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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;
|
|
328
|
+
// Every "oam could not be executed" outcome lands here: the synchronous
|
|
329
|
+
// throw from spawn() and the async 'error' event mean the same thing and
|
|
330
|
+
// must degrade the same way, so the handling lives in one place.
|
|
331
|
+
// errSync rather than process.stderr.write because stderr is async for
|
|
332
|
+
// TTYs and pipes on Windows and the process.exit below truncates pending
|
|
333
|
+
// writes.
|
|
334
|
+
const launchFailed = async (err) => {
|
|
264
335
|
if (mode === "oam") {
|
|
265
|
-
|
|
336
|
+
await errSync(`postgres-mcp: failed to launch oam (${err?.message ?? err})\n`);
|
|
266
337
|
process.exit(1);
|
|
267
338
|
}
|
|
268
|
-
|
|
269
|
-
}
|
|
339
|
+
await runInProcess();
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// ONE reporter shared by both launchFailed call sites, so the sync-throw
|
|
343
|
+
// path and the 'error'-event path cannot drift apart. Either can reject:
|
|
344
|
+
// runInProcess() is a bare import() that rejects when dist/index.js is
|
|
345
|
+
// missing, and at ESM top level an unhandled rejection is an uncaught
|
|
346
|
+
// exception -- the exact failure this handling exists to prevent.
|
|
347
|
+
const fallbackFailed = (e) => {
|
|
348
|
+
process.stderr.write(`postgres-mcp: fallback to Node failed (${e?.message ?? e})\n`);
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
};
|
|
270
351
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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;
|
|
299
|
-
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
300
|
-
process.on(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);
|
|
352
|
+
let child = null;
|
|
353
|
+
try {
|
|
354
|
+
child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
355
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
356
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
357
|
+
// server's shutdown path.
|
|
358
|
+
stdio: "inherit",
|
|
359
|
+
env: process.env,
|
|
360
|
+
windowsHide: true,
|
|
311
361
|
});
|
|
362
|
+
} catch (err) {
|
|
363
|
+
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
364
|
+
// 'error' listener is registered AFTER this call, so it can never observe
|
|
365
|
+
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
366
|
+
// instead of falling back to Node.
|
|
367
|
+
await launchFailed(err).catch(fallbackFailed);
|
|
312
368
|
}
|
|
313
369
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
370
|
+
if (child) {
|
|
371
|
+
|
|
372
|
+
// If oam cannot be executed at all (deleted between the stat and the
|
|
373
|
+
// spawn, wrong arch, permission), fall back rather than failing the whole
|
|
374
|
+
// server. `spawned` guards against falling back AFTER the child has begun
|
|
375
|
+
// running, which would double-start the server.
|
|
376
|
+
let spawned = false;
|
|
377
|
+
child.on("spawn", () => {
|
|
378
|
+
spawned = true;
|
|
379
|
+
});
|
|
380
|
+
child.on("error", (err) => {
|
|
381
|
+
if (spawned) return;
|
|
382
|
+
// Handle the rejection instead of discarding it: a failing in-process
|
|
383
|
+
// fallback would otherwise escape as an unhandled rejection, replacing
|
|
384
|
+
// this launcher's diagnostic with a raw stack trace.
|
|
385
|
+
launchFailed(err).catch(fallbackFailed);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
389
|
+
// rather than the child being orphaned.
|
|
390
|
+
//
|
|
391
|
+
// Registering ANY handler for these suppresses Node's default
|
|
392
|
+
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
393
|
+
// `child.killed` only records that kill() was CALLED, never that the child
|
|
394
|
+
// is gone, so gating on it swallows every signal after the first and wedges
|
|
395
|
+
// the launcher with no escape hatch.
|
|
396
|
+
//
|
|
397
|
+
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
398
|
+
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
399
|
+
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
400
|
+
// "a second signal" as impatience hard-kills a child that is already
|
|
401
|
+
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
402
|
+
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
403
|
+
// a wall-clock step cannot mis-gate the window either.
|
|
404
|
+
//
|
|
405
|
+
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
406
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
407
|
+
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
408
|
+
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
409
|
+
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
410
|
+
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
411
|
+
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
412
|
+
// child's process.on("exit") cleanup. The console has already notified the
|
|
413
|
+
// child, so on Windows the timer below is the only kill we issue.
|
|
414
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
415
|
+
let escalation = null;
|
|
416
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
417
|
+
process.on(sig, () => {
|
|
418
|
+
// No try/catch: kill() on an already-exited child returns false, it does
|
|
419
|
+
// not throw. It throws only for a signal the platform does not know,
|
|
420
|
+
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
421
|
+
if (!isWin) child.kill(sig);
|
|
422
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
423
|
+
escalation = setTimeout(() => {
|
|
424
|
+
// Still here after its grace window. Stop waiting on it.
|
|
425
|
+
child.kill("SIGKILL");
|
|
426
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
427
|
+
}, ESCALATE_AFTER_MS);
|
|
428
|
+
});
|
|
320
429
|
}
|
|
321
|
-
|
|
322
|
-
|
|
430
|
+
|
|
431
|
+
child.on("exit", (code, signal) => {
|
|
432
|
+
if (escalation) clearTimeout(escalation);
|
|
433
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
434
|
+
// conventional shell exit status rather than a bare 0.
|
|
435
|
+
if (signal) {
|
|
436
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
437
|
+
}
|
|
438
|
+
process.exit(code ?? 0);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
323
441
|
}
|
|
324
442
|
}
|