@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/index.js CHANGED
@@ -11,7 +11,16 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
 
12
12
  // src/env.ts
13
13
  import { execFileSync as execFileSync2 } from "child_process";
14
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
14
+ import {
15
+ appendFileSync,
16
+ closeSync,
17
+ existsSync as existsSync2,
18
+ constants as fsConstants,
19
+ fstatSync,
20
+ openSync,
21
+ readdirSync as readdirSync2,
22
+ readFileSync as readFileSync3
23
+ } from "fs";
15
24
  import { homedir as homedir3 } from "os";
16
25
  import { join as join3 } from "path";
17
26
 
@@ -432,8 +441,15 @@ function cachedKnownHostTypes(hosts, port) {
432
441
  knownHostTypeCache.set(cacheKey, { at: now, types });
433
442
  return types;
434
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
+ }
435
451
  function buildHostVerifier(hosts, port, rejection) {
436
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
452
+ const strict = isStrictHostKeyMode();
437
453
  const label = hosts.join(" / ");
438
454
  const remediationHost = unbracketHost(hosts[0]);
439
455
  return (key) => {
@@ -443,7 +459,7 @@ function buildHostVerifier(hosts, port, rejection) {
443
459
  if (strict) {
444
460
  rejection.current = {
445
461
  reason: "unknown-host-strict",
446
- 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`
447
463
  };
448
464
  }
449
465
  return !strict;
@@ -835,7 +851,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
835
851
  });
836
852
  if (stats.size > maxBytes) {
837
853
  throw new Error(
838
- `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).`
839
855
  );
840
856
  }
841
857
  return await new Promise((resolve, reject) => {
@@ -848,6 +864,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
848
864
  sftp.end();
849
865
  }
850
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
+ }
851
914
  async function writeFile(client, remotePath, content) {
852
915
  const sftp = await getSftp(client);
853
916
  try {
@@ -1083,7 +1146,7 @@ function ensureAgent() {
1083
1146
  message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
1084
1147
  };
1085
1148
  }
1086
- function detectKeyType(filePath, fileName) {
1149
+ function detectKeyType(filePath, fileName, content) {
1087
1150
  const pubPath = `${filePath}.pub`;
1088
1151
  if (existsSync2(pubPath)) {
1089
1152
  try {
@@ -1100,7 +1163,6 @@ function detectKeyType(filePath, fileName) {
1100
1163
  if (fileName.includes("ecdsa")) return "ecdsa";
1101
1164
  if (fileName.includes("dsa")) return "dsa";
1102
1165
  try {
1103
- const content = readFileSync3(filePath, "utf8");
1104
1166
  if (content.includes("RSA PRIVATE KEY")) return "rsa";
1105
1167
  if (content.includes("EC PRIVATE KEY")) return "ecdsa";
1106
1168
  if (content.includes("DSA PRIVATE KEY")) return "dsa";
@@ -1136,12 +1198,13 @@ function listSshKeysDetailed() {
1136
1198
  for (const file of files) {
1137
1199
  if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
1138
1200
  const filePath = join3(sshDir, file);
1201
+ let fd;
1139
1202
  try {
1140
- const stat = statSync(filePath);
1141
- if (!stat.isFile()) continue;
1142
- const content = readFileSync3(filePath, "utf8");
1203
+ fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
1204
+ if (!fstatSync(fd).isFile()) continue;
1205
+ const content = readFileSync3(fd, "utf8");
1143
1206
  if (!content.includes("PRIVATE KEY")) continue;
1144
- const type = detectKeyType(filePath, file);
1207
+ const type = detectKeyType(filePath, file, content);
1145
1208
  let fingerprint;
1146
1209
  const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
1147
1210
  if (fpOk) {
@@ -1151,6 +1214,8 @@ function listSshKeysDetailed() {
1151
1214
  const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
1152
1215
  keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1153
1216
  } catch {
1217
+ } finally {
1218
+ if (fd !== void 0) closeSync(fd);
1154
1219
  }
1155
1220
  }
1156
1221
  return { status: "ok", dir: sshDir, keys };
@@ -1306,7 +1371,7 @@ function testConnection(host, port = 22) {
1306
1371
  }
1307
1372
 
1308
1373
  // src/pool.ts
1309
- import { createHash } from "crypto";
1374
+ import { createHmac, randomBytes, scryptSync } from "crypto";
1310
1375
  var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
1311
1376
  var PoolFullError = class extends Error {
1312
1377
  code = POOL_FULL_ERROR_CODE;
@@ -1336,21 +1401,24 @@ function defaultMaxPoolSize() {
1336
1401
  const parsed = Number.parseInt(raw, 10);
1337
1402
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1338
1403
  }
1404
+ var AUTH_FINGERPRINT_SECRET = randomBytes(32);
1405
+ var PASSWORD_SCRYPT_COST = 1024;
1406
+ function updateField(h, tag, value) {
1407
+ const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
1408
+ const len = Buffer.alloc(4);
1409
+ len.writeUInt32BE(bytes.length);
1410
+ h.update(tag);
1411
+ h.update(len);
1412
+ h.update(bytes);
1413
+ }
1339
1414
  function authFingerprint(cc) {
1340
- const h = createHash("sha256");
1341
- if (cc.privateKey) {
1342
- h.update("k");
1343
- h.update(cc.privateKey);
1344
- }
1415
+ const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
1416
+ if (cc.privateKey) updateField(h, "k", cc.privateKey);
1345
1417
  if (cc.password !== void 0) {
1346
- h.update("p");
1347
- h.update(cc.password);
1418
+ updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
1348
1419
  }
1349
- if (typeof cc.agent === "string") {
1350
- h.update("a");
1351
- h.update(cc.agent);
1352
- }
1353
- return h.digest("hex").slice(0, 16);
1420
+ if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
1421
+ return h.digest("hex").slice(0, 32);
1354
1422
  }
1355
1423
  function resolveOrDiagnose(config) {
1356
1424
  try {
@@ -1510,7 +1578,7 @@ var ConnectionPool = class {
1510
1578
  return client;
1511
1579
  }
1512
1580
  throw new Error(
1513
- `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1581
+ `Failed to acquire SSH connection for ${cc.username}@${cc.host}:${cc.port} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1514
1582
  );
1515
1583
  }
1516
1584
  release(client) {
@@ -1518,6 +1586,7 @@ var ConnectionPool = class {
1518
1586
  if (entry.client === client) {
1519
1587
  entry.refCount = Math.max(0, entry.refCount - 1);
1520
1588
  if (entry.refCount === 0 && !entry.dead) {
1589
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
1521
1590
  entry.idleTimer = setTimeout(() => {
1522
1591
  try {
1523
1592
  entry.client.end();
@@ -1743,7 +1812,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1743
1812
  }
1744
1813
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1745
1814
  var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1815
+ var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
1816
+ function isNonNegativeInteger(n) {
1817
+ return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
1818
+ }
1746
1819
  async function find(client, options, timeoutMs = 3e4) {
1820
+ if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
1821
+ throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
1822
+ }
1823
+ if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
1824
+ throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
1825
+ }
1826
+ if (options.minsize !== void 0 && typeof options.minsize !== "string") {
1827
+ throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
1828
+ }
1829
+ if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
1830
+ throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
1831
+ }
1747
1832
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1748
1833
  throw new Error(
1749
1834
  `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
@@ -1754,14 +1839,14 @@ async function find(client, options, timeoutMs = 3e4) {
1754
1839
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1755
1840
  );
1756
1841
  }
1757
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1758
- const args = [shellQuote(pathOperand)];
1759
- if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1760
- if (options.type) args.push("-type", options.type);
1842
+ const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
1843
+ const args = [shellQuote(defuseFindOperand(options.path))];
1844
+ if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1845
+ if (options.type) args.push("-type", shellQuote(options.type));
1761
1846
  if (options.name) args.push("-name", shellQuote(options.name));
1762
- if (options.minsize) args.push("-size", `+${options.minsize}`);
1763
- if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1764
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1847
+ if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1848
+ if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1849
+ if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
1765
1850
  const command = `find ${args.join(" ")}`;
1766
1851
  const result = await exec(client, command, timeoutMs);
1767
1852
  if (!result.stdout.trim() && result.stderr.trim()) {
@@ -1770,6 +1855,9 @@ async function find(client, options, timeoutMs = 3e4) {
1770
1855
  return result.stdout.split("\n").filter(Boolean);
1771
1856
  }
1772
1857
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1858
+ if (!isNonNegativeInteger(lines) || lines === 0) {
1859
+ throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
1860
+ }
1773
1861
  let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1774
1862
  if (grep) {
1775
1863
  command += ` | grep -i -e ${shellQuote(grep)}`;
@@ -1781,7 +1869,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1781
1869
  return result.stdout;
1782
1870
  }
1783
1871
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1784
- 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);
1785
1873
  const raw = result.stdout;
1786
1874
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1787
1875
  const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
@@ -1868,6 +1956,9 @@ var connectionParams = {
1868
1956
  var DEFAULT_TIMEOUT_MS = 3e4;
1869
1957
  var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1870
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 };
1871
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.";
1872
1963
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1873
1964
  function applyEnvPrefix(command, env) {
@@ -1888,7 +1979,7 @@ function registerTools(server, pool) {
1888
1979
  const connectionPool = pool ?? new ConnectionPool();
1889
1980
  server.tool(
1890
1981
  "ssh_exec",
1891
- "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).",
1892
1983
  {
1893
1984
  ...connectionParams,
1894
1985
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
@@ -1903,13 +1994,16 @@ function registerTools(server, pool) {
1903
1994
  conn,
1904
1995
  async (client) => {
1905
1996
  const result = await exec(client, finalCommand, timeoutMs);
1906
- const parts = [];
1907
- if (result.stdout) parts.push(result.stdout);
1908
- if (result.stderr) parts.push(`[stderr]
1909
- ${result.stderr}`);
1910
- if (result.signal) parts.push(`[signal: ${result.signal}]`);
1911
- parts.push(`[exit code: ${result.code}]`);
1912
- 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 };
1913
2007
  },
1914
2008
  poolWait(timeoutMs)
1915
2009
  );
@@ -1917,19 +2011,30 @@ ${result.stderr}`);
1917
2011
  );
1918
2012
  server.tool(
1919
2013
  "ssh_read_file",
1920
- `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}`,
1921
2015
  {
1922
2016
  ...connectionParams,
1923
- path: z.string().refine((p) => p.startsWith("/"), {
1924
- message: "Path must be absolute (start with /). Relative paths resolve against the remote user's CWD, which is rarely what you want."
1925
- }).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
+ )
1926
2022
  },
1927
- async ({ path, ...conn }) => {
2023
+ READ_ONLY_REMOTE,
2024
+ async ({ path, offset, length, ...conn }) => {
1928
2025
  return connectionPool.withConnection(
1929
2026
  conn,
1930
2027
  async (client) => {
1931
- const content = await readFile(client, path);
1932
- 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 };
1933
2038
  },
1934
2039
  poolWait()
1935
2040
  );
@@ -1943,6 +2048,7 @@ ${result.stderr}`);
1943
2048
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1944
2049
  content: z.string().describe("File content to write")
1945
2050
  },
2051
+ DESTRUCTIVE_REMOTE,
1946
2052
  async ({ path, content, ...conn }) => {
1947
2053
  return connectionPool.withConnection(
1948
2054
  conn,
@@ -1957,7 +2063,7 @@ ${result.stderr}`);
1957
2063
  );
1958
2064
  server.tool(
1959
2065
  "ssh_upload",
1960
- `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}`,
1961
2067
  {
1962
2068
  ...connectionParams,
1963
2069
  localPath: z.string().describe("Path to the local file to upload"),
@@ -2000,6 +2106,7 @@ ${result.stderr}`);
2000
2106
  ...connectionParams,
2001
2107
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
2002
2108
  },
2109
+ READ_ONLY_REMOTE,
2003
2110
  async ({ path, ...conn }) => {
2004
2111
  return connectionPool.withConnection(
2005
2112
  conn,
@@ -2021,6 +2128,7 @@ ${result.stderr}`);
2021
2128
  ...connectionParams,
2022
2129
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
2023
2130
  },
2131
+ READ_ONLY_REMOTE,
2024
2132
  async ({ path, ...conn }) => {
2025
2133
  return connectionPool.withConnection(
2026
2134
  conn,
@@ -2071,6 +2179,7 @@ ${result.stderr}`);
2071
2179
  "Absolute path of the file or empty directory to delete. Must start with /."
2072
2180
  )
2073
2181
  },
2182
+ DESTRUCTIVE_REMOTE,
2074
2183
  async ({ path, ...conn }) => {
2075
2184
  return connectionPool.withConnection(
2076
2185
  conn,
@@ -2089,6 +2198,7 @@ ${result.stderr}`);
2089
2198
  host: HostSchema,
2090
2199
  port: PortSchema
2091
2200
  },
2201
+ READ_ONLY_REMOTE,
2092
2202
  async ({ host, port }) => {
2093
2203
  const report = diagnose(host, port || 22);
2094
2204
  const lines = [];
@@ -2135,6 +2245,7 @@ ${result.stderr}`);
2135
2245
  "ssh_key_list",
2136
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.",
2137
2247
  {},
2248
+ READ_ONLY_LOCAL,
2138
2249
  async () => {
2139
2250
  const listing = listSshKeysDetailed();
2140
2251
  if (listing.status === "unreadable") {
@@ -2199,6 +2310,7 @@ ${result.stderr}`);
2199
2310
  {
2200
2311
  host: HostSchema
2201
2312
  },
2313
+ READ_ONLY_LOCAL,
2202
2314
  async ({ host }) => {
2203
2315
  const result = configLookup(host);
2204
2316
  if ("error" in result) {
@@ -2271,7 +2383,9 @@ ${result.stderr}`);
2271
2383
  "ssh_multi_exec",
2272
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).",
2273
2385
  {
2274
- 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"),
2275
2389
  command: z.string().describe("Shell command to execute on all hosts"),
2276
2390
  port: PortSchema,
2277
2391
  username: UsernameSchema,
@@ -2314,12 +2428,13 @@ ${result.stderr}`);
2314
2428
  path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
2315
2429
  name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
2316
2430
  type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
2317
- maxdepth: z.number().optional().describe("Maximum directory depth to search"),
2431
+ maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
2318
2432
  minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
2319
2433
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
2320
2434
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
2321
2435
  timeout: TimeoutSchema
2322
2436
  },
2437
+ READ_ONLY_REMOTE,
2323
2438
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2324
2439
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2325
2440
  return connectionPool.withConnection(
@@ -2346,6 +2461,7 @@ ${files.join("\n")}` }] };
2346
2461
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
2347
2462
  timeout: TimeoutSchema
2348
2463
  },
2464
+ READ_ONLY_REMOTE,
2349
2465
  async ({ path, lines, grep, timeout, ...conn }) => {
2350
2466
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2351
2467
  return connectionPool.withConnection(
@@ -2376,6 +2492,7 @@ ${files.join("\n")}` }] };
2376
2492
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
2377
2493
  timeout: TimeoutSchema
2378
2494
  },
2495
+ READ_ONLY_REMOTE,
2379
2496
  async ({ service, timeout, ...conn }) => {
2380
2497
  const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2381
2498
  return connectionPool.withConnection(
@@ -2400,11 +2517,25 @@ ${files.join("\n")}` }] };
2400
2517
 
2401
2518
  // src/server.ts
2402
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");
2403
2531
  function createServer(pool) {
2404
- const server = new McpServer({
2405
- name: "ssh-mcp",
2406
- version
2407
- });
2532
+ const server = new McpServer(
2533
+ {
2534
+ name: "ssh-mcp",
2535
+ version
2536
+ },
2537
+ { instructions: SERVER_INSTRUCTIONS }
2538
+ );
2408
2539
  registerTools(server, pool);
2409
2540
  return server;
2410
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";