@yawlabs/ssh-mcp 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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, agent auth needs oam 0.18.0 or newer: that is the first oam whose `net` can dial a named pipe or a Unix socket, which is how ssh2 reaches the agent (and the launcher never serves on an older one). Agent-backed remote operations have so far been verified end to end on Node only; on oam, the handshake and `ssh_config_lookup` were verified, not an agent-only `ssh_exec`. 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
@@ -336,7 +338,7 @@ Two environment variables control the choice; set them in your MCP client's `env
336
338
  - `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
337
339
  - `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.
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; 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.
340
342
 
341
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.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.
342
344
 
@@ -346,6 +348,8 @@ When the launcher is itself started by oam (`oam run <path>/bin/ssh-mcp.mjs`, wh
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 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
package/bin/ssh-mcp.mjs CHANGED
@@ -79,13 +79,29 @@
79
79
  * MINIMUM OAM VERSION
80
80
  * The latest oam release, 0.18.0 -- 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
+ *
83
+ * The floor is not cosmetic. Two things this server does need 0.18.0 itself,
84
+ * so anyone lowering it has to answer for both:
85
+ *
86
+ * 1. An explicit child `env` that DELETES a variable. probeAgent
87
+ * (src/env.ts:85-90) runs `ssh-add -l` with SSH_AUTH_SOCK removed, so
88
+ * Windows ssh-add falls back to the OpenSSH named pipe instead of a stale
89
+ * Unix-style socket. Up to 0.17.x oam laid an explicit `env` OVER its
90
+ * startup environment, so the deleted SSH_AUTH_SOCK came back and ssh-add
91
+ * probed the wrong agent. 0.18.0 makes an explicit `env` replace it, as
92
+ * Node does (oam CHANGELOG 0.18.0, B4).
93
+ * 2. `net.connect` to a Windows named pipe or a Unix socket path.
94
+ * resolveConfig (src/ssh.ts:581-584) hands ssh2 SSH_AUTH_SOCK, or on Windows
95
+ * `\\.\pipe\openssh-ssh-agent`, and ssh2 dials it with
96
+ * `new Socket().connect(path)` for every agent-backed connection. Up to
97
+ * 0.17.x oam could not dial either (oam CHANGELOG 0.18.0, F1).
98
+ *
99
+ * Background, long before the current floor: before 0.9.0
100
+ * `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
101
+ * `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
102
+ * reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
103
+ * `'pipe'`. This server shells out to ssh, ssh-add and ssh-keygen on its main
104
+ * paths, so those were reachable bugs rather than theoretical ones.
89
105
  *
90
106
  * SELECTION
91
107
  * SSH_MCP_RUNTIME=auto newest usable oam, else Node (default)
@@ -103,7 +119,12 @@ import { constants, homedir } from "node:os";
103
119
  import { delimiter, join } from "node:path";
104
120
  import { fileURLToPath } from "node:url";
105
121
 
106
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
122
+ /**
123
+ * Oldest oam this server runs on: the one whose explicit child `env` replaces
124
+ * the environment (needed by probeAgent, src/env.ts) and whose `net` dials named
125
+ * pipes and Unix sockets (needed by ssh2's agent client, src/ssh.ts). See MINIMUM
126
+ * OAM VERSION above.
127
+ */
107
128
  const OAM_MIN = [0, 18, 0];
108
129
 
109
130
  /**
@@ -141,8 +162,12 @@ function pathKey(p) {
141
162
  * usually has oam/target/release on PATH, and cargo replaces that binary
142
163
  * underneath running processes; the installed copy is the release the user
143
164
  * 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.
165
+ * to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first.
166
+ *
167
+ * OAM_INSTALL_DIR, when set, is searched before both. It is the installer's
168
+ * install target (oam docs/cli-reference.md), so an oam put somewhere custom and
169
+ * left off PATH is still found -- this comment used to say it "can pick either"
170
+ * default while the code never read it.
146
171
  *
147
172
  * Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
148
173
  * run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
@@ -156,6 +181,7 @@ function discoverOamPaths() {
156
181
  if (isWin) {
157
182
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
158
183
  }
184
+ if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
159
185
  const onPath = (process.env.PATH ?? "")
160
186
  .split(delimiter)
161
187
  .filter(Boolean)
@@ -405,6 +431,48 @@ const fallbackFailed = (e) => {
405
431
  process.exitCode = 1;
406
432
  };
407
433
 
434
+ /**
435
+ * A permission flag oam may have put in NODE_OPTIONS: `--permission` (and the
436
+ * older `--experimental-permission`) or any `--allow-*` grant, with or without
437
+ * an `=value`.
438
+ */
439
+ const PERMISSION_FLAG = /^--(?:experimental-)?permission$|^--allow-[a-z0-9-]+(?:=.*)?$/;
440
+
441
+ /**
442
+ * `env` with oam's permission flags removed from NODE_OPTIONS, or `env` itself
443
+ * when there is nothing to remove.
444
+ *
445
+ * oam 0.18.0 appends its own `--permission` / `--allow-*` flags to every child's
446
+ * NODE_OPTIONS "for any program" (oam CHANGELOG 0.18.0, Permissions), so a
447
+ * process started under a sandboxed oam carries them in its environment. Node
448
+ * refuses some of them there -- measured on Node 22:
449
+ * `NODE_OPTIONS="--permission --allow-net" node` exits 9 with "--allow-net is
450
+ * not allowed in NODE_OPTIONS" -- and this launcher has no sandbox to pass on
451
+ * (see NO SANDBOX HERE), so the tokens are dropped before a child is started.
452
+ *
453
+ * Only the INHERITED copy can be removed here. When THIS process was itself
454
+ * started as `oam --permission ...`, oam re-appends those flags from its own
455
+ * execArgv inside spawn(), after this env is built -- measured on oam 0.18.0:
456
+ * the Node child still exits 9 with this env. That host is outside what this
457
+ * launcher supports, since the server needs unrestricted net and child-process
458
+ * grants anyway.
459
+ *
460
+ * Tokens are split on whitespace with double-quoted spans kept whole, which is
461
+ * how Node itself reads NODE_OPTIONS. Pure, so src/tests/launcher.test.ts can
462
+ * extract and test it without booting a runtime.
463
+ */
464
+ function stripPermissionFlags(env) {
465
+ const raw = env.NODE_OPTIONS;
466
+ if (!raw) return env;
467
+ const tokens = raw.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
468
+ const kept = tokens.filter((t) => !PERMISSION_FLAG.test(t.replaceAll('"', "")));
469
+ if (kept.length === tokens.length) return env;
470
+ const next = { ...env };
471
+ if (kept.length === 0) delete next.NODE_OPTIONS;
472
+ else next.NODE_OPTIONS = kept.join(" ");
473
+ return next;
474
+ }
475
+
408
476
  /**
409
477
  * Spawn the server in a child runtime and mirror its lifetime.
410
478
  *
@@ -428,7 +496,9 @@ async function launchChild(cmd, args, onLaunchFailed) {
428
496
  // server's shutdown path. Piping preserves both as well: bytes are copied
429
497
  // unchanged, and stdin's end propagates to the child.
430
498
  stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
431
- env: process.env,
499
+ // On an oam host, drop the permission flags oam put in NODE_OPTIONS; see
500
+ // stripPermissionFlags. A Node host's env passes through untouched.
501
+ env: process.versions.oam !== undefined ? stripPermissionFlags(process.env) : process.env,
432
502
  windowsHide: true,
433
503
  });
434
504
  } catch (err) {
package/dist/index.js CHANGED
@@ -441,8 +441,15 @@ function cachedKnownHostTypes(hosts, port) {
441
441
  knownHostTypeCache.set(cacheKey, { at: now, types });
442
442
  return types;
443
443
  }
444
+ function isStrictHostKeyMode(env = process.env) {
445
+ const on = (v) => {
446
+ const t = (v ?? "").trim().toLowerCase();
447
+ return t === "1" || t === "true";
448
+ };
449
+ return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
450
+ }
444
451
  function buildHostVerifier(hosts, port, rejection) {
445
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
452
+ const strict = isStrictHostKeyMode();
446
453
  const label = hosts.join(" / ");
447
454
  const remediationHost = unbracketHost(hosts[0]);
448
455
  return (key) => {
@@ -452,7 +459,7 @@ function buildHostVerifier(hosts, port, rejection) {
452
459
  if (strict) {
453
460
  rejection.current = {
454
461
  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`
462
+ 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
463
  };
457
464
  }
458
465
  return !strict;
@@ -844,7 +851,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
844
851
  });
845
852
  if (stats.size > maxBytes) {
846
853
  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.`
854
+ `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
855
  );
849
856
  }
850
857
  return await new Promise((resolve, reject) => {
@@ -857,6 +864,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
857
864
  sftp.end();
858
865
  }
859
866
  }
867
+ async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
868
+ if (!Number.isSafeInteger(offset) || offset < 0)
869
+ throw new Error(`offset must be a non-negative integer, got ${offset}`);
870
+ if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
871
+ if (length > maxBytes) {
872
+ throw new Error(
873
+ `length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
874
+ );
875
+ }
876
+ const sftp = await getSftp(client);
877
+ try {
878
+ const stats = await new Promise((resolve, reject) => {
879
+ sftp.stat(remotePath, (err, st) => {
880
+ if (err) return reject(err);
881
+ resolve(st);
882
+ });
883
+ });
884
+ const want = Math.max(0, Math.min(length, stats.size - offset));
885
+ if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
886
+ const handle = await new Promise((resolve, reject) => {
887
+ sftp.open(remotePath, "r", (err, h) => {
888
+ if (err) return reject(err);
889
+ resolve(h);
890
+ });
891
+ });
892
+ try {
893
+ const buf = Buffer.alloc(want);
894
+ let got = 0;
895
+ while (got < want) {
896
+ const n = await new Promise((resolve, reject) => {
897
+ sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
898
+ if (err) return reject(err);
899
+ resolve(bytesRead ?? 0);
900
+ });
901
+ });
902
+ if (n === 0) break;
903
+ got += n;
904
+ }
905
+ return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
906
+ } finally {
907
+ sftp.close(handle, () => {
908
+ });
909
+ }
910
+ } finally {
911
+ sftp.end();
912
+ }
913
+ }
860
914
  async function writeFile(client, remotePath, content) {
861
915
  const sftp = await getSftp(client);
862
916
  try {
@@ -1532,6 +1586,7 @@ var ConnectionPool = class {
1532
1586
  if (entry.client === client) {
1533
1587
  entry.refCount = Math.max(0, entry.refCount - 1);
1534
1588
  if (entry.refCount === 0 && !entry.dead) {
1589
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
1535
1590
  entry.idleTimer = setTimeout(() => {
1536
1591
  try {
1537
1592
  entry.client.end();
@@ -1784,14 +1839,14 @@ async function find(client, options, timeoutMs = 3e4) {
1784
1839
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1785
1840
  );
1786
1841
  }
1787
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1788
- const args = [shellQuote(pathOperand)];
1842
+ const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
1843
+ const args = [shellQuote(defuseFindOperand(options.path))];
1789
1844
  if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1790
1845
  if (options.type) args.push("-type", shellQuote(options.type));
1791
1846
  if (options.name) args.push("-name", shellQuote(options.name));
1792
1847
  if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1793
1848
  if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1794
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1849
+ if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
1795
1850
  const command = `find ${args.join(" ")}`;
1796
1851
  const result = await exec(client, command, timeoutMs);
1797
1852
  if (!result.stdout.trim() && result.stderr.trim()) {
@@ -1814,7 +1869,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1814
1869
  return result.stdout;
1815
1870
  }
1816
1871
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1817
- const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1872
+ const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1818
1873
  const raw = result.stdout;
1819
1874
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1820
1875
  const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
@@ -1901,6 +1956,9 @@ var connectionParams = {
1901
1956
  var DEFAULT_TIMEOUT_MS = 3e4;
1902
1957
  var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1903
1958
  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.`;
1959
+ var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
1960
+ var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
1961
+ var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
1904
1962
  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
1963
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1906
1964
  function applyEnvPrefix(command, env) {
@@ -1921,7 +1979,7 @@ function registerTools(server, pool) {
1921
1979
  const connectionPool = pool ?? new ConnectionPool();
1922
1980
  server.tool(
1923
1981
  "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).",
1982
+ "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
1983
  {
1926
1984
  ...connectionParams,
1927
1985
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
@@ -1936,13 +1994,16 @@ function registerTools(server, pool) {
1936
1994
  conn,
1937
1995
  async (client) => {
1938
1996
  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") }] };
1997
+ const status = [`[exit code: ${result.code}]`];
1998
+ if (result.signal) status.push(`[signal: ${result.signal}]`);
1999
+ if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
2000
+ if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
2001
+ const content = [{ type: "text", text: status.join("\n") }];
2002
+ if (result.stderr) content.push({ type: "text", text: `[stderr]
2003
+ ${result.stderr}` });
2004
+ if (result.stdout) content.push({ type: "text", text: `[stdout]
2005
+ ${result.stdout}` });
2006
+ return { content };
1946
2007
  },
1947
2008
  poolWait(timeoutMs)
1948
2009
  );
@@ -1950,19 +2011,30 @@ ${result.stderr}`);
1950
2011
  );
1951
2012
  server.tool(
1952
2013
  "ssh_read_file",
1953
- `Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
2014
+ `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
2015
  {
1955
2016
  ...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 /.")
2017
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
2018
+ offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
2019
+ length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
2020
+ "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."
2021
+ )
1959
2022
  },
1960
- async ({ path, ...conn }) => {
2023
+ READ_ONLY_REMOTE,
2024
+ async ({ path, offset, length, ...conn }) => {
1961
2025
  return connectionPool.withConnection(
1962
2026
  conn,
1963
2027
  async (client) => {
1964
- const content = await readFile(client, path);
1965
- return { content: [{ type: "text", text: content }] };
2028
+ if (offset === void 0 && length === void 0) {
2029
+ const content2 = await readFile(client, path);
2030
+ return { content: [{ type: "text", text: content2 }] };
2031
+ }
2032
+ const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
2033
+ const end = page.offset + page.bytesRead;
2034
+ 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]`;
2035
+ const content = [{ type: "text", text: where }];
2036
+ if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
2037
+ return { content };
1966
2038
  },
1967
2039
  poolWait()
1968
2040
  );
@@ -1976,6 +2048,7 @@ ${result.stderr}`);
1976
2048
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1977
2049
  content: z.string().describe("File content to write")
1978
2050
  },
2051
+ DESTRUCTIVE_REMOTE,
1979
2052
  async ({ path, content, ...conn }) => {
1980
2053
  return connectionPool.withConnection(
1981
2054
  conn,
@@ -1990,7 +2063,7 @@ ${result.stderr}`);
1990
2063
  );
1991
2064
  server.tool(
1992
2065
  "ssh_upload",
1993
- `Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
2066
+ `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
2067
  {
1995
2068
  ...connectionParams,
1996
2069
  localPath: z.string().describe("Path to the local file to upload"),
@@ -2033,6 +2106,7 @@ ${result.stderr}`);
2033
2106
  ...connectionParams,
2034
2107
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
2035
2108
  },
2109
+ READ_ONLY_REMOTE,
2036
2110
  async ({ path, ...conn }) => {
2037
2111
  return connectionPool.withConnection(
2038
2112
  conn,
@@ -2054,6 +2128,7 @@ ${result.stderr}`);
2054
2128
  ...connectionParams,
2055
2129
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
2056
2130
  },
2131
+ READ_ONLY_REMOTE,
2057
2132
  async ({ path, ...conn }) => {
2058
2133
  return connectionPool.withConnection(
2059
2134
  conn,
@@ -2104,6 +2179,7 @@ ${result.stderr}`);
2104
2179
  "Absolute path of the file or empty directory to delete. Must start with /."
2105
2180
  )
2106
2181
  },
2182
+ DESTRUCTIVE_REMOTE,
2107
2183
  async ({ path, ...conn }) => {
2108
2184
  return connectionPool.withConnection(
2109
2185
  conn,
@@ -2122,6 +2198,7 @@ ${result.stderr}`);
2122
2198
  host: HostSchema,
2123
2199
  port: PortSchema
2124
2200
  },
2201
+ READ_ONLY_REMOTE,
2125
2202
  async ({ host, port }) => {
2126
2203
  const report = diagnose(host, port || 22);
2127
2204
  const lines = [];
@@ -2168,6 +2245,7 @@ ${result.stderr}`);
2168
2245
  "ssh_key_list",
2169
2246
  "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
2247
  {},
2248
+ READ_ONLY_LOCAL,
2171
2249
  async () => {
2172
2250
  const listing = listSshKeysDetailed();
2173
2251
  if (listing.status === "unreadable") {
@@ -2232,6 +2310,7 @@ ${result.stderr}`);
2232
2310
  {
2233
2311
  host: HostSchema
2234
2312
  },
2313
+ READ_ONLY_LOCAL,
2235
2314
  async ({ host }) => {
2236
2315
  const result = configLookup(host);
2237
2316
  if ("error" in result) {
@@ -2304,7 +2383,9 @@ ${result.stderr}`);
2304
2383
  "ssh_multi_exec",
2305
2384
  "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
2385
  {
2307
- hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
2386
+ // .min(1): `hosts: []` used to return an empty success block indistinguishable from a
2387
+ // no-op -- the same boundary rejection ssh_git_check applies to its empty host.
2388
+ 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
2389
  command: z.string().describe("Shell command to execute on all hosts"),
2309
2390
  port: PortSchema,
2310
2391
  username: UsernameSchema,
@@ -2353,6 +2434,7 @@ ${result.stderr}`);
2353
2434
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
2354
2435
  timeout: TimeoutSchema
2355
2436
  },
2437
+ READ_ONLY_REMOTE,
2356
2438
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2357
2439
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2358
2440
  return connectionPool.withConnection(
@@ -2379,6 +2461,7 @@ ${files.join("\n")}` }] };
2379
2461
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
2380
2462
  timeout: TimeoutSchema
2381
2463
  },
2464
+ READ_ONLY_REMOTE,
2382
2465
  async ({ path, lines, grep, timeout, ...conn }) => {
2383
2466
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2384
2467
  return connectionPool.withConnection(
@@ -2409,6 +2492,7 @@ ${files.join("\n")}` }] };
2409
2492
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
2410
2493
  timeout: TimeoutSchema
2411
2494
  },
2495
+ READ_ONLY_REMOTE,
2412
2496
  async ({ service, timeout, ...conn }) => {
2413
2497
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2414
2498
  return connectionPool.withConnection(
@@ -2433,11 +2517,25 @@ ${files.join("\n")}` }] };
2433
2517
 
2434
2518
  // src/server.ts
2435
2519
  var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
2520
+ var SERVER_INSTRUCTIONS = [
2521
+ "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.",
2522
+ "",
2523
+ "- 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.",
2524
+ "- 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.",
2525
+ "- 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.",
2526
+ "- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
2527
+ "- 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.",
2528
+ "- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
2529
+ "- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
2530
+ ].join("\n");
2436
2531
  function createServer(pool) {
2437
- const server = new McpServer({
2438
- name: "ssh-mcp",
2439
- version
2440
- });
2532
+ const server = new McpServer(
2533
+ {
2534
+ name: "ssh-mcp",
2535
+ version
2536
+ },
2537
+ { instructions: SERVER_INSTRUCTIONS }
2538
+ );
2441
2539
  registerTools(server, pool);
2442
2540
  return server;
2443
2541
  }
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
@@ -449,8 +449,15 @@ function cachedKnownHostTypes(hosts, port) {
449
449
  knownHostTypeCache.set(cacheKey, { at: now, types });
450
450
  return types;
451
451
  }
452
+ function isStrictHostKeyMode(env = process.env) {
453
+ const on = (v) => {
454
+ const t = (v ?? "").trim().toLowerCase();
455
+ return t === "1" || t === "true";
456
+ };
457
+ return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
458
+ }
452
459
  function buildHostVerifier(hosts, port, rejection) {
453
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
460
+ const strict = isStrictHostKeyMode();
454
461
  const label = hosts.join(" / ");
455
462
  const remediationHost = unbracketHost(hosts[0]);
456
463
  return (key) => {
@@ -460,7 +467,7 @@ function buildHostVerifier(hosts, port, rejection) {
460
467
  if (strict) {
461
468
  rejection.current = {
462
469
  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`
470
+ 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
471
  };
465
472
  }
466
473
  return !strict;
@@ -861,7 +868,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
861
868
  });
862
869
  if (stats.size > maxBytes) {
863
870
  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.`
871
+ `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
872
  );
866
873
  }
867
874
  return await new Promise((resolve, reject) => {
@@ -874,6 +881,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
874
881
  sftp.end();
875
882
  }
876
883
  }
884
+ async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
885
+ if (!Number.isSafeInteger(offset) || offset < 0)
886
+ throw new Error(`offset must be a non-negative integer, got ${offset}`);
887
+ if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
888
+ if (length > maxBytes) {
889
+ throw new Error(
890
+ `length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
891
+ );
892
+ }
893
+ const sftp = await getSftp(client);
894
+ try {
895
+ const stats = await new Promise((resolve, reject) => {
896
+ sftp.stat(remotePath, (err, st) => {
897
+ if (err) return reject(err);
898
+ resolve(st);
899
+ });
900
+ });
901
+ const want = Math.max(0, Math.min(length, stats.size - offset));
902
+ if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
903
+ const handle = await new Promise((resolve, reject) => {
904
+ sftp.open(remotePath, "r", (err, h) => {
905
+ if (err) return reject(err);
906
+ resolve(h);
907
+ });
908
+ });
909
+ try {
910
+ const buf = Buffer.alloc(want);
911
+ let got = 0;
912
+ while (got < want) {
913
+ const n = await new Promise((resolve, reject) => {
914
+ sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
915
+ if (err) return reject(err);
916
+ resolve(bytesRead ?? 0);
917
+ });
918
+ });
919
+ if (n === 0) break;
920
+ got += n;
921
+ }
922
+ return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
923
+ } finally {
924
+ sftp.close(handle, () => {
925
+ });
926
+ }
927
+ } finally {
928
+ sftp.end();
929
+ }
930
+ }
877
931
  async function writeFile(client, remotePath, content) {
878
932
  const sftp = await getSftp(client);
879
933
  try {
@@ -1544,6 +1598,7 @@ var ConnectionPool = class {
1544
1598
  if (entry.client === client) {
1545
1599
  entry.refCount = Math.max(0, entry.refCount - 1);
1546
1600
  if (entry.refCount === 0 && !entry.dead) {
1601
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
1547
1602
  entry.idleTimer = setTimeout(() => {
1548
1603
  try {
1549
1604
  entry.client.end();
@@ -1787,14 +1842,14 @@ async function find(client, options, timeoutMs = 3e4) {
1787
1842
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1788
1843
  );
1789
1844
  }
1790
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1791
- const args = [shellQuote(pathOperand)];
1845
+ const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
1846
+ const args = [shellQuote(defuseFindOperand(options.path))];
1792
1847
  if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1793
1848
  if (options.type) args.push("-type", shellQuote(options.type));
1794
1849
  if (options.name) args.push("-name", shellQuote(options.name));
1795
1850
  if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1796
1851
  if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1797
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1852
+ if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
1798
1853
  const command = `find ${args.join(" ")}`;
1799
1854
  const result = await exec(client, command, timeoutMs);
1800
1855
  if (!result.stdout.trim() && result.stderr.trim()) {
@@ -1817,7 +1872,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1817
1872
  return result.stdout;
1818
1873
  }
1819
1874
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1820
- const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1875
+ const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1821
1876
  const raw = result.stdout;
1822
1877
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1823
1878
  const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
@@ -1907,6 +1962,9 @@ var connectionParams = {
1907
1962
  var DEFAULT_TIMEOUT_MS = 3e4;
1908
1963
  var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1909
1964
  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.`;
1965
+ var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
1966
+ var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
1967
+ var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
1910
1968
  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
1969
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1912
1970
  function applyEnvPrefix(command, env) {
@@ -1927,7 +1985,7 @@ function registerTools(server, pool) {
1927
1985
  const connectionPool = pool ?? new ConnectionPool();
1928
1986
  server.tool(
1929
1987
  "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).",
1988
+ "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
1989
  {
1932
1990
  ...connectionParams,
1933
1991
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
@@ -1942,13 +2000,16 @@ function registerTools(server, pool) {
1942
2000
  conn,
1943
2001
  async (client) => {
1944
2002
  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") }] };
2003
+ const status = [`[exit code: ${result.code}]`];
2004
+ if (result.signal) status.push(`[signal: ${result.signal}]`);
2005
+ if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
2006
+ if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
2007
+ const content = [{ type: "text", text: status.join("\n") }];
2008
+ if (result.stderr) content.push({ type: "text", text: `[stderr]
2009
+ ${result.stderr}` });
2010
+ if (result.stdout) content.push({ type: "text", text: `[stdout]
2011
+ ${result.stdout}` });
2012
+ return { content };
1952
2013
  },
1953
2014
  poolWait(timeoutMs)
1954
2015
  );
@@ -1956,19 +2017,30 @@ ${result.stderr}`);
1956
2017
  );
1957
2018
  server.tool(
1958
2019
  "ssh_read_file",
1959
- `Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
2020
+ `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
2021
  {
1961
2022
  ...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 /.")
2023
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
2024
+ offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
2025
+ length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
2026
+ "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."
2027
+ )
1965
2028
  },
1966
- async ({ path, ...conn }) => {
2029
+ READ_ONLY_REMOTE,
2030
+ async ({ path, offset, length, ...conn }) => {
1967
2031
  return connectionPool.withConnection(
1968
2032
  conn,
1969
2033
  async (client) => {
1970
- const content = await readFile(client, path);
1971
- return { content: [{ type: "text", text: content }] };
2034
+ if (offset === void 0 && length === void 0) {
2035
+ const content2 = await readFile(client, path);
2036
+ return { content: [{ type: "text", text: content2 }] };
2037
+ }
2038
+ const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
2039
+ const end = page.offset + page.bytesRead;
2040
+ 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]`;
2041
+ const content = [{ type: "text", text: where }];
2042
+ if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
2043
+ return { content };
1972
2044
  },
1973
2045
  poolWait()
1974
2046
  );
@@ -1982,6 +2054,7 @@ ${result.stderr}`);
1982
2054
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1983
2055
  content: z.string().describe("File content to write")
1984
2056
  },
2057
+ DESTRUCTIVE_REMOTE,
1985
2058
  async ({ path, content, ...conn }) => {
1986
2059
  return connectionPool.withConnection(
1987
2060
  conn,
@@ -1996,7 +2069,7 @@ ${result.stderr}`);
1996
2069
  );
1997
2070
  server.tool(
1998
2071
  "ssh_upload",
1999
- `Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
2072
+ `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
2073
  {
2001
2074
  ...connectionParams,
2002
2075
  localPath: z.string().describe("Path to the local file to upload"),
@@ -2039,6 +2112,7 @@ ${result.stderr}`);
2039
2112
  ...connectionParams,
2040
2113
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
2041
2114
  },
2115
+ READ_ONLY_REMOTE,
2042
2116
  async ({ path, ...conn }) => {
2043
2117
  return connectionPool.withConnection(
2044
2118
  conn,
@@ -2060,6 +2134,7 @@ ${result.stderr}`);
2060
2134
  ...connectionParams,
2061
2135
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
2062
2136
  },
2137
+ READ_ONLY_REMOTE,
2063
2138
  async ({ path, ...conn }) => {
2064
2139
  return connectionPool.withConnection(
2065
2140
  conn,
@@ -2110,6 +2185,7 @@ ${result.stderr}`);
2110
2185
  "Absolute path of the file or empty directory to delete. Must start with /."
2111
2186
  )
2112
2187
  },
2188
+ DESTRUCTIVE_REMOTE,
2113
2189
  async ({ path, ...conn }) => {
2114
2190
  return connectionPool.withConnection(
2115
2191
  conn,
@@ -2128,6 +2204,7 @@ ${result.stderr}`);
2128
2204
  host: HostSchema,
2129
2205
  port: PortSchema
2130
2206
  },
2207
+ READ_ONLY_REMOTE,
2131
2208
  async ({ host, port }) => {
2132
2209
  const report = diagnose(host, port || 22);
2133
2210
  const lines = [];
@@ -2174,6 +2251,7 @@ ${result.stderr}`);
2174
2251
  "ssh_key_list",
2175
2252
  "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
2253
  {},
2254
+ READ_ONLY_LOCAL,
2177
2255
  async () => {
2178
2256
  const listing = listSshKeysDetailed();
2179
2257
  if (listing.status === "unreadable") {
@@ -2238,6 +2316,7 @@ ${result.stderr}`);
2238
2316
  {
2239
2317
  host: HostSchema
2240
2318
  },
2319
+ READ_ONLY_LOCAL,
2241
2320
  async ({ host }) => {
2242
2321
  const result = configLookup(host);
2243
2322
  if ("error" in result) {
@@ -2310,7 +2389,9 @@ ${result.stderr}`);
2310
2389
  "ssh_multi_exec",
2311
2390
  "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
2391
  {
2313
- hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
2392
+ // .min(1): `hosts: []` used to return an empty success block indistinguishable from a
2393
+ // no-op -- the same boundary rejection ssh_git_check applies to its empty host.
2394
+ 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
2395
  command: z.string().describe("Shell command to execute on all hosts"),
2315
2396
  port: PortSchema,
2316
2397
  username: UsernameSchema,
@@ -2359,6 +2440,7 @@ ${result.stderr}`);
2359
2440
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
2360
2441
  timeout: TimeoutSchema
2361
2442
  },
2443
+ READ_ONLY_REMOTE,
2362
2444
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2363
2445
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2364
2446
  return connectionPool.withConnection(
@@ -2385,6 +2467,7 @@ ${files.join("\n")}` }] };
2385
2467
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
2386
2468
  timeout: TimeoutSchema
2387
2469
  },
2470
+ READ_ONLY_REMOTE,
2388
2471
  async ({ path, lines, grep, timeout, ...conn }) => {
2389
2472
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2390
2473
  return connectionPool.withConnection(
@@ -2415,6 +2498,7 @@ ${files.join("\n")}` }] };
2415
2498
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
2416
2499
  timeout: TimeoutSchema
2417
2500
  },
2501
+ READ_ONLY_REMOTE,
2418
2502
  async ({ service, timeout, ...conn }) => {
2419
2503
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2420
2504
  return connectionPool.withConnection(
@@ -2439,11 +2523,25 @@ ${files.join("\n")}` }] };
2439
2523
 
2440
2524
  // src/server.ts
2441
2525
  var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
2526
+ var SERVER_INSTRUCTIONS = [
2527
+ "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.",
2528
+ "",
2529
+ "- 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.",
2530
+ "- 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.",
2531
+ "- 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.",
2532
+ "- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
2533
+ "- 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.",
2534
+ "- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
2535
+ "- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
2536
+ ].join("\n");
2442
2537
  function createServer(pool) {
2443
- const server = new McpServer({
2444
- name: "ssh-mcp",
2445
- version
2446
- });
2538
+ const server = new McpServer(
2539
+ {
2540
+ name: "ssh-mcp",
2541
+ version
2542
+ },
2543
+ { instructions: SERVER_INSTRUCTIONS }
2544
+ );
2447
2545
  registerTools(server, pool);
2448
2546
  return server;
2449
2547
  }
@@ -2451,6 +2549,7 @@ export {
2451
2549
  ConnectionPool,
2452
2550
  POOL_FULL_ERROR_CODE,
2453
2551
  PoolFullError,
2552
+ SERVER_INSTRUCTIONS,
2454
2553
  checkConnectivity,
2455
2554
  checkGitSsh,
2456
2555
  checkKnownHosts,
@@ -2475,14 +2574,17 @@ export {
2475
2574
  isPoolFullError,
2476
2575
  listDir,
2477
2576
  listSshKeys,
2577
+ listSshKeysDetailed,
2478
2578
  loadKey,
2479
2579
  makeDir,
2480
2580
  multiExec,
2481
2581
  readFile,
2582
+ readFileRange,
2482
2583
  readKnownHostsKeys,
2483
2584
  registerTools,
2484
2585
  resolveConfig,
2485
2586
  serviceStatus,
2587
+ shellQuote,
2486
2588
  statFile,
2487
2589
  tail,
2488
2590
  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.0",
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,6 +74,7 @@
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
79
  "postject": "^1.0.0-alpha.6",
77
80
  "tsup": "^8.5.1",