@yawlabs/ssh-mcp 0.17.3 → 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 CHANGED
@@ -87,8 +87,8 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
87
87
 
88
88
  | Tool | Description |
89
89
  |------|-------------|
90
- | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Optional `env` param sets per-call environment variables (POSIX-safe prefix, works regardless of sshd's `AcceptEnv`). Subject to [command policy](#command-policy) if configured. |
91
- | `ssh_read_file` | Read a file from a remote host via SFTP. |
90
+ | `ssh_exec` | Execute a command on a remote host. Returns up to three content blocks, status first: `[exit code: N]` (plus `[signal: NAME]` with `code: -1` when the channel closed signal-only, and a flag when a stream hit the 10 MB cap), then `[stderr]`, then `[stdout]`. Status leads so a proxy that cuts long results — Yaw MCP keeps about 100 KB by default — never drops the exit code. Optional `env` param sets per-call environment variables (POSIX-safe prefix, works regardless of sshd's `AcceptEnv`). Subject to [command policy](#command-policy) if configured. |
91
+ | `ssh_read_file` | Read a file from a remote host via SFTP (whole file up to 10 MB). Optional `offset` and `length` (bytes) read one page and return a `[bytes A-B of SIZE; next offset: N]` status block before the text — use them for a large file, or behind a proxy that caps results. |
92
92
  | `ssh_write_file` | Write content to a file on a remote host via SFTP. |
93
93
  | `ssh_upload` | Upload a local file to a remote host via SFTP. |
94
94
  | `ssh_download` | Download a file from a remote host to local filesystem. |
@@ -137,11 +137,11 @@ All remote operations verify the server's host key against `~/.ssh/known_hosts`:
137
137
 
138
138
  - **Known host, key matches** — accept.
139
139
  - **Known host, key changed** — reject (MITM protection). The rejection message distinguishes a genuine key mismatch from "the server offered a key type your `known_hosts` entry doesn't cover", so a missing ed25519 line doesn't read as an attack.
140
- - **Unknown host** — accept, unless `SSH_MCP_STRICT_HOST_KEY=1`.
140
+ - **Unknown host** — accept, unless strict mode is on (`SSH_MCP_STRICT_HOSTKEYS=1`).
141
141
 
142
142
  **That last branch is trust-always, not TOFU.** Real trust-on-first-use pins the key it saw the first time and rejects a change afterwards. The connection path never writes to `known_hosts` — no tool adds an entry as a side effect of connecting — so connecting pins nothing: *every* connection to a host absent from `known_hosts` is a "first" use and is accepted, including one where an attacker swapped the key since your last call. Only hosts put into `known_hosts` out of band get mismatch protection — by you, by `ssh-keyscan`, or by `ssh_known_hosts_fix`, the one tool here that does write the file. Call it explicitly to add an entry so future changes are caught.
143
143
 
144
- For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
144
+ For stricter environments, set `SSH_MCP_STRICT_HOSTKEYS=1` (or `true`, case-insensitive) to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first. The older name `SSH_MCP_STRICT_HOST_KEY` still works and accepts the same values, but prefer the new one: credential scanners such as Yaw MCP's doctor treat any name with a `_KEY` segment as a secret, and flag the old name as a plaintext credential.
145
145
 
146
146
  The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
147
147
 
@@ -207,6 +207,8 @@ 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, 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
+
210
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.
211
213
 
212
214
  ## Authentication
@@ -326,26 +328,28 @@ for (const check of report.checks) {
326
328
 
327
329
  ## Runtime selection
328
330
 
329
- 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.18.0**, and it never serves on an older oam.
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.
330
332
 
331
333
  Two environment variables control the choice; set them in your MCP client's `env` block:
332
334
 
333
335
  - `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
334
- - `auto` (default) — use the newest oam it can find at 0.18.0 or newer, otherwise run on Node.
336
+ - `auto` (default) — use the newest oam it can find at 0.19.1 or newer, otherwise run on Node.
335
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.
336
338
  - `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
337
- - `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.18.0 or newer. If it does not exist, is older, or will not run, the launcher says so on stderr and carries on with discovery.
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.
338
340
 
339
- Discovery looks in `%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.18.0 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.
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.
340
342
 
341
- 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.18.0, 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.
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.
342
344
 
343
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):
344
346
 
345
- - On oam 0.18.0 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.
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.
346
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).
347
349
  - With `SSH_MCP_RUNTIME=node` it hands the server off to Node on `PATH`, whatever the oam version.
348
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 (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
+
349
353
  For example, to always run on Node:
350
354
 
351
355
  ```json
@@ -366,7 +370,7 @@ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#qu
366
370
 
367
371
  - Node.js 18+
368
372
  - SSH client installed (for diagnostics and environment management)
369
- - Optional: [oam](https://oamjs.org) 0.18.0+ — see [Runtime selection](#runtime-selection)
373
+ - Optional: [oam](https://oamjs.org) 0.19.1+ — see [Runtime selection](#runtime-selection)
370
374
 
371
375
  ## License
372
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.18.0 on PATH, stderr stays empty.
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,15 +77,41 @@
77
77
  * wired up here.
78
78
  *
79
79
  * MINIMUM OAM VERSION
80
- * The latest oam release, 0.18.0 -- bump OAM_MIN when oam ships a newer one.
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
- * The floor is not cosmetic: before 0.9.0 `child_process.execFile` ran its
83
- * arguments through a SHELL, `exec` accepted `timeout` and ignored it,
84
- * `spawnSync` truncated at `maxBuffer` while reporting success, and
85
- * `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. This server shells
86
- * out to a CLI on its main paths, so those were reachable bugs rather than
87
- * theoretical ones: an argument containing shell metacharacters was re-split
88
- * and executed.
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.
85
+ *
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:
88
+ *
89
+ * 1. An explicit child `env` that DELETES a variable. probeAgent
90
+ * (src/env.ts:85-90) runs `ssh-add -l` with SSH_AUTH_SOCK removed, so
91
+ * Windows ssh-add falls back to the OpenSSH named pipe instead of a stale
92
+ * Unix-style socket. Up to 0.17.x oam laid an explicit `env` OVER its
93
+ * startup environment, so the deleted SSH_AUTH_SOCK came back and ssh-add
94
+ * probed the wrong agent. 0.18.0 makes an explicit `env` replace it, as
95
+ * Node does (oam CHANGELOG 0.18.0, B4).
96
+ * 2. `net.connect` to a Windows named pipe or a Unix socket path.
97
+ * resolveConfig (src/ssh.ts:581-584) hands ssh2 SSH_AUTH_SOCK, or on Windows
98
+ * `\\.\pipe\openssh-ssh-agent`, and ssh2 dials it with
99
+ * `new Socket().connect(path)` for every agent-backed connection. Up to
100
+ * 0.17.x oam could not dial either (oam CHANGELOG 0.18.0, F1).
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
+ *
109
+ * Background, long before the current floor: before 0.9.0
110
+ * `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
111
+ * `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
112
+ * reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
113
+ * `'pipe'`. This server shells out to ssh, ssh-add and ssh-keygen on its main
114
+ * paths, so those were reachable bugs rather than theoretical ones.
89
115
  *
90
116
  * SELECTION
91
117
  * SSH_MCP_RUNTIME=auto newest usable oam, else Node (default)
@@ -103,8 +129,14 @@ import { constants, homedir } from "node:os";
103
129
  import { delimiter, join } from "node:path";
104
130
  import { fileURLToPath } from "node:url";
105
131
 
106
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
107
- const OAM_MIN = [0, 18, 0];
132
+ /**
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.
138
+ */
139
+ const OAM_MIN = [0, 19, 1];
108
140
 
109
141
  /**
110
142
  * Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
@@ -141,8 +173,12 @@ function pathKey(p) {
141
173
  * usually has oam/target/release on PATH, and cargo replaces that binary
142
174
  * underneath running processes; the installed copy is the release the user
143
175
  * actually installed. Both forms are checked on Windows: the installer defaults
144
- * to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
145
- * OAM_INSTALL_DIR can pick either.
176
+ * to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first.
177
+ *
178
+ * OAM_INSTALL_DIR, when set, is searched before both. It is the installer's
179
+ * install target (oam docs/cli-reference.md), so an oam put somewhere custom and
180
+ * left off PATH is still found -- this comment used to say it "can pick either"
181
+ * default while the code never read it.
146
182
  *
147
183
  * Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
148
184
  * run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
@@ -156,6 +192,7 @@ function discoverOamPaths() {
156
192
  if (isWin) {
157
193
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
158
194
  }
195
+ if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
159
196
  const onPath = (process.env.PATH ?? "")
160
197
  .split(delimiter)
161
198
  .filter(Boolean)
@@ -201,7 +238,7 @@ async function errSync(message) {
201
238
  * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
202
239
  *
203
240
  * Shared by the two places a version is read -- a discovered binary's
204
- * `oam --version` output ("oam 0.15.1") and the host's own
241
+ * `oam --version` banner ("oam 0.15.1", via oamBanner) and the host's own
205
242
  * `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
206
243
  * version string means, or which floor it has to clear.
207
244
  */
@@ -210,6 +247,19 @@ function parseVersion(text) {
210
247
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
211
248
  }
212
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
+
213
263
  /** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
214
264
  function oamVersion(cmd) {
215
265
  try {
@@ -219,7 +269,7 @@ function oamVersion(cmd) {
219
269
  timeout: VERSION_PROBE_TIMEOUT_MS,
220
270
  windowsHide: true,
221
271
  });
222
- return parseVersion(out);
272
+ return oamBanner(out);
223
273
  } catch {
224
274
  // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
225
275
  return null;
@@ -405,6 +455,48 @@ const fallbackFailed = (e) => {
405
455
  process.exitCode = 1;
406
456
  };
407
457
 
458
+ /**
459
+ * A permission flag oam may have put in NODE_OPTIONS: `--permission` (and the
460
+ * older `--experimental-permission`) or any `--allow-*` grant, with or without
461
+ * an `=value`.
462
+ */
463
+ const PERMISSION_FLAG = /^--(?:experimental-)?permission$|^--allow-[a-z0-9-]+(?:=.*)?$/;
464
+
465
+ /**
466
+ * `env` with oam's permission flags removed from NODE_OPTIONS, or `env` itself
467
+ * when there is nothing to remove.
468
+ *
469
+ * oam 0.18.0 appends its own `--permission` / `--allow-*` flags to every child's
470
+ * NODE_OPTIONS "for any program" (oam CHANGELOG 0.18.0, Permissions), so a
471
+ * process started under a sandboxed oam carries them in its environment. Node
472
+ * refuses some of them there -- measured on Node 22:
473
+ * `NODE_OPTIONS="--permission --allow-net" node` exits 9 with "--allow-net is
474
+ * not allowed in NODE_OPTIONS" -- and this launcher has no sandbox to pass on
475
+ * (see NO SANDBOX HERE), so the tokens are dropped before a child is started.
476
+ *
477
+ * Only the INHERITED copy can be removed here. When THIS process was itself
478
+ * started as `oam --permission ...`, oam re-appends those flags from its own
479
+ * execArgv inside spawn(), after this env is built -- measured on oam 0.18.0:
480
+ * the Node child still exits 9 with this env. That host is outside what this
481
+ * launcher supports, since the server needs unrestricted net and child-process
482
+ * grants anyway.
483
+ *
484
+ * Tokens are split on whitespace with double-quoted spans kept whole, which is
485
+ * how Node itself reads NODE_OPTIONS. Pure, so src/tests/launcher.test.ts can
486
+ * extract and test it without booting a runtime.
487
+ */
488
+ function stripPermissionFlags(env) {
489
+ const raw = env.NODE_OPTIONS;
490
+ if (!raw) return env;
491
+ const tokens = raw.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
492
+ const kept = tokens.filter((t) => !PERMISSION_FLAG.test(t.replaceAll('"', "")));
493
+ if (kept.length === tokens.length) return env;
494
+ const next = { ...env };
495
+ if (kept.length === 0) delete next.NODE_OPTIONS;
496
+ else next.NODE_OPTIONS = kept.join(" ");
497
+ return next;
498
+ }
499
+
408
500
  /**
409
501
  * Spawn the server in a child runtime and mirror its lifetime.
410
502
  *
@@ -428,7 +520,9 @@ async function launchChild(cmd, args, onLaunchFailed) {
428
520
  // server's shutdown path. Piping preserves both as well: bytes are copied
429
521
  // unchanged, and stdin's end propagates to the child.
430
522
  stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
431
- env: process.env,
523
+ // On an oam host, drop the permission flags oam put in NODE_OPTIONS; see
524
+ // stripPermissionFlags. A Node host's env passes through untouched.
525
+ env: process.versions.oam !== undefined ? stripPermissionFlags(process.env) : process.env,
432
526
  windowsHide: true,
433
527
  });
434
528
  } catch (err) {
@@ -592,7 +686,10 @@ const hostOam = process.versions.oam;
592
686
  const plan = runtimePlan({ mode, hostOam });
593
687
 
594
688
  if (plan === "in-process") {
595
- await runInProcess();
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);
596
693
  } else if (plan === "handoff-node") {
597
694
  const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
598
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, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
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;
@@ -441,8 +446,15 @@ function cachedKnownHostTypes(hosts, port) {
441
446
  knownHostTypeCache.set(cacheKey, { at: now, types });
442
447
  return types;
443
448
  }
449
+ function isStrictHostKeyMode(env = process.env) {
450
+ const on = (v) => {
451
+ const t = (v ?? "").trim().toLowerCase();
452
+ return t === "1" || t === "true";
453
+ };
454
+ return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
455
+ }
444
456
  function buildHostVerifier(hosts, port, rejection) {
445
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
457
+ const strict = isStrictHostKeyMode();
446
458
  const label = hosts.join(" / ");
447
459
  const remediationHost = unbracketHost(hosts[0]);
448
460
  return (key) => {
@@ -452,7 +464,7 @@ function buildHostVerifier(hosts, port, rejection) {
452
464
  if (strict) {
453
465
  rejection.current = {
454
466
  reason: "unknown-host-strict",
455
- message: `no known_hosts entry for ${label}, and SSH_MCP_STRICT_HOST_KEY=1 requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
467
+ message: `no known_hosts entry for ${label}, and strict mode (SSH_MCP_STRICT_HOSTKEYS=1) requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
456
468
  };
457
469
  }
458
470
  return !strict;
@@ -844,7 +856,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
844
856
  });
845
857
  if (stats.size > maxBytes) {
846
858
  throw new Error(
847
- `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
859
+ `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Read it a page at a time with ssh_read_file's offset and length arguments (byte offsets; the file is ${stats.size} bytes).`
848
860
  );
849
861
  }
850
862
  return await new Promise((resolve, reject) => {
@@ -857,6 +869,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
857
869
  sftp.end();
858
870
  }
859
871
  }
872
+ async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
873
+ if (!Number.isSafeInteger(offset) || offset < 0)
874
+ throw new Error(`offset must be a non-negative integer, got ${offset}`);
875
+ if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
876
+ if (length > maxBytes) {
877
+ throw new Error(
878
+ `length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
879
+ );
880
+ }
881
+ const sftp = await getSftp(client);
882
+ try {
883
+ const stats = await new Promise((resolve, reject) => {
884
+ sftp.stat(remotePath, (err, st) => {
885
+ if (err) return reject(err);
886
+ resolve(st);
887
+ });
888
+ });
889
+ const want = Math.max(0, Math.min(length, stats.size - offset));
890
+ if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
891
+ const handle = await new Promise((resolve, reject) => {
892
+ sftp.open(remotePath, "r", (err, h) => {
893
+ if (err) return reject(err);
894
+ resolve(h);
895
+ });
896
+ });
897
+ try {
898
+ const buf = Buffer.alloc(want);
899
+ let got = 0;
900
+ while (got < want) {
901
+ const n = await new Promise((resolve, reject) => {
902
+ sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
903
+ if (err) return reject(err);
904
+ resolve(bytesRead ?? 0);
905
+ });
906
+ });
907
+ if (n === 0) break;
908
+ got += n;
909
+ }
910
+ return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
911
+ } finally {
912
+ sftp.close(handle, () => {
913
+ });
914
+ }
915
+ } finally {
916
+ sftp.end();
917
+ }
918
+ }
860
919
  async function writeFile(client, remotePath, content) {
861
920
  const sftp = await getSftp(client);
862
921
  try {
@@ -1010,7 +1069,9 @@ function runArgsWithEnv(cmd, args, extraEnv) {
1010
1069
  env,
1011
1070
  encoding: "utf8",
1012
1071
  timeout: 1e4,
1013
- 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
1014
1075
  });
1015
1076
  return { stdout: stdout.trim(), ok: true };
1016
1077
  } catch (e) {
@@ -1342,9 +1403,9 @@ function isPoolFullError(err) {
1342
1403
  }
1343
1404
  var MAX_TIMER_MS = 2147483647;
1344
1405
  function defaultMaxPoolSize() {
1345
- const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1346
- if (!raw) return 100;
1347
- const parsed = Number.parseInt(raw, 10);
1406
+ const raw = process.env.SSH_MCP_MAX_POOL_SIZE?.trim();
1407
+ if (!raw || !/^\d+$/.test(raw)) return 100;
1408
+ const parsed = Number(raw);
1348
1409
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1349
1410
  }
1350
1411
  var AUTH_FINGERPRINT_SECRET = randomBytes(32);
@@ -1532,6 +1593,7 @@ var ConnectionPool = class {
1532
1593
  if (entry.client === client) {
1533
1594
  entry.refCount = Math.max(0, entry.refCount - 1);
1534
1595
  if (entry.refCount === 0 && !entry.dead) {
1596
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
1535
1597
  entry.idleTimer = setTimeout(() => {
1536
1598
  try {
1537
1599
  entry.client.end();
@@ -1784,14 +1846,14 @@ async function find(client, options, timeoutMs = 3e4) {
1784
1846
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1785
1847
  );
1786
1848
  }
1787
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1788
- const args = [shellQuote(pathOperand)];
1849
+ const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
1850
+ const args = [shellQuote(defuseFindOperand(options.path))];
1789
1851
  if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1790
1852
  if (options.type) args.push("-type", shellQuote(options.type));
1791
1853
  if (options.name) args.push("-name", shellQuote(options.name));
1792
1854
  if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1793
1855
  if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1794
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1856
+ if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
1795
1857
  const command = `find ${args.join(" ")}`;
1796
1858
  const result = await exec(client, command, timeoutMs);
1797
1859
  if (!result.stdout.trim() && result.stderr.trim()) {
@@ -1814,7 +1876,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1814
1876
  return result.stdout;
1815
1877
  }
1816
1878
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1817
- const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1879
+ const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1818
1880
  const raw = result.stdout;
1819
1881
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1820
1882
  const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
@@ -1901,6 +1963,9 @@ var connectionParams = {
1901
1963
  var DEFAULT_TIMEOUT_MS = 3e4;
1902
1964
  var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1903
1965
  var SFTP_POOL_WAIT_NOTE = ` If the connection pool is full (SSH_MCP_MAX_POOL_SIZE, default 100), waits up to ${DEFAULT_TIMEOUT_MS / 1e3}s for a free slot before starting.`;
1966
+ var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
1967
+ var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
1968
+ var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
1904
1969
  var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
1905
1970
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1906
1971
  function applyEnvPrefix(command, env) {
@@ -1921,7 +1986,7 @@ function registerTools(server, pool) {
1921
1986
  const connectionPool = pool ?? new ConnectionPool();
1922
1987
  server.tool(
1923
1988
  "ssh_exec",
1924
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
1989
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns up to three content blocks in this order: the status (`[exit code: N]`, plus the signal and any truncation), then `[stderr]`, then `[stdout]`. Status comes first so a proxy that cuts long results (Yaw MCP keeps about 100 KB) never drops the exit code; for big output, filter or page on the remote (head, tail, grep). Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
1925
1990
  {
1926
1991
  ...connectionParams,
1927
1992
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
@@ -1936,13 +2001,16 @@ function registerTools(server, pool) {
1936
2001
  conn,
1937
2002
  async (client) => {
1938
2003
  const result = await exec(client, finalCommand, timeoutMs);
1939
- const parts = [];
1940
- if (result.stdout) parts.push(result.stdout);
1941
- if (result.stderr) parts.push(`[stderr]
1942
- ${result.stderr}`);
1943
- if (result.signal) parts.push(`[signal: ${result.signal}]`);
1944
- parts.push(`[exit code: ${result.code}]`);
1945
- return { content: [{ type: "text", text: parts.join("\n") }] };
2004
+ const status = [`[exit code: ${result.code}]`];
2005
+ if (result.signal) status.push(`[signal: ${result.signal}]`);
2006
+ if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
2007
+ if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
2008
+ const content = [{ type: "text", text: status.join("\n") }];
2009
+ if (result.stderr) content.push({ type: "text", text: `[stderr]
2010
+ ${result.stderr}` });
2011
+ if (result.stdout) content.push({ type: "text", text: `[stdout]
2012
+ ${result.stdout}` });
2013
+ return { content };
1946
2014
  },
1947
2015
  poolWait(timeoutMs)
1948
2016
  );
@@ -1950,19 +2018,30 @@ ${result.stderr}`);
1950
2018
  );
1951
2019
  server.tool(
1952
2020
  "ssh_read_file",
1953
- `Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
2021
+ `Read a file from a remote host via SFTP. A whole-file read is limited to 10 MB. For a large file -- or behind a proxy that caps results, such as Yaw MCP at about 100 KB -- read it in pages with \`offset\` and \`length\` (bytes): a paged read returns a status block first (\`[bytes A-B of SIZE]\` and the next offset) and then the text.${SFTP_POOL_WAIT_NOTE}`,
1954
2022
  {
1955
2023
  ...connectionParams,
1956
- path: z.string().refine((p) => p.startsWith("/"), {
1957
- message: "Path must be absolute (start with /). Relative paths resolve against the remote user's CWD, which is rarely what you want."
1958
- }).describe("Absolute path to the remote file. Must start with /.")
2024
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
2025
+ offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
2026
+ length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
2027
+ "Number of bytes to read (default: the rest of the file, up to 10 MB). Behind Yaw MCP keep it at or under about 90000 so the page is not cut. A page boundary can split a multi-byte UTF-8 character."
2028
+ )
1959
2029
  },
1960
- async ({ path, ...conn }) => {
2030
+ READ_ONLY_REMOTE,
2031
+ async ({ path, offset, length, ...conn }) => {
1961
2032
  return connectionPool.withConnection(
1962
2033
  conn,
1963
2034
  async (client) => {
1964
- const content = await readFile(client, path);
1965
- return { content: [{ type: "text", text: content }] };
2035
+ if (offset === void 0 && length === void 0) {
2036
+ const content2 = await readFile(client, path);
2037
+ return { content: [{ type: "text", text: content2 }] };
2038
+ }
2039
+ const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
2040
+ const end = page.offset + page.bytesRead;
2041
+ const where = page.bytesRead === 0 ? `[no bytes at offset ${page.offset}: the file is ${page.size} bytes]` : end < page.size ? `[bytes ${page.offset}-${end} of ${page.size}; next offset: ${end}]` : `[bytes ${page.offset}-${end} of ${page.size}; end of file]`;
2042
+ const content = [{ type: "text", text: where }];
2043
+ if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
2044
+ return { content };
1966
2045
  },
1967
2046
  poolWait()
1968
2047
  );
@@ -1976,6 +2055,7 @@ ${result.stderr}`);
1976
2055
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1977
2056
  content: z.string().describe("File content to write")
1978
2057
  },
2058
+ DESTRUCTIVE_REMOTE,
1979
2059
  async ({ path, content, ...conn }) => {
1980
2060
  return connectionPool.withConnection(
1981
2061
  conn,
@@ -1990,7 +2070,7 @@ ${result.stderr}`);
1990
2070
  );
1991
2071
  server.tool(
1992
2072
  "ssh_upload",
1993
- `Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
2073
+ `Upload a local file to a remote host via SFTP. Reads an arbitrary local file at an agent-chosen \`localPath\` -- any file readable by this process leaves the machine toward the remote host (the mirror direction of ssh_download's local write).${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1994
2074
  {
1995
2075
  ...connectionParams,
1996
2076
  localPath: z.string().describe("Path to the local file to upload"),
@@ -2033,6 +2113,7 @@ ${result.stderr}`);
2033
2113
  ...connectionParams,
2034
2114
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
2035
2115
  },
2116
+ READ_ONLY_REMOTE,
2036
2117
  async ({ path, ...conn }) => {
2037
2118
  return connectionPool.withConnection(
2038
2119
  conn,
@@ -2054,6 +2135,7 @@ ${result.stderr}`);
2054
2135
  ...connectionParams,
2055
2136
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
2056
2137
  },
2138
+ READ_ONLY_REMOTE,
2057
2139
  async ({ path, ...conn }) => {
2058
2140
  return connectionPool.withConnection(
2059
2141
  conn,
@@ -2104,6 +2186,7 @@ ${result.stderr}`);
2104
2186
  "Absolute path of the file or empty directory to delete. Must start with /."
2105
2187
  )
2106
2188
  },
2189
+ DESTRUCTIVE_REMOTE,
2107
2190
  async ({ path, ...conn }) => {
2108
2191
  return connectionPool.withConnection(
2109
2192
  conn,
@@ -2122,6 +2205,7 @@ ${result.stderr}`);
2122
2205
  host: HostSchema,
2123
2206
  port: PortSchema
2124
2207
  },
2208
+ READ_ONLY_REMOTE,
2125
2209
  async ({ host, port }) => {
2126
2210
  const report = diagnose(host, port || 22);
2127
2211
  const lines = [];
@@ -2168,6 +2252,7 @@ ${result.stderr}`);
2168
2252
  "ssh_key_list",
2169
2253
  "List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
2170
2254
  {},
2255
+ READ_ONLY_LOCAL,
2171
2256
  async () => {
2172
2257
  const listing = listSshKeysDetailed();
2173
2258
  if (listing.status === "unreadable") {
@@ -2232,6 +2317,7 @@ ${result.stderr}`);
2232
2317
  {
2233
2318
  host: HostSchema
2234
2319
  },
2320
+ READ_ONLY_LOCAL,
2235
2321
  async ({ host }) => {
2236
2322
  const result = configLookup(host);
2237
2323
  if ("error" in result) {
@@ -2304,7 +2390,9 @@ ${result.stderr}`);
2304
2390
  "ssh_multi_exec",
2305
2391
  "Execute a command on multiple remote hosts in parallel. Runs at most SSH_MCP_MAX_POOL_SIZE hosts at once (default 100) and works through a longer list as slots free up. `timeout` is per host: it bounds each host's command, not the whole call. Each parallel slot works through its share of the list one host at a time, so with the pool to itself a call can take up to about ceil(hosts / SSH_MCP_MAX_POOL_SIZE) x (`timeout` + connect time). The connection pool is shared with every other tool; when it is full this call waits for slots instead of failing, and gives up only when none of its own hosts holds a slot and a full `timeout` has passed with none of them starting or finishing. The hosts waiting at that point (up to one per parallel slot) and every host still queued then report `Connection pool is full`; the queued ones are never attempted. Rerun those hosts once the other calls finish, or raise SSH_MCP_MAX_POOL_SIZE. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
2306
2392
  {
2307
- hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
2393
+ // .min(1): `hosts: []` used to return an empty success block indistinguishable from a
2394
+ // no-op -- the same boundary rejection ssh_git_check applies to its empty host.
2395
+ hosts: z.array(z.string()).min(1, "hosts must not be empty. Name at least one host -- an empty list runs the command on nothing.").describe("List of SSH hostnames or IPs"),
2308
2396
  command: z.string().describe("Shell command to execute on all hosts"),
2309
2397
  port: PortSchema,
2310
2398
  username: UsernameSchema,
@@ -2353,6 +2441,7 @@ ${result.stderr}`);
2353
2441
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
2354
2442
  timeout: TimeoutSchema
2355
2443
  },
2444
+ READ_ONLY_REMOTE,
2356
2445
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2357
2446
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2358
2447
  return connectionPool.withConnection(
@@ -2379,6 +2468,7 @@ ${files.join("\n")}` }] };
2379
2468
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
2380
2469
  timeout: TimeoutSchema
2381
2470
  },
2471
+ READ_ONLY_REMOTE,
2382
2472
  async ({ path, lines, grep, timeout, ...conn }) => {
2383
2473
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2384
2474
  return connectionPool.withConnection(
@@ -2409,6 +2499,7 @@ ${files.join("\n")}` }] };
2409
2499
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
2410
2500
  timeout: TimeoutSchema
2411
2501
  },
2502
+ READ_ONLY_REMOTE,
2412
2503
  async ({ service, timeout, ...conn }) => {
2413
2504
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2414
2505
  return connectionPool.withConnection(
@@ -2433,11 +2524,25 @@ ${files.join("\n")}` }] };
2433
2524
 
2434
2525
  // src/server.ts
2435
2526
  var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
2527
+ var SERVER_INSTRUCTIONS = [
2528
+ "ssh-mcp runs commands and moves files on remote hosts over SSH, using ~/.ssh/config, the ssh-agent and known_hosts the way the OpenSSH client does.",
2529
+ "",
2530
+ "- After an auth, connect or host-key failure, run ssh_diagnose on that host before retrying. ssh_test is the quick check; ssh_key_list and ssh_key_load fix a key missing from the agent; ssh_known_hosts_fix replaces a stale host key.",
2531
+ "- Prefer the purpose-built tools over ssh_exec: ssh_read_file, ssh_ls, ssh_stat, ssh_find, ssh_tail, ssh_service_status. Use ssh_multi_exec for the same command on several hosts.",
2532
+ "- ssh_exec returns the exit code first, then stderr, then stdout. Proxies may cut long results (Yaw MCP keeps about 100 KB), so filter on the remote (grep, head, tail) instead of printing everything. Page a large file with ssh_read_file offset and length.",
2533
+ "- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
2534
+ "- Host keys: a host already in known_hosts must match. An unknown host is trusted on every connection unless strict mode (SSH_MCP_STRICT_HOSTKEYS=1) is on; add it with ssh_known_hosts_fix first.",
2535
+ "- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
2536
+ "- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
2537
+ ].join("\n");
2436
2538
  function createServer(pool) {
2437
- const server = new McpServer({
2438
- name: "ssh-mcp",
2439
- version
2440
- });
2539
+ const server = new McpServer(
2540
+ {
2541
+ name: "ssh-mcp",
2542
+ version
2543
+ },
2544
+ { instructions: SERVER_INSTRUCTIONS }
2545
+ );
2441
2546
  registerTools(server, pool);
2442
2547
  return server;
2443
2548
  }
package/dist/policy.d.ts CHANGED
@@ -18,5 +18,5 @@ export interface PolicyContext {
18
18
  * routed through here at all.
19
19
  */
20
20
  export declare function enforcePolicy(command: string, context?: PolicyContext): void;
21
- /** Returns true if any policy is currently configured. Used by tool descriptions to surface that. */
21
+ /** Returns true if any policy is currently configured. Exported for library consumers and tests; tool descriptions use static "if configured" text instead. */
22
22
  export declare function isPolicyConfigured(): boolean;
package/dist/server.d.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { ConnectionPool } from "./pool.js";
3
3
  export declare const version: string;
4
+ /**
5
+ * Routing guidance sent once, at initialize. Yaw MCP shows it when this server is
6
+ * activated, capped at 2000 UTF-8 bytes, so it stays plain ASCII, well under that, and
7
+ * about these tools only (src/tests/server.test.ts pins both). It must not carry
8
+ * Yaw MCP's own framing markers, which the proxy strips or rejects.
9
+ */
10
+ export declare const SERVER_INSTRUCTIONS: string;
4
11
  export declare function createServer(pool?: ConnectionPool): McpServer;
5
12
  export type { DiagnosticReport, DiagnosticResult } from "./diagnose.js";
6
13
  export { checkConnectivity, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, diagnose, } from "./diagnose.js";
7
- export type { AgentResult, ConfigLookupResult, KeyInfo } from "./env.js";
8
- export { checkGitSsh, configLookup, ensureAgent, fixKnownHosts, listSshKeys, loadKey, testConnection, } from "./env.js";
14
+ export type { AgentResult, ConfigLookupResult, KeyInfo, SshKeyListing } from "./env.js";
15
+ export { checkGitSsh, configLookup, ensureAgent, fixKnownHosts, listSshKeys, listSshKeysDetailed, loadKey, testConnection, } from "./env.js";
9
16
  export type { FindOptions, MultiExecHost, MultiExecResult, ServiceStatus } from "./ops.js";
10
- export { find, multiExec, serviceStatus, tail } from "./ops.js";
17
+ export { find, multiExec, serviceStatus, shellQuote, tail } from "./ops.js";
11
18
  export type { PolicyContext } from "./policy.js";
12
19
  export { enforcePolicy, isPolicyConfigured } from "./policy.js";
13
20
  export type { AcquireOptions, PoolOptions } from "./pool.js";
14
21
  export { ConnectionPool, isPoolFullError, POOL_FULL_ERROR_CODE, PoolFullError } from "./pool.js";
15
- export type { ExecResult, FileStats, HostKeyRejection, HostKeyRejectionReason, ResolvedConfig, SSHConfig, } from "./ssh.js";
16
- export { connect, connectRaw, connectWithProxy, deleteFile, downloadFile, exec, formatDiagnostics, listDir, makeDir, readFile, readKnownHostsKeys, resolveConfig, statFile, uploadFile, writeFile, } from "./ssh.js";
22
+ export type { ExecResult, FileRange, FileStats, HostKeyRejection, HostKeyRejectionReason, ResolvedConfig, SSHConfig, } from "./ssh.js";
23
+ export { connect, connectRaw, connectWithProxy, deleteFile, downloadFile, exec, formatDiagnostics, listDir, makeDir, readFile, readFileRange, readKnownHostsKeys, resolveConfig, statFile, uploadFile, writeFile, } from "./ssh.js";
17
24
  export { registerTools } from "./tools.js";
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, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
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;
@@ -449,8 +454,15 @@ function cachedKnownHostTypes(hosts, port) {
449
454
  knownHostTypeCache.set(cacheKey, { at: now, types });
450
455
  return types;
451
456
  }
457
+ function isStrictHostKeyMode(env = process.env) {
458
+ const on = (v) => {
459
+ const t = (v ?? "").trim().toLowerCase();
460
+ return t === "1" || t === "true";
461
+ };
462
+ return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
463
+ }
452
464
  function buildHostVerifier(hosts, port, rejection) {
453
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
465
+ const strict = isStrictHostKeyMode();
454
466
  const label = hosts.join(" / ");
455
467
  const remediationHost = unbracketHost(hosts[0]);
456
468
  return (key) => {
@@ -460,7 +472,7 @@ function buildHostVerifier(hosts, port, rejection) {
460
472
  if (strict) {
461
473
  rejection.current = {
462
474
  reason: "unknown-host-strict",
463
- message: `no known_hosts entry for ${label}, and SSH_MCP_STRICT_HOST_KEY=1 requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
475
+ message: `no known_hosts entry for ${label}, and strict mode (SSH_MCP_STRICT_HOSTKEYS=1) requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
464
476
  };
465
477
  }
466
478
  return !strict;
@@ -861,7 +873,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
861
873
  });
862
874
  if (stats.size > maxBytes) {
863
875
  throw new Error(
864
- `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
876
+ `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Read it a page at a time with ssh_read_file's offset and length arguments (byte offsets; the file is ${stats.size} bytes).`
865
877
  );
866
878
  }
867
879
  return await new Promise((resolve, reject) => {
@@ -874,6 +886,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
874
886
  sftp.end();
875
887
  }
876
888
  }
889
+ async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
890
+ if (!Number.isSafeInteger(offset) || offset < 0)
891
+ throw new Error(`offset must be a non-negative integer, got ${offset}`);
892
+ if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
893
+ if (length > maxBytes) {
894
+ throw new Error(
895
+ `length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
896
+ );
897
+ }
898
+ const sftp = await getSftp(client);
899
+ try {
900
+ const stats = await new Promise((resolve, reject) => {
901
+ sftp.stat(remotePath, (err, st) => {
902
+ if (err) return reject(err);
903
+ resolve(st);
904
+ });
905
+ });
906
+ const want = Math.max(0, Math.min(length, stats.size - offset));
907
+ if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
908
+ const handle = await new Promise((resolve, reject) => {
909
+ sftp.open(remotePath, "r", (err, h) => {
910
+ if (err) return reject(err);
911
+ resolve(h);
912
+ });
913
+ });
914
+ try {
915
+ const buf = Buffer.alloc(want);
916
+ let got = 0;
917
+ while (got < want) {
918
+ const n = await new Promise((resolve, reject) => {
919
+ sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
920
+ if (err) return reject(err);
921
+ resolve(bytesRead ?? 0);
922
+ });
923
+ });
924
+ if (n === 0) break;
925
+ got += n;
926
+ }
927
+ return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
928
+ } finally {
929
+ sftp.close(handle, () => {
930
+ });
931
+ }
932
+ } finally {
933
+ sftp.end();
934
+ }
935
+ }
877
936
  async function writeFile(client, remotePath, content) {
878
937
  const sftp = await getSftp(client);
879
938
  try {
@@ -1027,7 +1086,9 @@ function runArgsWithEnv(cmd, args, extraEnv) {
1027
1086
  env,
1028
1087
  encoding: "utf8",
1029
1088
  timeout: 1e4,
1030
- 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
1031
1092
  });
1032
1093
  return { stdout: stdout.trim(), ok: true };
1033
1094
  } catch (e) {
@@ -1354,9 +1415,9 @@ function isPoolFullError(err) {
1354
1415
  }
1355
1416
  var MAX_TIMER_MS = 2147483647;
1356
1417
  function defaultMaxPoolSize() {
1357
- const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1358
- if (!raw) return 100;
1359
- const parsed = Number.parseInt(raw, 10);
1418
+ const raw = process.env.SSH_MCP_MAX_POOL_SIZE?.trim();
1419
+ if (!raw || !/^\d+$/.test(raw)) return 100;
1420
+ const parsed = Number(raw);
1360
1421
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1361
1422
  }
1362
1423
  var AUTH_FINGERPRINT_SECRET = randomBytes(32);
@@ -1544,6 +1605,7 @@ var ConnectionPool = class {
1544
1605
  if (entry.client === client) {
1545
1606
  entry.refCount = Math.max(0, entry.refCount - 1);
1546
1607
  if (entry.refCount === 0 && !entry.dead) {
1608
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
1547
1609
  entry.idleTimer = setTimeout(() => {
1548
1610
  try {
1549
1611
  entry.client.end();
@@ -1787,14 +1849,14 @@ async function find(client, options, timeoutMs = 3e4) {
1787
1849
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1788
1850
  );
1789
1851
  }
1790
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1791
- const args = [shellQuote(pathOperand)];
1852
+ const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
1853
+ const args = [shellQuote(defuseFindOperand(options.path))];
1792
1854
  if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1793
1855
  if (options.type) args.push("-type", shellQuote(options.type));
1794
1856
  if (options.name) args.push("-name", shellQuote(options.name));
1795
1857
  if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1796
1858
  if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1797
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1859
+ if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
1798
1860
  const command = `find ${args.join(" ")}`;
1799
1861
  const result = await exec(client, command, timeoutMs);
1800
1862
  if (!result.stdout.trim() && result.stderr.trim()) {
@@ -1817,7 +1879,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1817
1879
  return result.stdout;
1818
1880
  }
1819
1881
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1820
- const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1882
+ const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1821
1883
  const raw = result.stdout;
1822
1884
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1823
1885
  const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
@@ -1907,6 +1969,9 @@ var connectionParams = {
1907
1969
  var DEFAULT_TIMEOUT_MS = 3e4;
1908
1970
  var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1909
1971
  var SFTP_POOL_WAIT_NOTE = ` If the connection pool is full (SSH_MCP_MAX_POOL_SIZE, default 100), waits up to ${DEFAULT_TIMEOUT_MS / 1e3}s for a free slot before starting.`;
1972
+ var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
1973
+ var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
1974
+ var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
1910
1975
  var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
1911
1976
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1912
1977
  function applyEnvPrefix(command, env) {
@@ -1927,7 +1992,7 @@ function registerTools(server, pool) {
1927
1992
  const connectionPool = pool ?? new ConnectionPool();
1928
1993
  server.tool(
1929
1994
  "ssh_exec",
1930
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
1995
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns up to three content blocks in this order: the status (`[exit code: N]`, plus the signal and any truncation), then `[stderr]`, then `[stdout]`. Status comes first so a proxy that cuts long results (Yaw MCP keeps about 100 KB) never drops the exit code; for big output, filter or page on the remote (head, tail, grep). Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
1931
1996
  {
1932
1997
  ...connectionParams,
1933
1998
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
@@ -1942,13 +2007,16 @@ function registerTools(server, pool) {
1942
2007
  conn,
1943
2008
  async (client) => {
1944
2009
  const result = await exec(client, finalCommand, timeoutMs);
1945
- const parts = [];
1946
- if (result.stdout) parts.push(result.stdout);
1947
- if (result.stderr) parts.push(`[stderr]
1948
- ${result.stderr}`);
1949
- if (result.signal) parts.push(`[signal: ${result.signal}]`);
1950
- parts.push(`[exit code: ${result.code}]`);
1951
- return { content: [{ type: "text", text: parts.join("\n") }] };
2010
+ const status = [`[exit code: ${result.code}]`];
2011
+ if (result.signal) status.push(`[signal: ${result.signal}]`);
2012
+ if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
2013
+ if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
2014
+ const content = [{ type: "text", text: status.join("\n") }];
2015
+ if (result.stderr) content.push({ type: "text", text: `[stderr]
2016
+ ${result.stderr}` });
2017
+ if (result.stdout) content.push({ type: "text", text: `[stdout]
2018
+ ${result.stdout}` });
2019
+ return { content };
1952
2020
  },
1953
2021
  poolWait(timeoutMs)
1954
2022
  );
@@ -1956,19 +2024,30 @@ ${result.stderr}`);
1956
2024
  );
1957
2025
  server.tool(
1958
2026
  "ssh_read_file",
1959
- `Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
2027
+ `Read a file from a remote host via SFTP. A whole-file read is limited to 10 MB. For a large file -- or behind a proxy that caps results, such as Yaw MCP at about 100 KB -- read it in pages with \`offset\` and \`length\` (bytes): a paged read returns a status block first (\`[bytes A-B of SIZE]\` and the next offset) and then the text.${SFTP_POOL_WAIT_NOTE}`,
1960
2028
  {
1961
2029
  ...connectionParams,
1962
- path: z.string().refine((p) => p.startsWith("/"), {
1963
- message: "Path must be absolute (start with /). Relative paths resolve against the remote user's CWD, which is rarely what you want."
1964
- }).describe("Absolute path to the remote file. Must start with /.")
2030
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
2031
+ offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
2032
+ length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
2033
+ "Number of bytes to read (default: the rest of the file, up to 10 MB). Behind Yaw MCP keep it at or under about 90000 so the page is not cut. A page boundary can split a multi-byte UTF-8 character."
2034
+ )
1965
2035
  },
1966
- async ({ path, ...conn }) => {
2036
+ READ_ONLY_REMOTE,
2037
+ async ({ path, offset, length, ...conn }) => {
1967
2038
  return connectionPool.withConnection(
1968
2039
  conn,
1969
2040
  async (client) => {
1970
- const content = await readFile(client, path);
1971
- return { content: [{ type: "text", text: content }] };
2041
+ if (offset === void 0 && length === void 0) {
2042
+ const content2 = await readFile(client, path);
2043
+ return { content: [{ type: "text", text: content2 }] };
2044
+ }
2045
+ const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
2046
+ const end = page.offset + page.bytesRead;
2047
+ const where = page.bytesRead === 0 ? `[no bytes at offset ${page.offset}: the file is ${page.size} bytes]` : end < page.size ? `[bytes ${page.offset}-${end} of ${page.size}; next offset: ${end}]` : `[bytes ${page.offset}-${end} of ${page.size}; end of file]`;
2048
+ const content = [{ type: "text", text: where }];
2049
+ if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
2050
+ return { content };
1972
2051
  },
1973
2052
  poolWait()
1974
2053
  );
@@ -1982,6 +2061,7 @@ ${result.stderr}`);
1982
2061
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1983
2062
  content: z.string().describe("File content to write")
1984
2063
  },
2064
+ DESTRUCTIVE_REMOTE,
1985
2065
  async ({ path, content, ...conn }) => {
1986
2066
  return connectionPool.withConnection(
1987
2067
  conn,
@@ -1996,7 +2076,7 @@ ${result.stderr}`);
1996
2076
  );
1997
2077
  server.tool(
1998
2078
  "ssh_upload",
1999
- `Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
2079
+ `Upload a local file to a remote host via SFTP. Reads an arbitrary local file at an agent-chosen \`localPath\` -- any file readable by this process leaves the machine toward the remote host (the mirror direction of ssh_download's local write).${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
2000
2080
  {
2001
2081
  ...connectionParams,
2002
2082
  localPath: z.string().describe("Path to the local file to upload"),
@@ -2039,6 +2119,7 @@ ${result.stderr}`);
2039
2119
  ...connectionParams,
2040
2120
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
2041
2121
  },
2122
+ READ_ONLY_REMOTE,
2042
2123
  async ({ path, ...conn }) => {
2043
2124
  return connectionPool.withConnection(
2044
2125
  conn,
@@ -2060,6 +2141,7 @@ ${result.stderr}`);
2060
2141
  ...connectionParams,
2061
2142
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
2062
2143
  },
2144
+ READ_ONLY_REMOTE,
2063
2145
  async ({ path, ...conn }) => {
2064
2146
  return connectionPool.withConnection(
2065
2147
  conn,
@@ -2110,6 +2192,7 @@ ${result.stderr}`);
2110
2192
  "Absolute path of the file or empty directory to delete. Must start with /."
2111
2193
  )
2112
2194
  },
2195
+ DESTRUCTIVE_REMOTE,
2113
2196
  async ({ path, ...conn }) => {
2114
2197
  return connectionPool.withConnection(
2115
2198
  conn,
@@ -2128,6 +2211,7 @@ ${result.stderr}`);
2128
2211
  host: HostSchema,
2129
2212
  port: PortSchema
2130
2213
  },
2214
+ READ_ONLY_REMOTE,
2131
2215
  async ({ host, port }) => {
2132
2216
  const report = diagnose(host, port || 22);
2133
2217
  const lines = [];
@@ -2174,6 +2258,7 @@ ${result.stderr}`);
2174
2258
  "ssh_key_list",
2175
2259
  "List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
2176
2260
  {},
2261
+ READ_ONLY_LOCAL,
2177
2262
  async () => {
2178
2263
  const listing = listSshKeysDetailed();
2179
2264
  if (listing.status === "unreadable") {
@@ -2238,6 +2323,7 @@ ${result.stderr}`);
2238
2323
  {
2239
2324
  host: HostSchema
2240
2325
  },
2326
+ READ_ONLY_LOCAL,
2241
2327
  async ({ host }) => {
2242
2328
  const result = configLookup(host);
2243
2329
  if ("error" in result) {
@@ -2310,7 +2396,9 @@ ${result.stderr}`);
2310
2396
  "ssh_multi_exec",
2311
2397
  "Execute a command on multiple remote hosts in parallel. Runs at most SSH_MCP_MAX_POOL_SIZE hosts at once (default 100) and works through a longer list as slots free up. `timeout` is per host: it bounds each host's command, not the whole call. Each parallel slot works through its share of the list one host at a time, so with the pool to itself a call can take up to about ceil(hosts / SSH_MCP_MAX_POOL_SIZE) x (`timeout` + connect time). The connection pool is shared with every other tool; when it is full this call waits for slots instead of failing, and gives up only when none of its own hosts holds a slot and a full `timeout` has passed with none of them starting or finishing. The hosts waiting at that point (up to one per parallel slot) and every host still queued then report `Connection pool is full`; the queued ones are never attempted. Rerun those hosts once the other calls finish, or raise SSH_MCP_MAX_POOL_SIZE. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
2312
2398
  {
2313
- hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
2399
+ // .min(1): `hosts: []` used to return an empty success block indistinguishable from a
2400
+ // no-op -- the same boundary rejection ssh_git_check applies to its empty host.
2401
+ hosts: z.array(z.string()).min(1, "hosts must not be empty. Name at least one host -- an empty list runs the command on nothing.").describe("List of SSH hostnames or IPs"),
2314
2402
  command: z.string().describe("Shell command to execute on all hosts"),
2315
2403
  port: PortSchema,
2316
2404
  username: UsernameSchema,
@@ -2359,6 +2447,7 @@ ${result.stderr}`);
2359
2447
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
2360
2448
  timeout: TimeoutSchema
2361
2449
  },
2450
+ READ_ONLY_REMOTE,
2362
2451
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2363
2452
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2364
2453
  return connectionPool.withConnection(
@@ -2385,6 +2474,7 @@ ${files.join("\n")}` }] };
2385
2474
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
2386
2475
  timeout: TimeoutSchema
2387
2476
  },
2477
+ READ_ONLY_REMOTE,
2388
2478
  async ({ path, lines, grep, timeout, ...conn }) => {
2389
2479
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2390
2480
  return connectionPool.withConnection(
@@ -2415,6 +2505,7 @@ ${files.join("\n")}` }] };
2415
2505
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
2416
2506
  timeout: TimeoutSchema
2417
2507
  },
2508
+ READ_ONLY_REMOTE,
2418
2509
  async ({ service, timeout, ...conn }) => {
2419
2510
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2420
2511
  return connectionPool.withConnection(
@@ -2439,11 +2530,25 @@ ${files.join("\n")}` }] };
2439
2530
 
2440
2531
  // src/server.ts
2441
2532
  var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
2533
+ var SERVER_INSTRUCTIONS = [
2534
+ "ssh-mcp runs commands and moves files on remote hosts over SSH, using ~/.ssh/config, the ssh-agent and known_hosts the way the OpenSSH client does.",
2535
+ "",
2536
+ "- After an auth, connect or host-key failure, run ssh_diagnose on that host before retrying. ssh_test is the quick check; ssh_key_list and ssh_key_load fix a key missing from the agent; ssh_known_hosts_fix replaces a stale host key.",
2537
+ "- Prefer the purpose-built tools over ssh_exec: ssh_read_file, ssh_ls, ssh_stat, ssh_find, ssh_tail, ssh_service_status. Use ssh_multi_exec for the same command on several hosts.",
2538
+ "- ssh_exec returns the exit code first, then stderr, then stdout. Proxies may cut long results (Yaw MCP keeps about 100 KB), so filter on the remote (grep, head, tail) instead of printing everything. Page a large file with ssh_read_file offset and length.",
2539
+ "- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
2540
+ "- Host keys: a host already in known_hosts must match. An unknown host is trusted on every connection unless strict mode (SSH_MCP_STRICT_HOSTKEYS=1) is on; add it with ssh_known_hosts_fix first.",
2541
+ "- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
2542
+ "- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
2543
+ ].join("\n");
2442
2544
  function createServer(pool) {
2443
- const server = new McpServer({
2444
- name: "ssh-mcp",
2445
- version
2446
- });
2545
+ const server = new McpServer(
2546
+ {
2547
+ name: "ssh-mcp",
2548
+ version
2549
+ },
2550
+ { instructions: SERVER_INSTRUCTIONS }
2551
+ );
2447
2552
  registerTools(server, pool);
2448
2553
  return server;
2449
2554
  }
@@ -2451,6 +2556,7 @@ export {
2451
2556
  ConnectionPool,
2452
2557
  POOL_FULL_ERROR_CODE,
2453
2558
  PoolFullError,
2559
+ SERVER_INSTRUCTIONS,
2454
2560
  checkConnectivity,
2455
2561
  checkGitSsh,
2456
2562
  checkKnownHosts,
@@ -2475,14 +2581,17 @@ export {
2475
2581
  isPoolFullError,
2476
2582
  listDir,
2477
2583
  listSshKeys,
2584
+ listSshKeysDetailed,
2478
2585
  loadKey,
2479
2586
  makeDir,
2480
2587
  multiExec,
2481
2588
  readFile,
2589
+ readFileRange,
2482
2590
  readKnownHostsKeys,
2483
2591
  registerTools,
2484
2592
  resolveConfig,
2485
2593
  serviceStatus,
2594
+ shellQuote,
2486
2595
  statFile,
2487
2596
  tail,
2488
2597
  testConnection,
package/dist/ssh.d.ts CHANGED
@@ -20,7 +20,7 @@ export interface ExecResult {
20
20
  }
21
21
  /** Why the hostVerifier turned a server's host key down. */
22
22
  export type HostKeyRejectionReason =
23
- /** No known_hosts entry at all, and SSH_MCP_STRICT_HOST_KEY=1. */
23
+ /** No known_hosts entry at all, and strict mode is on (SSH_MCP_STRICT_HOSTKEYS, see isStrictHostKeyMode). */
24
24
  "unknown-host-strict"
25
25
  /** known_hosts has entries for this host, but none of the algorithm the server offered. */
26
26
  | "algorithm-not-in-known-hosts"
@@ -107,6 +107,21 @@ export declare function hostKeyBlobType(key: Buffer): string | null;
107
107
  export declare function hostKeyAlgorithmOrder(knownHostTypes: ReadonlyArray<string>): string[] | null;
108
108
  /** Test-only: drop the memoized known_hosts key types. */
109
109
  export declare function clearKnownHostTypeCache(): void;
110
+ /**
111
+ * Strict host-key mode: reject a host with no known_hosts entry instead of trusting it.
112
+ *
113
+ * Two names, one switch. SSH_MCP_STRICT_HOSTKEYS is the documented one.
114
+ * SSH_MCP_STRICT_HOST_KEY is the original name and keeps working, but it is
115
+ * credential-shaped -- `KEY` is a whole underscore segment -- so Yaw MCP's doctor
116
+ * listed `SSH_MCP_STRICT_HOST_KEY=1` as a plaintext secret and suggested moving it
117
+ * into the vault. `HOSTKEYS` is not a credential segment, so the new name is not
118
+ * flagged. Either one turns strict mode on.
119
+ *
120
+ * Both are read the way Yaw MCP reads an opt-in: trimmed, case-insensitive `1` or
121
+ * `true`. Only the exact string "1" used to count, so `=true` silently left strict
122
+ * mode OFF -- the unsafe direction for a security switch.
123
+ */
124
+ export declare function isStrictHostKeyMode(env?: NodeJS.ProcessEnv): boolean;
110
125
  export declare function resolveConfig(config: SSHConfig): ResolvedConfig;
111
126
  export declare function formatDiagnostics(host: string): string;
112
127
  export declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
@@ -136,7 +151,7 @@ export interface JumpHop {
136
151
  *
137
152
  * It is also a host-key issue, not only a connectivity one: `knownHostsTargets`
138
153
  * rejects those mangled spellings and returns [], and a verifier with zero known
139
- * entries accepts ANY key unless SSH_MCP_STRICT_HOST_KEY=1. Parsing correctly is
154
+ * entries accepts ANY key unless SSH_MCP_STRICT_HOSTKEYS=1. Parsing correctly is
140
155
  * what puts the bastion hop back under real known_hosts checking.
141
156
  *
142
157
  * Grammar (OpenSSH ssh_config(5)): `[user@]host[:port]`, or the equivalent
@@ -173,7 +188,28 @@ export declare function enhanceSshError(err: unknown, host: string, resolved?: R
173
188
  export declare function connect(config: SSHConfig): Promise<Client>;
174
189
  export declare const DEFAULT_MAX_EXEC_BYTES: number;
175
190
  export declare function exec(client: Client, command: string, timeoutMs?: number, maxBytes?: number): Promise<ExecResult>;
191
+ export declare const DEFAULT_MAX_READ_BYTES: number;
176
192
  export declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
193
+ /** One page of a remote file, plus where it sits in the whole. */
194
+ export interface FileRange {
195
+ /** The bytes read, decoded as UTF-8. A page boundary can split a multi-byte character. */
196
+ text: string;
197
+ /** Byte offset the page starts at (as requested). */
198
+ offset: number;
199
+ /** Number of bytes actually read: less than requested at end of file. */
200
+ bytesRead: number;
201
+ /** Size of the whole file, from the same stat. */
202
+ size: number;
203
+ }
204
+ /**
205
+ * Read `length` bytes of a remote file starting at byte `offset`.
206
+ *
207
+ * The paging half of ssh_read_file. A whole-file read is capped at 10 MB here, but a
208
+ * proxy in front of this server can cap far lower -- Yaw MCP keeps 100 KB of a result
209
+ * by default and cuts the rest -- so a large file has to be readable in pages without
210
+ * turning a cap off. `length` is bounded by the same `maxBytes` as a whole read.
211
+ */
212
+ export declare function readFileRange(client: Client, remotePath: string, offset: number, length: number, maxBytes?: number): Promise<FileRange>;
177
213
  export declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
178
214
  export declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
179
215
  export declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.17.3",
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",
@@ -28,6 +28,8 @@
28
28
  "test": "vitest run",
29
29
  "test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
30
30
  "test:ci": "npm run build && npm test",
31
+ "check:compliance": "node scripts/check-compliance.mjs",
32
+ "check:oam-floor": "node scripts/check-oam-floor.mjs",
31
33
  "prepublishOnly": "npm run build"
32
34
  },
33
35
  "keywords": [
@@ -72,8 +74,8 @@
72
74
  "@types/node": "^26.1.1",
73
75
  "@types/ssh2": "^1.15.5",
74
76
  "@vitest/coverage-v8": "^4.1.11",
77
+ "@yawlabs/mcp-compliance": "^0.20.4",
75
78
  "esbuild": "^0.28.1",
76
- "postject": "^1.0.0-alpha.6",
77
79
  "tsup": "^8.5.1",
78
80
  "typescript": "^7.0.2",
79
81
  "vitest": "^4.1.6"