@yawlabs/ssh-mcp 0.14.0 → 0.14.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.
Files changed (2) hide show
  1. package/bin/ssh-mcp.mjs +237 -67
  2. package/package.json +1 -1
package/bin/ssh-mcp.mjs CHANGED
@@ -63,11 +63,21 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
63
63
  const isWin = process.platform === "win32";
64
64
  const exe = isWin ? "oam.exe" : "oam";
65
65
 
66
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
66
+ /**
67
+ * Locate an oam binary. Returns `{ path, shim }`:
68
+ * path -- an oam this launcher can actually execute, or null
69
+ * shim -- an oam-named `.cmd`/`.bat` seen on PATH and SKIPPED, or null
70
+ *
71
+ * The shim is reported rather than silently dropped: "no oam binary was found"
72
+ * is the wrong thing to tell someone who has one installed in a shape we cannot
73
+ * spawn. Every branch is a stat, never a subprocess.
74
+ */
67
75
  function findOam() {
68
- // 1. Explicit override wins and is never second-guessed.
76
+ // 1. Explicit override wins and is never second-guessed -- including a .cmd.
77
+ // If it cannot be executed the version gate reports that specifically,
78
+ // which is better than second-guessing an explicit instruction here.
69
79
  const override = process.env.OAM_BIN;
70
- if (override) return existsSync(override) ? override : null;
80
+ if (override) return { path: existsSync(override) ? override : null, shim: null };
71
81
 
72
82
  // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
73
83
  // usually has oam/target/release on PATH, and a build directory is the
@@ -78,7 +88,7 @@ function findOam() {
78
88
  // point deliberately at a dev build.
79
89
  //
80
90
  // Both forms are checked on Windows: the installer defaults to
81
- // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
91
+ // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
82
92
  // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
83
93
  // install.
84
94
  const installed = [join(homedir(), ".oam", "bin", exe)];
@@ -86,21 +96,65 @@ function findOam() {
86
96
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
87
97
  }
88
98
  for (const candidate of installed) {
89
- if (existsSync(candidate)) return candidate;
99
+ if (existsSync(candidate)) return { path: candidate, shim: null };
90
100
  }
91
101
 
92
102
  // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
93
103
  // would cost a subprocess on every launch just to decide whether to spawn.
94
- const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
104
+ //
105
+ // Windows: only `.exe` is RETURNED -- deliberately narrower than PATHEXT.
106
+ // Node refuses to run a `.cmd`/`.bat` through execFile/spawn without
107
+ // `shell: true` (EINVAL, and for spawn it throws SYNCHRONOUSLY rather than
108
+ // emitting 'error'), so returning one would hand back a path this launcher
109
+ // cannot execute -- discovery has to agree with execution. `exe` is also
110
+ // what the installed-location checks above look for, so both discovery
111
+ // paths accept exactly the same shapes.
112
+ //
113
+ // A shim is still NOTED, though. An npm-style install puts `oam.cmd` on
114
+ // PATH, and staying silent about it means auto mode degrades with no
115
+ // explanation and `SSH_MCP_RUNTIME=oam` claims nothing was found -- both
116
+ // of which send someone to reinstall an oam they already have.
117
+ let shim = null;
95
118
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
96
119
  if (!dir) continue;
97
- for (const ext of isWin ? pathExt : [""]) {
98
- const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
99
- if (existsSync(candidate)) return candidate;
120
+ const candidate = join(dir, exe);
121
+ if (existsSync(candidate)) return { path: candidate, shim: null };
122
+ if (isWin && shim === null) {
123
+ for (const ext of [".cmd", ".bat"]) {
124
+ const alt = join(dir, `oam${ext}`);
125
+ if (existsSync(alt)) {
126
+ shim = alt;
127
+ break;
128
+ }
129
+ }
100
130
  }
101
131
  }
102
132
 
103
- return null;
133
+ return { path: null, shim };
134
+ }
135
+
136
+ /**
137
+ * Write a diagnostic to stderr synchronously, so a following process.exit
138
+ * cannot truncate it.
139
+ *
140
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
141
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
142
+ * there rather than blocking the write. Loop over the remaining bytes, and if
143
+ * stderr turns out to be unusable give up quietly -- failing to print a
144
+ * diagnostic is not worth crashing a stdio server over.
145
+ */
146
+ async function errSync(message) {
147
+ const { writeSync } = await import("node:fs");
148
+ const buf = Buffer.from(message);
149
+ let off = 0;
150
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
151
+ try {
152
+ off += writeSync(2, buf, off, buf.length - off);
153
+ } catch (err) {
154
+ if (err?.code !== "EAGAIN") return;
155
+ // Pipe is full and the reader has not drained yet -- retry.
156
+ }
157
+ }
104
158
  }
105
159
 
106
160
  /**
@@ -152,85 +206,201 @@ const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
152
206
  if (mode === "node") {
153
207
  await runInProcess();
154
208
  } else {
155
- const oam = findOam();
209
+ const { path: oam, shim: oamShim } = findOam();
210
+ // Read the version ONCE, and only when discovery found something: the gate
211
+ // below has to tell "too old" apart from "could not be read at all", and
212
+ // re-probing inside the branch would cost a second subprocess.
213
+ //
214
+ // Discovery itself stays stat-only; this is the first subprocess. It is paid
215
+ // on every launch that finds an oam -- including the ones that go on to fall
216
+ // back to Node -- not only the ones that end up spawning it. Measured 26ms
217
+ // median (n=12, windows-arm64), once per MCP session.
218
+ const found = oam ? oamVersion(oam) : null;
156
219
 
157
220
  if (!oam) {
221
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this launcher
222
+ // cannot spawn. Naming it turns "no oam binary was found" -- which reads as
223
+ // "install oam", the one thing that will not help -- into something the user
224
+ // can act on.
225
+ const shimNote = oamShim
226
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
227
+ "Install the native oam binary, or point OAM_BIN at one.\n"
228
+ : "";
158
229
  if (mode === "oam") {
159
- // Explicitly demanded, so this is a real misconfiguration. writeSync
160
- // because stderr is async for TTYs/pipes on Windows and process.exit
161
- // truncates pending writes.
162
- const { writeSync } = await import("node:fs");
163
- writeSync(
164
- 2,
165
- "ssh-mcp: SSH_MCP_RUNTIME=oam but no oam binary was found.\n" +
230
+ // Explicitly demanded, so this is a real misconfiguration.
231
+ await errSync(
232
+ `ssh-mcp: SSH_MCP_RUNTIME=oam but no runnable oam binary was found.\n${shimNote}` +
166
233
  "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use SSH_MCP_RUNTIME=node.\n",
167
234
  );
168
235
  process.exit(1);
169
236
  }
237
+ // auto: falling back is correct, but silence is how someone never learns
238
+ // their oam install is a shape this launcher skips. Only worth saying when
239
+ // there was actually something to skip.
240
+ if (oamShim) await errSync(`ssh-mcp: ${shimNote}Using Node instead.\n`);
170
241
  await runInProcess();
171
- } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
172
- // Discovery itself stays stat-only; this is the first subprocess, and it
173
- // runs only once we have already decided to spawn oam anyway. Measured 26ms
174
- // median (n=12, windows-arm64), paid once per MCP session.
242
+ } else if (!atLeast(found, OAM_MIN)) {
175
243
  const min = OAM_MIN.join(".");
244
+ // Two different causes reach this branch and they need different remedies.
245
+ // `found === null` is NOT "old": oamVersion returns null when the binary
246
+ // could not be run at all (not executable, wrong arch, a .cmd/.bat Node
247
+ // refuses, deleted between the stat and the probe) or when its --version
248
+ // output did not parse. Telling that user to `oam self-update` sends them
249
+ // after the one cause it definitely is not, so the wording splits here.
250
+ const detail = found
251
+ ? `${oam} is oam ${found.join(".")}, older than ${min}`
252
+ : `${oam} could not be run, or did not report a version this launcher understands`;
253
+ const remedy = found
254
+ ? "Run `oam self-update`, or use SSH_MCP_RUNTIME=node.\n"
255
+ : "Check that it is an executable oam binary for this platform, or use SSH_MCP_RUNTIME=node.\n";
176
256
  if (mode === "oam") {
177
- const { writeSync } = await import("node:fs");
178
- writeSync(
179
- 2,
180
- `ssh-mcp: SSH_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
181
- `Run \`oam self-update\`, or use SSH_MCP_RUNTIME=node.\n`,
182
- );
257
+ await errSync(`ssh-mcp: SSH_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
183
258
  process.exit(1);
184
259
  }
185
- // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
260
+ // auto: neither cause is worth failing over -- prefer Node. Say so, because
186
261
  // a silent downgrade is how someone keeps running an oam they meant to
187
- // update. stderr is safe -- MCP frames travel on stdout.
188
- process.stderr.write(`ssh-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
262
+ // update, or never learns their oam is unexecutable. stdout carries the MCP
263
+ // frames, so stderr is the only safe channel.
264
+ //
265
+ // errSync, not process.stderr.write: an exit DOES follow, just indirectly.
266
+ // runInProcess() imports dist/index.js, whose top level answers `--version`
267
+ // with console.log + process.exit(0) (src/index.ts) -- and that exit
268
+ // truncates a pending async stderr write on Windows TTYs and pipes.
269
+ await errSync(`ssh-mcp: ${detail}; using Node instead.\n`);
189
270
  await runInProcess();
190
271
  } else {
191
- // `--` separates oam's own flags from the script's argv, so `ssh-mcp
192
- // --version` and any host-supplied flags survive the hop unchanged.
193
- const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
194
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
195
- // stdin/stdout is untouched and the host's stdin-close still reaches the
196
- // server's shutdown path.
197
- stdio: "inherit",
198
- env: process.env,
199
- windowsHide: true,
200
- });
201
-
202
- // If oam cannot be executed at all (deleted between the stat and the spawn,
203
- // wrong arch, permission), fall back rather than failing the whole server.
204
- // `spawned` prevents falling back AFTER the child started, which would
205
- // double-start the server on the same stdio.
206
- let spawned = false;
207
- child.on("spawn", () => {
208
- spawned = true;
209
- });
210
- child.on("error", (err) => {
211
- if (spawned) return;
272
+ // Every "oam could not be executed" outcome lands here: the synchronous
273
+ // throw from spawn() and the async 'error' event both mean the same thing
274
+ // and must degrade the same way, so the handling lives in one place.
275
+ // errSync rather than process.stderr.write because stderr is async for
276
+ // TTYs and pipes on Windows and the process.exit below truncates pending
277
+ // writes -- the same reason the two branches above use it.
278
+ const launchFailed = async (err) => {
212
279
  if (mode === "oam") {
213
- process.stderr.write(`ssh-mcp: failed to launch oam (${err.message})\n`);
280
+ await errSync(`ssh-mcp: failed to launch oam (${err?.message ?? err})\n`);
214
281
  process.exit(1);
215
282
  }
216
- void runInProcess();
217
- });
283
+ await runInProcess();
284
+ };
285
+
286
+ // ONE reporter shared by both launchFailed call sites below, so the
287
+ // sync-throw path and the 'error'-event path cannot drift apart. Either can
288
+ // reject: in auto mode launchFailed awaits runInProcess(), a bare import()
289
+ // that rejects whenever dist/index.js is missing or throws at load. At ESM
290
+ // top level an unhandled rejection is an uncaught exception -- it kills the
291
+ // process and replaces this launcher's diagnostic with a raw stack trace,
292
+ // which is the exact failure this handling exists to prevent.
293
+ const fallbackFailed = (e) => {
294
+ process.stderr.write(`ssh-mcp: fallback to Node failed (${e?.message ?? e})\n`);
295
+ process.exitCode = 1;
296
+ };
218
297
 
219
- // Forward termination so the server's own shutdown path runs in the child
220
- // rather than the child being orphaned. No-op on Windows, harmless to add.
221
- for (const sig of ["SIGINT", "SIGTERM"]) {
222
- process.on(sig, () => {
223
- if (!child.killed) child.kill(sig);
298
+ // `--` separates oam's own flags from the script's argv, so `ssh-mcp
299
+ // --version` and any host-supplied flags survive the hop unchanged.
300
+ let child = null;
301
+ try {
302
+ child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
303
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
304
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
305
+ // server's shutdown path.
306
+ stdio: "inherit",
307
+ env: process.env,
308
+ windowsHide: true,
224
309
  });
310
+ } catch (err) {
311
+ // spawn() THROWS for some failures instead of emitting 'error', and the
312
+ // 'error' listener is registered AFTER this call, so it can never observe
313
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
314
+ // instead of falling back to Node.
315
+ //
316
+ // Belt-and-braces, deliberately: reaching this line already means
317
+ // execFileSync ran this same binary and read a version from it, so the
318
+ // shapes that throw synchronously (a .cmd/.bat Node refuses with EINVAL)
319
+ // have been diverted by the version gate above, and the ones the comments
320
+ // below name -- deleted (ENOENT), permission (EACCES) -- are among the
321
+ // errnos Node routes to the async 'error' event instead. What is left is
322
+ // a genuine TOCTOU: the binary replaced between the probe and the spawn.
323
+ // Cheap to keep, and the alternative is a stack trace in a stdio server.
324
+ await launchFailed(err).catch(fallbackFailed);
225
325
  }
226
326
 
227
- child.on("exit", (code, signal) => {
228
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
229
- // conventional shell exit status rather than a bare 0.
230
- if (signal) {
231
- process.exit(128 + (constants.signals[signal] ?? 15));
327
+ if (child) {
328
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
329
+ // wrong arch, permission), fall back rather than failing the whole server.
330
+ // `spawned` prevents falling back AFTER the child started, which would
331
+ // double-start the server on the same stdio.
332
+ let spawned = false;
333
+ child.on("spawn", () => {
334
+ spawned = true;
335
+ });
336
+ child.on("error", (err) => {
337
+ if (spawned) return;
338
+ // Handle the rejection instead of discarding the promise: a failing
339
+ // runInProcess() used to escape as an unhandled rejection, replacing
340
+ // this launcher's diagnostic with a raw stack trace.
341
+ launchFailed(err).catch(fallbackFailed);
342
+ });
343
+
344
+ // Forward termination so the server's own shutdown path runs in the child
345
+ // rather than the child being orphaned.
346
+ //
347
+ // Registering ANY handler for these suppresses Node's default
348
+ // terminate-on-signal, so the parent's exit has to be arranged
349
+ // explicitly. `child.killed` only records that kill() was CALLED, never
350
+ // that the child is gone, so gating on it swallows every signal after the
351
+ // first and wedges the launcher with no escape hatch.
352
+ //
353
+ // Escalation is driven by a TIMER, not by counting signals, and not by
354
+ // comparing timestamps. Counting is ambiguous: a supervisor routinely
355
+ // sends SIGINT then SIGTERM milliseconds apart, and a terminal Ctrl-C
356
+ // reaches the whole process group, so the child usually gets its own copy
357
+ // alongside ours -- reading "a second signal" as impatience hard-kills a
358
+ // child that is already shutting down cleanly. A timer makes the count
359
+ // irrelevant: ONE press is enough, and a wedged child dies on schedule
360
+ // without the user having to guess how many times to press. It also
361
+ // sidesteps the wall clock -- setTimeout is monotonic, so a clock step
362
+ // cannot mis-gate the window in either direction.
363
+ //
364
+ // POSIX vs Windows, and why we do not forward on Windows.
365
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so
366
+ // forwarding is what lets the child run its shutdown. On Windows there
367
+ // are no POSIX signals: child.kill IGNORES the name and calls
368
+ // TerminateProcess -- an immediate hard kill (verified: a child with a
369
+ // SIGTERM handler never runs it and dies with code=null). Forwarding
370
+ // there would ABORT the graceful shutdown the console's own Ctrl-C just
371
+ // started, skipping the child's process.on("exit") backstop -- which is
372
+ // what reaps an ssh-agent this server spawned (killStartedAgent,
373
+ // src/env.ts) -- and leak the daemon. The console has already notified
374
+ // the child, so on Windows the timer below is the only kill we issue.
375
+ //
376
+ // The window comfortably exceeds the child's own shutdown budget
377
+ // (server.close -> pool.drain -> killStartedAgent -> ~100ms FIN grace).
378
+ const ESCALATE_AFTER_MS = 2000;
379
+ let escalation = null;
380
+ for (const sig of ["SIGINT", "SIGTERM"]) {
381
+ process.on(sig, () => {
382
+ // No try/catch: kill() on an already-exited child returns false, it
383
+ // does not throw. It throws only for a signal the platform does not
384
+ // know, which SIGINT/SIGTERM/SIGKILL never are.
385
+ if (!isWin) child.kill(sig);
386
+ if (escalation) return; // already counting down; further signals are noise
387
+ escalation = setTimeout(() => {
388
+ // Still here after its grace window. Stop waiting on it.
389
+ child.kill("SIGKILL");
390
+ process.exit(128 + (constants.signals[sig] ?? 15));
391
+ }, ESCALATE_AFTER_MS);
392
+ });
232
393
  }
233
- process.exit(code ?? 0);
234
- });
394
+
395
+ child.on("exit", (code, signal) => {
396
+ if (escalation) clearTimeout(escalation);
397
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
398
+ // conventional shell exit status rather than a bare 0.
399
+ if (signal) {
400
+ process.exit(128 + (constants.signals[signal] ?? 15));
401
+ }
402
+ process.exit(code ?? 0);
403
+ });
404
+ }
235
405
  }
236
406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "MCP server for SSH operations with built-in diagnostics",
6
6
  "type": "module",