@yawlabs/ssh-mcp 0.13.0 → 0.14.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.
Files changed (2) hide show
  1. package/bin/ssh-mcp.mjs +68 -1
  2. package/package.json +1 -1
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
+ * NO SANDBOX HERE -- DELIBERATELY
26
+ * The purpose of this server is to open outbound SSH to hosts the caller names
27
+ * at run time and run commands there, so the net and child-process grants would
28
+ * both have to be unrestricted, and key material plus known_hosts need the
29
+ * filesystem. Nothing meaningful is left to deny, so `--permission` is not
30
+ * wired up here.
31
+ *
32
+ * MINIMUM OAM VERSION
33
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
34
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
35
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
36
+ * behaved as `'pipe'`. This server shells out to a CLI on its
37
+ * main paths, so those were reachable bugs rather than theoretical ones: an
38
+ * argument containing shell metacharacters was re-split and executed.
39
+ * An older oam is not an error: the launcher falls back to Node and says so on
40
+ * stderr. Pinning the floor here is what makes that fallback automatic.
41
+ *
25
42
  * SELECTION
26
43
  * SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
44
  * SSH_MCP_RUNTIME=node never use oam
@@ -29,12 +46,15 @@
29
46
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
47
  */
31
48
 
32
- import { spawn } from "node:child_process";
49
+ import { execFileSync, spawn } from "node:child_process";
33
50
  import { existsSync } from "node:fs";
34
51
  import { constants, homedir } from "node:os";
35
52
  import { delimiter, join } from "node:path";
36
53
  import { fileURLToPath } from "node:url";
37
54
 
55
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
56
+ const OAM_MIN = [0, 9, 0];
57
+
38
58
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
59
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
40
60
  // in-process fallback must use the file:// URL. spawn() needs a real path.
@@ -83,6 +103,34 @@ function findOam() {
83
103
  return null;
84
104
  }
85
105
 
106
+ /**
107
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
108
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
109
+ */
110
+ function oamVersion(cmd) {
111
+ try {
112
+ const out = execFileSync(cmd, ["--version"], {
113
+ encoding: "utf-8",
114
+ stdio: ["ignore", "pipe", "ignore"],
115
+ });
116
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
117
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
118
+ } catch {
119
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
125
+ function atLeast(v, min) {
126
+ if (!v) return false;
127
+ for (let i = 0; i < min.length; i++) {
128
+ if (v[i] > min[i]) return true;
129
+ if (v[i] < min[i]) return false;
130
+ }
131
+ return true;
132
+ }
133
+
86
134
  /** Run the server in THIS process. The zero-overhead fallback. */
87
135
  async function runInProcess() {
88
136
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -120,6 +168,25 @@ if (mode === "node") {
120
168
  process.exit(1);
121
169
  }
122
170
  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.
175
+ const min = OAM_MIN.join(".");
176
+ 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
+ );
183
+ process.exit(1);
184
+ }
185
+ // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
186
+ // 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`);
189
+ await runInProcess();
123
190
  } else {
124
191
  // `--` separates oam's own flags from the script's argv, so `ssh-mcp
125
192
  // --version` and any host-supplied flags survive the hop unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "MCP server for SSH operations with built-in diagnostics",
6
6
  "type": "module",