@yawlabs/ssh-mcp 0.15.1 → 0.15.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/README.md CHANGED
@@ -303,10 +303,37 @@ for (const check of report.checks) {
303
303
  }
304
304
  ```
305
305
 
306
+ ## Runtime selection
307
+
308
+ The `ssh-mcp` command — what `npx @yawlabs/ssh-mcp` runs — is a small launcher. It prefers the [oam](https://oamjs.org) runtime and otherwise runs the server on Node, so oam is optional. Two environment variables control the choice; set them in your MCP client's `env` block:
309
+
310
+ - `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
311
+ - `auto` (default) — use oam if it is usable, otherwise fall back to Node.
312
+ - `oam` — require oam. Fails loudly: if oam is missing or unusable, the launcher prints the reason to stderr and exits with status 1 instead of starting the server.
313
+ - `node` — never use oam. The launcher does not look for it, and `OAM_BIN` is ignored.
314
+ - `OAM_BIN` — path to a specific oam binary. When set to a non-empty value it wins over discovery and nothing else is searched, so a path that does not exist counts as no oam found even if a working oam is installed elsewhere. Without it, the launcher takes the first oam it finds in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then `PATH`, and does not keep looking if that one is unusable.
315
+
316
+ oam must be **0.9.0 or newer**. By default an unusable oam is not an error: in `auto` mode the launcher falls back to Node — silently when no oam is found (including an `OAM_BIN` path that does not exist), or with a note on stderr when it finds one it cannot use: older than 0.9.0, not runnable, or on Windows only an `oam.cmd`/`oam.bat` shim on `PATH`. With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
317
+
318
+ ```json
319
+ {
320
+ "mcpServers": {
321
+ "ssh": {
322
+ "command": "npx",
323
+ "args": ["-y", "@yawlabs/ssh-mcp@latest"],
324
+ "env": { "SSH_MCP_RUNTIME": "node" }
325
+ }
326
+ }
327
+ }
328
+ ```
329
+
330
+ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#quick-start).
331
+
306
332
  ## Requirements
307
333
 
308
334
  - Node.js 18+
309
335
  - SSH client installed (for diagnostics and environment management)
336
+ - Optional: [oam](https://oamjs.org) 0.9.0+ — see [Runtime selection](#runtime-selection)
310
337
 
311
338
  ## License
312
339
 
package/bin/ssh-mcp.mjs CHANGED
@@ -22,6 +22,23 @@
22
22
  * For an MCP host config, point straight at oam and skip this file:
23
23
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
24
  *
25
+ * ALREADY RUNNING ON OAM
26
+ * A host can resolve this package's `bin` and launch `oam run <this file>`
27
+ * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
28
+ * regression matrix. This launcher used to discover oam and spawn it anyway,
29
+ * so one server cost two runtime boots: measured on Windows, oam.exe with a
30
+ * NESTED oam.exe + conhost.exe underneath it. Now, when `process.versions.oam`
31
+ * clears the same MINIMUM OAM VERSION a discovered binary has to, the server is
32
+ * imported into THIS process exactly as the Node fallback is -- no discovery,
33
+ * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
34
+ * is not consulted on that path: the host has already chosen which oam runs.
35
+ *
36
+ * A host oam below the floor still takes the discovery path exactly as it
37
+ * always did. Nothing else has to keep spawning on oam: the spawn below passes
38
+ * oam no runtime flags -- there is no `--permission` sandbox to apply, see NO
39
+ * SANDBOX HERE -- so serving in-process drops nothing a fresh oam would have
40
+ * applied.
41
+ *
25
42
  * NO SANDBOX HERE -- DELIBERATELY
26
43
  * The purpose of this server is to open outbound SSH to hosts the caller names
27
44
  * at run time and run commands there, so the net and child-process grants would
@@ -41,6 +58,7 @@
41
58
  *
42
59
  * SELECTION
43
60
  * SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
61
+ * (already running on oam satisfies it)
44
62
  * SSH_MCP_RUNTIME=node never use oam
45
63
  * SSH_MCP_RUNTIME=auto prefer oam, silently fall back (default)
46
64
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
@@ -158,17 +176,27 @@ async function errSync(message) {
158
176
  }
159
177
 
160
178
  /**
161
- * `oam --version` -> [major, minor, patch], or null when it cannot be read.
179
+ * Version text -> [major, minor, patch], or null when it holds no version.
162
180
  * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
181
+ *
182
+ * Shared by the two places a version is read -- a discovered binary's
183
+ * `oam --version` output ("oam 0.15.1") and the host's own
184
+ * `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
185
+ * version string means, or which floor it has to clear.
163
186
  */
187
+ function parseVersion(text) {
188
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
189
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
190
+ }
191
+
192
+ /** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
164
193
  function oamVersion(cmd) {
165
194
  try {
166
195
  const out = execFileSync(cmd, ["--version"], {
167
196
  encoding: "utf-8",
168
197
  stdio: ["ignore", "pipe", "ignore"],
169
198
  });
170
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
171
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
199
+ return parseVersion(out);
172
200
  } catch {
173
201
  // Not executable, wrong arch, or deleted since the stat. Caller degrades.
174
202
  return null;
@@ -185,6 +213,27 @@ function atLeast(v, min) {
185
213
  return true;
186
214
  }
187
215
 
216
+ /**
217
+ * Where the server runs, decided BEFORE any discovery:
218
+ * "in-process" import it into THIS process
219
+ * "discover" find an oam binary, gate its version, spawn it -- or fall
220
+ * back to Node in-process when that fails
221
+ *
222
+ * `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
223
+ * Node every mode but `node` is the discovery path it always was. The floor is
224
+ * OAM_MIN itself, not a parameter, so a host oam and a discovered one can never
225
+ * be held to different minimums. There is no sandbox input because this
226
+ * launcher has no sandbox; see ALREADY RUNNING ON OAM above for why nothing
227
+ * else forces a spawn.
228
+ *
229
+ * Pure on purpose: every input is passed in, so the whole decision is testable
230
+ * without booting a runtime.
231
+ */
232
+ function runtimePlan({ mode, hostOam }) {
233
+ if (mode === "node") return "in-process";
234
+ return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
235
+ }
236
+
188
237
  /** Run the server in THIS process. The zero-overhead fallback. */
189
238
  async function runInProcess() {
190
239
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -202,8 +251,9 @@ async function runInProcess() {
202
251
  }
203
252
 
204
253
  const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
254
+ const plan = runtimePlan({ mode, hostOam: process.versions.oam });
205
255
 
206
- if (mode === "node") {
256
+ if (plan === "in-process") {
207
257
  await runInProcess();
208
258
  } else {
209
259
  const { path: oam, shim: oamShim } = findOam();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "SSH MCP server: run remote commands, transfer files over SFTP, manage ssh-agent keys and known_hosts, and auto-diagnose SSH failures.",
6
6
  "type": "module",