@yawlabs/ssh-mcp 0.18.0 → 0.18.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.
- package/README.md +9 -9
- package/bin/ssh-mcp.mjs +39 -12
- package/dist/index.js +12 -5
- package/dist/server.js +12 -5
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -207,7 +207,7 @@ If you don't trust the agent's `env` values at all, the simplest mitigation is t
|
|
|
207
207
|
|
|
208
208
|
On Windows, ssh-mcp uses the OpenSSH Authentication Agent's `\\.\pipe\openssh-ssh-agent` named pipe automatically when `SSH_AUTH_SOCK` is not set. No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
209
209
|
|
|
210
|
-
On the [oam](https://oamjs.org) runtime,
|
|
210
|
+
On the [oam](https://oamjs.org) runtime, the launcher serves only on oam 0.19.1 or newer. Agent auth needs a `net` that can dial a named pipe or a Unix socket (that is how ssh2 reaches the agent), which older oam releases could not do. On oam 0.19.1 (Windows) the MCP handshake, `ssh_config_lookup`, `ssh_key_list` and `ssh_agent_ensure` were verified through the launcher, and ssh2's own agent client was verified dialing a named pipe and listing identities. An agent-only `ssh_exec` end to end has so far been verified on Node only. If agent auth fails on oam, set `SSH_MCP_RUNTIME=node` to rule the runtime out.
|
|
211
211
|
|
|
212
212
|
`ssh_agent_ensure` and `ssh_diagnose` probe that pipe and tell you if the service is down. Remote operations do *not*: they assume the pipe and let the connection fail on its own if the agent isn't there. That is why a stopped agent service shows up as an auth failure rather than an "agent not running" error until you run the diagnostic tools.
|
|
213
213
|
|
|
@@ -328,27 +328,27 @@ for (const check of report.checks) {
|
|
|
328
328
|
|
|
329
329
|
## Runtime selection
|
|
330
330
|
|
|
331
|
-
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. Its minimum is the **latest oam release, currently 0.
|
|
331
|
+
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. Its minimum is the **latest oam release, currently 0.19.1**, and it never serves on an older oam.
|
|
332
332
|
|
|
333
333
|
Two environment variables control the choice; set them in your MCP client's `env` block:
|
|
334
334
|
|
|
335
335
|
- `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
|
|
336
|
-
- `auto` (default) — use the newest oam it can find at 0.
|
|
336
|
+
- `auto` (default) — use the newest oam it can find at 0.19.1 or newer, otherwise run on Node.
|
|
337
337
|
- `oam` — require oam. Fails loudly: if no usable oam is found, the launcher prints what it found and why each one was passed over to stderr, and exits with status 1 instead of starting the server.
|
|
338
338
|
- `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
|
|
339
|
-
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.
|
|
339
|
+
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.19.1 or newer. If it does not exist, is older, will not run, or does not answer `--version` with oam's own `oam x.y.z` banner (another program's version, such as Node's `v22.x.y`, does not count), the launcher says so on stderr and carries on with discovery.
|
|
340
340
|
|
|
341
|
-
Discovery looks in `$OAM_INSTALL_DIR` (when set — the oam installer's install target), then `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.
|
|
341
|
+
Discovery looks in `$OAM_INSTALL_DIR` (when set — the oam installer's install target), then `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version, counting only an answer that carries oam's own `oam x.y.z` banner; and uses the newest one at 0.19.1 or newer. On a tie the one found first wins, so an installed copy beats one on `PATH`. On Windows only `oam.exe` counts: an `oam.cmd`/`oam.bat` shim on `PATH` is never run, and is named on stderr when no usable oam is found.
|
|
342
342
|
|
|
343
|
-
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.
|
|
343
|
+
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.19.1, not runnable, or a shim. A chosen oam that then fails to start is named on stderr too. When an older oam starts the launcher, the handoff to Node is always noted on stderr (see below). With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
|
|
344
344
|
|
|
345
345
|
When the launcher is itself started by oam (`oam run <path>/bin/ssh-mcp.mjs`, which is how Yaw MCP starts an `npx @yawlabs/ssh-mcp` entry when a recent oam is installed):
|
|
346
346
|
|
|
347
|
-
- On oam 0.
|
|
347
|
+
- On oam 0.19.1 or newer it runs the server on that oam, in the same process. Nothing is discovered or spawned, `OAM_BIN` is not read, and `SSH_MCP_RUNTIME=oam` counts it as the oam it requires.
|
|
348
348
|
- On an older oam it never runs the server there. It hands the server off to the newest usable oam it can find, or to Node on `PATH`, or exits with status 1 when there is neither (`SSH_MCP_RUNTIME=oam` exits with status 1 rather than handing off to Node).
|
|
349
349
|
- With `SSH_MCP_RUNTIME=node` it hands the server off to Node on `PATH`, whatever the oam version.
|
|
350
350
|
|
|
351
|
-
Whenever an oam host starts a child (the handoffs above), the launcher removes `--permission` and `--allow-*` flags from the child's inherited `NODE_OPTIONS`. oam 0.18.0 adds its own permission flags to every child's `NODE_OPTIONS`, and Node refuses some of them there (`--allow-net is not allowed in NODE_OPTIONS`, exit 9); this server is not sandboxed, so there is nothing to pass on. An oam that was itself started with `--permission` re-adds the flags inside `spawn()`, which the launcher cannot undo, so do not start it that way.
|
|
351
|
+
Whenever an oam host starts a child (the handoffs above), the launcher removes `--permission` and `--allow-*` flags from the child's inherited `NODE_OPTIONS`. oam (since 0.18.0) adds its own permission flags to every child's `NODE_OPTIONS`, and Node refuses some of them there (`--allow-net is not allowed in NODE_OPTIONS`, exit 9); this server is not sandboxed, so there is nothing to pass on. An oam that was itself started with `--permission` re-adds the flags inside `spawn()`, which the launcher cannot undo, so do not start it that way.
|
|
352
352
|
|
|
353
353
|
For example, to always run on Node:
|
|
354
354
|
|
|
@@ -370,7 +370,7 @@ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#qu
|
|
|
370
370
|
|
|
371
371
|
- Node.js 18+
|
|
372
372
|
- SSH client installed (for diagnostics and environment management)
|
|
373
|
-
- Optional: [oam](https://oamjs.org) 0.
|
|
373
|
+
- Optional: [oam](https://oamjs.org) 0.19.1+ — see [Runtime selection](#runtime-selection)
|
|
374
374
|
|
|
375
375
|
## License
|
|
376
376
|
|
package/bin/ssh-mcp.mjs
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* named on stderr, and discovery carries on. It used to stop everything: a typo
|
|
41
41
|
* in OAM_BIN meant Node, with no hint why. The discovered binaries that were
|
|
42
42
|
* passed over, and any .cmd/.bat shim on PATH, are named only when NO usable
|
|
43
|
-
* oam is found -- with 0.9.0 installed and 0.
|
|
43
|
+
* oam is found -- with 0.9.0 installed and 0.19.1 on PATH, stderr stays empty.
|
|
44
44
|
*
|
|
45
45
|
* ALREADY RUNNING ON OAM
|
|
46
46
|
* A host can resolve this package's `bin` and launch `oam run <this file>`
|
|
@@ -77,11 +77,14 @@
|
|
|
77
77
|
* wired up here.
|
|
78
78
|
*
|
|
79
79
|
* MINIMUM OAM VERSION
|
|
80
|
-
* The latest oam release, 0.
|
|
80
|
+
* The latest oam release, 0.19.1 -- bump OAM_MIN when oam ships a newer one.
|
|
81
81
|
* Only the current oam is used and verified; an older one is passed over.
|
|
82
|
+
* Verified on 0.19.1 through this launcher (`oam run bin/ssh-mcp.mjs`): MCP
|
|
83
|
+
* initialize, tools/list and tools/call on ssh_key_list, ssh_config_lookup and
|
|
84
|
+
* ssh_agent_ensure, plus ssh2's agent client dialing a Windows named pipe.
|
|
82
85
|
*
|
|
83
|
-
* The floor is not cosmetic. Two things this server does
|
|
84
|
-
* so anyone
|
|
86
|
+
* The floor is not cosmetic. Two things this server does were first fixed in
|
|
87
|
+
* oam 0.18.0, so anyone taking it below that has to answer for both:
|
|
85
88
|
*
|
|
86
89
|
* 1. An explicit child `env` that DELETES a variable. probeAgent
|
|
87
90
|
* (src/env.ts:85-90) runs `ssh-add -l` with SSH_AUTH_SOCK removed, so
|
|
@@ -96,6 +99,13 @@
|
|
|
96
99
|
* `new Socket().connect(path)` for every agent-backed connection. Up to
|
|
97
100
|
* 0.17.x oam could not dial either (oam CHANGELOG 0.18.0, F1).
|
|
98
101
|
*
|
|
102
|
+
* What moving to 0.19.1 changed here: oam used to start EVERY child with
|
|
103
|
+
* CREATE_NO_WINDOW and ignore `windowsHide`; 0.19.1 honours `windowsHide` as
|
|
104
|
+
* Node does, so by default a child shares the console (oam CHANGELOG 0.19.1).
|
|
105
|
+
* runArgs (src/diagnose.ts) and runArgsWithEnv (src/env.ts) therefore pass
|
|
106
|
+
* `windowsHide: true` themselves, or every ssh / ssh-add / ssh-keygen probe could
|
|
107
|
+
* flash a console window on Windows.
|
|
108
|
+
*
|
|
99
109
|
* Background, long before the current floor: before 0.9.0
|
|
100
110
|
* `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
|
|
101
111
|
* `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
|
|
@@ -120,12 +130,13 @@ import { delimiter, join } from "node:path";
|
|
|
120
130
|
import { fileURLToPath } from "node:url";
|
|
121
131
|
|
|
122
132
|
/**
|
|
123
|
-
* Oldest oam this server runs on: the one
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* OAM VERSION
|
|
133
|
+
* Oldest oam this server runs on: the latest release, the one it is verified on.
|
|
134
|
+
* It must also keep 0.18.0's explicit child `env` that replaces the environment
|
|
135
|
+
* (needed by probeAgent, src/env.ts) and `net` dialing named pipes and Unix
|
|
136
|
+
* sockets (needed by ssh2's agent client, src/ssh.ts). See MINIMUM OAM VERSION
|
|
137
|
+
* above.
|
|
127
138
|
*/
|
|
128
|
-
const OAM_MIN = [0,
|
|
139
|
+
const OAM_MIN = [0, 19, 1];
|
|
129
140
|
|
|
130
141
|
/**
|
|
131
142
|
* Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
|
|
@@ -227,7 +238,7 @@ async function errSync(message) {
|
|
|
227
238
|
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
|
|
228
239
|
*
|
|
229
240
|
* Shared by the two places a version is read -- a discovered binary's
|
|
230
|
-
* `oam --version`
|
|
241
|
+
* `oam --version` banner ("oam 0.15.1", via oamBanner) and the host's own
|
|
231
242
|
* `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
|
|
232
243
|
* version string means, or which floor it has to clear.
|
|
233
244
|
*/
|
|
@@ -236,6 +247,19 @@ function parseVersion(text) {
|
|
|
236
247
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
237
248
|
}
|
|
238
249
|
|
|
250
|
+
/**
|
|
251
|
+
* `oam --version` output -> [major, minor, patch], or null unless it carries
|
|
252
|
+
* oam's own banner ("oam 0.19.1" at the start of a line). Any x.y.z is NOT
|
|
253
|
+
* enough: an OAM_BIN (or an oam.exe on disk) that is really another program --
|
|
254
|
+
* node prints "v22.22.2" -- would clear the floor, and the launcher would then
|
|
255
|
+
* spawn `<it> run dist/index.js`, which dies with no Node fallback because the
|
|
256
|
+
* spawn itself succeeded. process.versions.oam needs no banner: only oam sets it.
|
|
257
|
+
*/
|
|
258
|
+
function oamBanner(text) {
|
|
259
|
+
const m = /^oam\s+(\d+\.\d+\.\d+)/m.exec(text);
|
|
260
|
+
return m ? parseVersion(m[1]) : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
239
263
|
/** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
|
|
240
264
|
function oamVersion(cmd) {
|
|
241
265
|
try {
|
|
@@ -245,7 +269,7 @@ function oamVersion(cmd) {
|
|
|
245
269
|
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
246
270
|
windowsHide: true,
|
|
247
271
|
});
|
|
248
|
-
return
|
|
272
|
+
return oamBanner(out);
|
|
249
273
|
} catch {
|
|
250
274
|
// Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
|
|
251
275
|
return null;
|
|
@@ -662,7 +686,10 @@ const hostOam = process.versions.oam;
|
|
|
662
686
|
const plan = runtimePlan({ mode, hostOam });
|
|
663
687
|
|
|
664
688
|
if (plan === "in-process") {
|
|
665
|
-
|
|
689
|
+
// Guarded like every other in-process fallback: a broken install (missing
|
|
690
|
+
// dist/index.js) must print this launcher's diagnostic, not a raw Node stack
|
|
691
|
+
// trace from the bare import().
|
|
692
|
+
await runInProcess().catch(fallbackFailed);
|
|
666
693
|
} else if (plan === "handoff-node") {
|
|
667
694
|
const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
|
|
668
695
|
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,12 @@ function isValidHostname(host) {
|
|
|
46
46
|
}
|
|
47
47
|
function runArgs(cmd, args) {
|
|
48
48
|
try {
|
|
49
|
-
const stdout = execFileSync(cmd, args, {
|
|
49
|
+
const stdout = execFileSync(cmd, args, {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: 1e4,
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
53
|
+
windowsHide: true
|
|
54
|
+
});
|
|
50
55
|
return { stdout: stdout.trim(), ok: true };
|
|
51
56
|
} catch (e) {
|
|
52
57
|
const err = e;
|
|
@@ -1064,7 +1069,9 @@ function runArgsWithEnv(cmd, args, extraEnv) {
|
|
|
1064
1069
|
env,
|
|
1065
1070
|
encoding: "utf8",
|
|
1066
1071
|
timeout: 1e4,
|
|
1067
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1072
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1073
|
+
// No console window for ssh-add on Windows; see runArgs in diagnose.ts.
|
|
1074
|
+
windowsHide: true
|
|
1068
1075
|
});
|
|
1069
1076
|
return { stdout: stdout.trim(), ok: true };
|
|
1070
1077
|
} catch (e) {
|
|
@@ -1396,9 +1403,9 @@ function isPoolFullError(err) {
|
|
|
1396
1403
|
}
|
|
1397
1404
|
var MAX_TIMER_MS = 2147483647;
|
|
1398
1405
|
function defaultMaxPoolSize() {
|
|
1399
|
-
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1400
|
-
if (!raw) return 100;
|
|
1401
|
-
const parsed = Number
|
|
1406
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE?.trim();
|
|
1407
|
+
if (!raw || !/^\d+$/.test(raw)) return 100;
|
|
1408
|
+
const parsed = Number(raw);
|
|
1402
1409
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1403
1410
|
}
|
|
1404
1411
|
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
package/dist/server.js
CHANGED
|
@@ -36,7 +36,12 @@ function isValidHostname(host) {
|
|
|
36
36
|
}
|
|
37
37
|
function runArgs(cmd, args) {
|
|
38
38
|
try {
|
|
39
|
-
const stdout = execFileSync(cmd, args, {
|
|
39
|
+
const stdout = execFileSync(cmd, args, {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
timeout: 1e4,
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
windowsHide: true
|
|
44
|
+
});
|
|
40
45
|
return { stdout: stdout.trim(), ok: true };
|
|
41
46
|
} catch (e) {
|
|
42
47
|
const err = e;
|
|
@@ -1081,7 +1086,9 @@ function runArgsWithEnv(cmd, args, extraEnv) {
|
|
|
1081
1086
|
env,
|
|
1082
1087
|
encoding: "utf8",
|
|
1083
1088
|
timeout: 1e4,
|
|
1084
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1089
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1090
|
+
// No console window for ssh-add on Windows; see runArgs in diagnose.ts.
|
|
1091
|
+
windowsHide: true
|
|
1085
1092
|
});
|
|
1086
1093
|
return { stdout: stdout.trim(), ok: true };
|
|
1087
1094
|
} catch (e) {
|
|
@@ -1408,9 +1415,9 @@ function isPoolFullError(err) {
|
|
|
1408
1415
|
}
|
|
1409
1416
|
var MAX_TIMER_MS = 2147483647;
|
|
1410
1417
|
function defaultMaxPoolSize() {
|
|
1411
|
-
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1412
|
-
if (!raw) return 100;
|
|
1413
|
-
const parsed = Number
|
|
1418
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE?.trim();
|
|
1419
|
+
if (!raw || !/^\d+$/.test(raw)) return 100;
|
|
1420
|
+
const parsed = Number(raw);
|
|
1414
1421
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1415
1422
|
}
|
|
1416
1423
|
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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",
|
|
@@ -76,7 +76,6 @@
|
|
|
76
76
|
"@vitest/coverage-v8": "^4.1.11",
|
|
77
77
|
"@yawlabs/mcp-compliance": "^0.20.4",
|
|
78
78
|
"esbuild": "^0.28.1",
|
|
79
|
-
"postject": "^1.0.0-alpha.6",
|
|
80
79
|
"tsup": "^8.5.1",
|
|
81
80
|
"typescript": "^7.0.2",
|
|
82
81
|
"vitest": "^4.1.6"
|