@yawlabs/ssh-mcp 0.17.2 → 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/dist/server.js CHANGED
@@ -299,7 +299,16 @@ function diagnose(host, port = 22) {
299
299
 
300
300
  // src/env.ts
301
301
  import { execFileSync as execFileSync2 } from "child_process";
302
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
302
+ import {
303
+ appendFileSync,
304
+ closeSync,
305
+ existsSync as existsSync2,
306
+ constants as fsConstants,
307
+ fstatSync,
308
+ openSync,
309
+ readdirSync as readdirSync2,
310
+ readFileSync as readFileSync3
311
+ } from "fs";
303
312
  import { homedir as homedir3 } from "os";
304
313
  import { join as join3 } from "path";
305
314
 
@@ -440,8 +449,15 @@ function cachedKnownHostTypes(hosts, port) {
440
449
  knownHostTypeCache.set(cacheKey, { at: now, types });
441
450
  return types;
442
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
+ }
443
459
  function buildHostVerifier(hosts, port, rejection) {
444
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
460
+ const strict = isStrictHostKeyMode();
445
461
  const label = hosts.join(" / ");
446
462
  const remediationHost = unbracketHost(hosts[0]);
447
463
  return (key) => {
@@ -451,7 +467,7 @@ function buildHostVerifier(hosts, port, rejection) {
451
467
  if (strict) {
452
468
  rejection.current = {
453
469
  reason: "unknown-host-strict",
454
- 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`
455
471
  };
456
472
  }
457
473
  return !strict;
@@ -852,7 +868,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
852
868
  });
853
869
  if (stats.size > maxBytes) {
854
870
  throw new Error(
855
- `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).`
856
872
  );
857
873
  }
858
874
  return await new Promise((resolve, reject) => {
@@ -865,6 +881,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
865
881
  sftp.end();
866
882
  }
867
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
+ }
868
931
  async function writeFile(client, remotePath, content) {
869
932
  const sftp = await getSftp(client);
870
933
  try {
@@ -1092,7 +1155,7 @@ function ensureAgent() {
1092
1155
  message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
1093
1156
  };
1094
1157
  }
1095
- function detectKeyType(filePath, fileName) {
1158
+ function detectKeyType(filePath, fileName, content) {
1096
1159
  const pubPath = `${filePath}.pub`;
1097
1160
  if (existsSync2(pubPath)) {
1098
1161
  try {
@@ -1109,7 +1172,6 @@ function detectKeyType(filePath, fileName) {
1109
1172
  if (fileName.includes("ecdsa")) return "ecdsa";
1110
1173
  if (fileName.includes("dsa")) return "dsa";
1111
1174
  try {
1112
- const content = readFileSync3(filePath, "utf8");
1113
1175
  if (content.includes("RSA PRIVATE KEY")) return "rsa";
1114
1176
  if (content.includes("EC PRIVATE KEY")) return "ecdsa";
1115
1177
  if (content.includes("DSA PRIVATE KEY")) return "dsa";
@@ -1148,12 +1210,13 @@ function listSshKeysDetailed() {
1148
1210
  for (const file of files) {
1149
1211
  if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
1150
1212
  const filePath = join3(sshDir, file);
1213
+ let fd;
1151
1214
  try {
1152
- const stat = statSync(filePath);
1153
- if (!stat.isFile()) continue;
1154
- const content = readFileSync3(filePath, "utf8");
1215
+ fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
1216
+ if (!fstatSync(fd).isFile()) continue;
1217
+ const content = readFileSync3(fd, "utf8");
1155
1218
  if (!content.includes("PRIVATE KEY")) continue;
1156
- const type = detectKeyType(filePath, file);
1219
+ const type = detectKeyType(filePath, file, content);
1157
1220
  let fingerprint;
1158
1221
  const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
1159
1222
  if (fpOk) {
@@ -1163,6 +1226,8 @@ function listSshKeysDetailed() {
1163
1226
  const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
1164
1227
  keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1165
1228
  } catch {
1229
+ } finally {
1230
+ if (fd !== void 0) closeSync(fd);
1166
1231
  }
1167
1232
  }
1168
1233
  return { status: "ok", dir: sshDir, keys };
@@ -1318,7 +1383,7 @@ function testConnection(host, port = 22) {
1318
1383
  }
1319
1384
 
1320
1385
  // src/pool.ts
1321
- import { createHash } from "crypto";
1386
+ import { createHmac, randomBytes, scryptSync } from "crypto";
1322
1387
  var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
1323
1388
  var PoolFullError = class extends Error {
1324
1389
  code = POOL_FULL_ERROR_CODE;
@@ -1348,21 +1413,24 @@ function defaultMaxPoolSize() {
1348
1413
  const parsed = Number.parseInt(raw, 10);
1349
1414
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1350
1415
  }
1416
+ var AUTH_FINGERPRINT_SECRET = randomBytes(32);
1417
+ var PASSWORD_SCRYPT_COST = 1024;
1418
+ function updateField(h, tag, value) {
1419
+ const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
1420
+ const len = Buffer.alloc(4);
1421
+ len.writeUInt32BE(bytes.length);
1422
+ h.update(tag);
1423
+ h.update(len);
1424
+ h.update(bytes);
1425
+ }
1351
1426
  function authFingerprint(cc) {
1352
- const h = createHash("sha256");
1353
- if (cc.privateKey) {
1354
- h.update("k");
1355
- h.update(cc.privateKey);
1356
- }
1427
+ const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
1428
+ if (cc.privateKey) updateField(h, "k", cc.privateKey);
1357
1429
  if (cc.password !== void 0) {
1358
- h.update("p");
1359
- h.update(cc.password);
1430
+ updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
1360
1431
  }
1361
- if (typeof cc.agent === "string") {
1362
- h.update("a");
1363
- h.update(cc.agent);
1364
- }
1365
- return h.digest("hex").slice(0, 16);
1432
+ if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
1433
+ return h.digest("hex").slice(0, 32);
1366
1434
  }
1367
1435
  function resolveOrDiagnose(config) {
1368
1436
  try {
@@ -1522,7 +1590,7 @@ var ConnectionPool = class {
1522
1590
  return client;
1523
1591
  }
1524
1592
  throw new Error(
1525
- `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1593
+ `Failed to acquire SSH connection for ${cc.username}@${cc.host}:${cc.port} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1526
1594
  );
1527
1595
  }
1528
1596
  release(client) {
@@ -1530,6 +1598,7 @@ var ConnectionPool = class {
1530
1598
  if (entry.client === client) {
1531
1599
  entry.refCount = Math.max(0, entry.refCount - 1);
1532
1600
  if (entry.refCount === 0 && !entry.dead) {
1601
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
1533
1602
  entry.idleTimer = setTimeout(() => {
1534
1603
  try {
1535
1604
  entry.client.end();
@@ -1746,7 +1815,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1746
1815
  }
1747
1816
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1748
1817
  var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1818
+ var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
1819
+ function isNonNegativeInteger(n) {
1820
+ return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
1821
+ }
1749
1822
  async function find(client, options, timeoutMs = 3e4) {
1823
+ if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
1824
+ throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
1825
+ }
1826
+ if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
1827
+ throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
1828
+ }
1829
+ if (options.minsize !== void 0 && typeof options.minsize !== "string") {
1830
+ throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
1831
+ }
1832
+ if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
1833
+ throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
1834
+ }
1750
1835
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1751
1836
  throw new Error(
1752
1837
  `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
@@ -1757,14 +1842,14 @@ async function find(client, options, timeoutMs = 3e4) {
1757
1842
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1758
1843
  );
1759
1844
  }
1760
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1761
- const args = [shellQuote(pathOperand)];
1762
- if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1763
- if (options.type) args.push("-type", options.type);
1845
+ const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
1846
+ const args = [shellQuote(defuseFindOperand(options.path))];
1847
+ if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1848
+ if (options.type) args.push("-type", shellQuote(options.type));
1764
1849
  if (options.name) args.push("-name", shellQuote(options.name));
1765
- if (options.minsize) args.push("-size", `+${options.minsize}`);
1766
- if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1767
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1850
+ if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1851
+ if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1852
+ if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
1768
1853
  const command = `find ${args.join(" ")}`;
1769
1854
  const result = await exec(client, command, timeoutMs);
1770
1855
  if (!result.stdout.trim() && result.stderr.trim()) {
@@ -1773,6 +1858,9 @@ async function find(client, options, timeoutMs = 3e4) {
1773
1858
  return result.stdout.split("\n").filter(Boolean);
1774
1859
  }
1775
1860
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1861
+ if (!isNonNegativeInteger(lines) || lines === 0) {
1862
+ throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
1863
+ }
1776
1864
  let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1777
1865
  if (grep) {
1778
1866
  command += ` | grep -i -e ${shellQuote(grep)}`;
@@ -1784,7 +1872,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1784
1872
  return result.stdout;
1785
1873
  }
1786
1874
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1787
- 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);
1788
1876
  const raw = result.stdout;
1789
1877
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1790
1878
  const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
@@ -1874,6 +1962,9 @@ var connectionParams = {
1874
1962
  var DEFAULT_TIMEOUT_MS = 3e4;
1875
1963
  var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1876
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 };
1877
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.";
1878
1969
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1879
1970
  function applyEnvPrefix(command, env) {
@@ -1894,7 +1985,7 @@ function registerTools(server, pool) {
1894
1985
  const connectionPool = pool ?? new ConnectionPool();
1895
1986
  server.tool(
1896
1987
  "ssh_exec",
1897
- "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).",
1898
1989
  {
1899
1990
  ...connectionParams,
1900
1991
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
@@ -1909,13 +2000,16 @@ function registerTools(server, pool) {
1909
2000
  conn,
1910
2001
  async (client) => {
1911
2002
  const result = await exec(client, finalCommand, timeoutMs);
1912
- const parts = [];
1913
- if (result.stdout) parts.push(result.stdout);
1914
- if (result.stderr) parts.push(`[stderr]
1915
- ${result.stderr}`);
1916
- if (result.signal) parts.push(`[signal: ${result.signal}]`);
1917
- parts.push(`[exit code: ${result.code}]`);
1918
- 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 };
1919
2013
  },
1920
2014
  poolWait(timeoutMs)
1921
2015
  );
@@ -1923,19 +2017,30 @@ ${result.stderr}`);
1923
2017
  );
1924
2018
  server.tool(
1925
2019
  "ssh_read_file",
1926
- `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}`,
1927
2021
  {
1928
2022
  ...connectionParams,
1929
- path: z.string().refine((p) => p.startsWith("/"), {
1930
- message: "Path must be absolute (start with /). Relative paths resolve against the remote user's CWD, which is rarely what you want."
1931
- }).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
+ )
1932
2028
  },
1933
- async ({ path, ...conn }) => {
2029
+ READ_ONLY_REMOTE,
2030
+ async ({ path, offset, length, ...conn }) => {
1934
2031
  return connectionPool.withConnection(
1935
2032
  conn,
1936
2033
  async (client) => {
1937
- const content = await readFile(client, path);
1938
- 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 };
1939
2044
  },
1940
2045
  poolWait()
1941
2046
  );
@@ -1949,6 +2054,7 @@ ${result.stderr}`);
1949
2054
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1950
2055
  content: z.string().describe("File content to write")
1951
2056
  },
2057
+ DESTRUCTIVE_REMOTE,
1952
2058
  async ({ path, content, ...conn }) => {
1953
2059
  return connectionPool.withConnection(
1954
2060
  conn,
@@ -1963,7 +2069,7 @@ ${result.stderr}`);
1963
2069
  );
1964
2070
  server.tool(
1965
2071
  "ssh_upload",
1966
- `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}`,
1967
2073
  {
1968
2074
  ...connectionParams,
1969
2075
  localPath: z.string().describe("Path to the local file to upload"),
@@ -2006,6 +2112,7 @@ ${result.stderr}`);
2006
2112
  ...connectionParams,
2007
2113
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
2008
2114
  },
2115
+ READ_ONLY_REMOTE,
2009
2116
  async ({ path, ...conn }) => {
2010
2117
  return connectionPool.withConnection(
2011
2118
  conn,
@@ -2027,6 +2134,7 @@ ${result.stderr}`);
2027
2134
  ...connectionParams,
2028
2135
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
2029
2136
  },
2137
+ READ_ONLY_REMOTE,
2030
2138
  async ({ path, ...conn }) => {
2031
2139
  return connectionPool.withConnection(
2032
2140
  conn,
@@ -2077,6 +2185,7 @@ ${result.stderr}`);
2077
2185
  "Absolute path of the file or empty directory to delete. Must start with /."
2078
2186
  )
2079
2187
  },
2188
+ DESTRUCTIVE_REMOTE,
2080
2189
  async ({ path, ...conn }) => {
2081
2190
  return connectionPool.withConnection(
2082
2191
  conn,
@@ -2095,6 +2204,7 @@ ${result.stderr}`);
2095
2204
  host: HostSchema,
2096
2205
  port: PortSchema
2097
2206
  },
2207
+ READ_ONLY_REMOTE,
2098
2208
  async ({ host, port }) => {
2099
2209
  const report = diagnose(host, port || 22);
2100
2210
  const lines = [];
@@ -2141,6 +2251,7 @@ ${result.stderr}`);
2141
2251
  "ssh_key_list",
2142
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.",
2143
2253
  {},
2254
+ READ_ONLY_LOCAL,
2144
2255
  async () => {
2145
2256
  const listing = listSshKeysDetailed();
2146
2257
  if (listing.status === "unreadable") {
@@ -2205,6 +2316,7 @@ ${result.stderr}`);
2205
2316
  {
2206
2317
  host: HostSchema
2207
2318
  },
2319
+ READ_ONLY_LOCAL,
2208
2320
  async ({ host }) => {
2209
2321
  const result = configLookup(host);
2210
2322
  if ("error" in result) {
@@ -2277,7 +2389,9 @@ ${result.stderr}`);
2277
2389
  "ssh_multi_exec",
2278
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).",
2279
2391
  {
2280
- 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"),
2281
2395
  command: z.string().describe("Shell command to execute on all hosts"),
2282
2396
  port: PortSchema,
2283
2397
  username: UsernameSchema,
@@ -2320,12 +2434,13 @@ ${result.stderr}`);
2320
2434
  path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
2321
2435
  name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
2322
2436
  type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
2323
- maxdepth: z.number().optional().describe("Maximum directory depth to search"),
2437
+ maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
2324
2438
  minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
2325
2439
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
2326
2440
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
2327
2441
  timeout: TimeoutSchema
2328
2442
  },
2443
+ READ_ONLY_REMOTE,
2329
2444
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2330
2445
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2331
2446
  return connectionPool.withConnection(
@@ -2352,6 +2467,7 @@ ${files.join("\n")}` }] };
2352
2467
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
2353
2468
  timeout: TimeoutSchema
2354
2469
  },
2470
+ READ_ONLY_REMOTE,
2355
2471
  async ({ path, lines, grep, timeout, ...conn }) => {
2356
2472
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2357
2473
  return connectionPool.withConnection(
@@ -2382,6 +2498,7 @@ ${files.join("\n")}` }] };
2382
2498
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
2383
2499
  timeout: TimeoutSchema
2384
2500
  },
2501
+ READ_ONLY_REMOTE,
2385
2502
  async ({ service, timeout, ...conn }) => {
2386
2503
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2387
2504
  return connectionPool.withConnection(
@@ -2406,11 +2523,25 @@ ${files.join("\n")}` }] };
2406
2523
 
2407
2524
  // src/server.ts
2408
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");
2409
2537
  function createServer(pool) {
2410
- const server = new McpServer({
2411
- name: "ssh-mcp",
2412
- version
2413
- });
2538
+ const server = new McpServer(
2539
+ {
2540
+ name: "ssh-mcp",
2541
+ version
2542
+ },
2543
+ { instructions: SERVER_INSTRUCTIONS }
2544
+ );
2414
2545
  registerTools(server, pool);
2415
2546
  return server;
2416
2547
  }
@@ -2418,6 +2549,7 @@ export {
2418
2549
  ConnectionPool,
2419
2550
  POOL_FULL_ERROR_CODE,
2420
2551
  PoolFullError,
2552
+ SERVER_INSTRUCTIONS,
2421
2553
  checkConnectivity,
2422
2554
  checkGitSsh,
2423
2555
  checkKnownHosts,
@@ -2442,14 +2574,17 @@ export {
2442
2574
  isPoolFullError,
2443
2575
  listDir,
2444
2576
  listSshKeys,
2577
+ listSshKeysDetailed,
2445
2578
  loadKey,
2446
2579
  makeDir,
2447
2580
  multiExec,
2448
2581
  readFile,
2582
+ readFileRange,
2449
2583
  readKnownHostsKeys,
2450
2584
  registerTools,
2451
2585
  resolveConfig,
2452
2586
  serviceStatus,
2587
+ shellQuote,
2453
2588
  statFile,
2454
2589
  tail,
2455
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.2",
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",