@yawlabs/ssh-mcp 0.16.0 → 0.17.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
@@ -1307,6 +1307,29 @@ function testConnection(host, port = 22) {
1307
1307
 
1308
1308
  // src/pool.ts
1309
1309
  import { createHash } from "crypto";
1310
+ var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
1311
+ var PoolFullError = class extends Error {
1312
+ code = POOL_FULL_ERROR_CODE;
1313
+ maxPoolSize;
1314
+ waitedMs;
1315
+ constructor(maxPoolSize, waitedMs) {
1316
+ const base = `Connection pool is full (${maxPoolSize} connections in use or dialing, the SSH_MCP_MAX_POOL_SIZE cap)`;
1317
+ super(
1318
+ waitedMs === void 0 ? base : (
1319
+ // "became available to this call", not "freed up": every capacity signal wakes every
1320
+ // parked caller, so a slot can free during the wait and still go to someone else.
1321
+ `${base}; no slot became available to this call within ${Math.round(waitedMs)}ms. Retry once the calls holding the slots finish, or raise the cap (SSH_MCP_MAX_POOL_SIZE in the MCP server's environment).`
1322
+ )
1323
+ );
1324
+ this.name = "PoolFullError";
1325
+ this.maxPoolSize = maxPoolSize;
1326
+ if (waitedMs !== void 0) this.waitedMs = waitedMs;
1327
+ }
1328
+ };
1329
+ function isPoolFullError(err) {
1330
+ return typeof err === "object" && err !== null && err.code === POOL_FULL_ERROR_CODE;
1331
+ }
1332
+ var MAX_TIMER_MS = 2147483647;
1310
1333
  function defaultMaxPoolSize() {
1311
1334
  const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1312
1335
  if (!raw) return 100;
@@ -1359,11 +1382,42 @@ var ConnectionPool = class {
1359
1382
  // factory closes the freshly-connected client instead of registering it.
1360
1383
  // Consumers must construct a new pool to use again.
1361
1384
  drained = false;
1385
+ // Callers parked in waitForCapacity(). Every site that can free a slot for a NEW key calls
1386
+ // notifyCapacity(), which wakes all of them: an entry dropping to refCount 0 (it becomes
1387
+ // evictable), an entry leaving the map (markDead, idle expiry), a dial failing without
1388
+ // registering, and drain(). Waking all rather than one means a woken caller that does not
1389
+ // take the slot (its retry fails for another reason, or joins a same-key dial) cannot
1390
+ // strand the rest; the losers of a wake just find the pool full again and re-park.
1391
+ capacityWaiters = /* @__PURE__ */ new Set();
1362
1392
  constructor(options) {
1363
1393
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
1364
1394
  this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
1365
1395
  }
1366
- async acquire(config) {
1396
+ /**
1397
+ * Checks out a connection for `config`, dialing one if needed. Fails fast on a full pool
1398
+ * unless `options.waitForCapacityMs` is set, in which case the caller parks and retries on
1399
+ * every capacity signal until it wins a slot or the budget is spent. The wait is bounded,
1400
+ * never a reservation: a woken caller can lose the slot to another and simply parks again
1401
+ * for whatever budget is left. The final rejection carries the budget in its message.
1402
+ */
1403
+ async acquire(config, options) {
1404
+ const budgetMs = options?.waitForCapacityMs ?? 0;
1405
+ if (!(budgetMs > 0)) return this.acquireNow(config);
1406
+ const deadline = performance.now() + budgetMs;
1407
+ for (; ; ) {
1408
+ try {
1409
+ return await this.acquireNow(config);
1410
+ } catch (err) {
1411
+ if (!isPoolFullError(err)) throw err;
1412
+ const remaining = deadline - performance.now();
1413
+ if (remaining <= 0) throw new PoolFullError(this.maxPoolSize, budgetMs);
1414
+ await this.waitForCapacity(remaining);
1415
+ }
1416
+ }
1417
+ }
1418
+ // The fail-fast acquire: one admission check, one dial (or a join on an in-flight one).
1419
+ async acquireNow(config) {
1420
+ if (this.drained) throw new Error("ConnectionPool was drained");
1367
1421
  const resolved = resolveOrDiagnose(config);
1368
1422
  const cc = resolved.connectConfig;
1369
1423
  const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
@@ -1387,25 +1441,20 @@ var ConnectionPool = class {
1387
1441
  }
1388
1442
  let inflight = this.pending.get(key);
1389
1443
  if (!inflight) {
1390
- if (this.entries.size >= this.maxPoolSize) {
1391
- let evicted = false;
1392
- for (const [k, e] of this.entries) {
1393
- if (e.refCount === 0) {
1394
- if (e.idleTimer) clearTimeout(e.idleTimer);
1395
- try {
1396
- e.client.end();
1397
- } catch {
1398
- }
1399
- this.entries.delete(k);
1400
- evicted = true;
1401
- break;
1402
- }
1444
+ if (this.entries.size + this.pending.size >= this.maxPoolSize) {
1445
+ const victim = this.findEvictable();
1446
+ if (!victim) {
1447
+ throw new PoolFullError(this.maxPoolSize);
1403
1448
  }
1404
- if (!evicted) {
1405
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1449
+ if (victim.idleTimer) clearTimeout(victim.idleTimer);
1450
+ try {
1451
+ victim.client.end();
1452
+ } catch {
1406
1453
  }
1454
+ this.entries.delete(victim.key);
1407
1455
  }
1408
1456
  const promise = (async () => {
1457
+ let registered = false;
1409
1458
  try {
1410
1459
  const client2 = await connectWithProxy(resolved);
1411
1460
  if (this.drained) {
@@ -1425,15 +1474,18 @@ var ConnectionPool = class {
1425
1474
  }
1426
1475
  if (this.entries.get(key) === entry2) {
1427
1476
  this.entries.delete(key);
1477
+ this.notifyCapacity();
1428
1478
  }
1429
1479
  };
1430
1480
  client2.on("close", markDead);
1431
1481
  client2.on("end", markDead);
1432
1482
  client2.on("error", markDead);
1433
1483
  this.entries.set(key, entry2);
1484
+ registered = true;
1434
1485
  return client2;
1435
1486
  } finally {
1436
1487
  this.pending.delete(key);
1488
+ if (!registered) this.notifyCapacity();
1437
1489
  }
1438
1490
  })();
1439
1491
  inflight = { promise, resolved };
@@ -1472,8 +1524,10 @@ var ConnectionPool = class {
1472
1524
  } catch {
1473
1525
  }
1474
1526
  this.entries.delete(entry.key);
1527
+ this.notifyCapacity();
1475
1528
  }, this.idleTtlMs);
1476
1529
  entry.idleTimer.unref();
1530
+ this.notifyCapacity();
1477
1531
  }
1478
1532
  return;
1479
1533
  }
@@ -1483,8 +1537,9 @@ var ConnectionPool = class {
1483
1537
  } catch {
1484
1538
  }
1485
1539
  }
1486
- async withConnection(config, fn) {
1487
- const client = await this.acquire(config);
1540
+ /** `acquire()` + `fn` + `release()`, with the same optional bounded wait for a slot. */
1541
+ async withConnection(config, fn, options) {
1542
+ const client = await this.acquire(config, options);
1488
1543
  try {
1489
1544
  return await fn(client);
1490
1545
  } finally {
@@ -1504,10 +1559,81 @@ var ConnectionPool = class {
1504
1559
  }
1505
1560
  this.entries.clear();
1506
1561
  this.pending.clear();
1562
+ this.notifyCapacity();
1563
+ }
1564
+ /**
1565
+ * Parks until the pool's capacity may have changed, for a caller whose `acquire()` just
1566
+ * rejected with a {@link PoolFullError} and that would rather wait than fail. This is the
1567
+ * primitive behind `acquire()`'s `waitForCapacityMs` option; a bare `acquire()` never waits.
1568
+ *
1569
+ * Resolves `true` when a slot may be free (woken by a release, an entry closing or expiring,
1570
+ * a failed dial, or `drain()`), or at once if a new host would fit right now or the pool is
1571
+ * already drained. Resolves `false` after `timeoutMs` with nothing having changed. Never
1572
+ * rejects. A `true` is a hint, not a reservation: another caller can take the slot first,
1573
+ * so retry `acquire()` and wait again on another `PoolFullError`. The timer is cleared on
1574
+ * wake and unref'd, so a parked caller never holds the process open.
1575
+ *
1576
+ * Aborting `signal` ends the park early with `false`, as if the timer had run out; an
1577
+ * already-aborted signal resolves `false` without parking, unless the pool is drained or a
1578
+ * new host would fit right now. A wake that lands first still resolves `true`. A caller that
1579
+ * gives up on its own (`ssh_multi_exec` declaring the call starved) aborts rather than racing
1580
+ * this promise against another: the race would add a promise hop to every wake.
1581
+ */
1582
+ waitForCapacity(timeoutMs, signal) {
1583
+ return new Promise((resolve) => {
1584
+ let timer;
1585
+ const settle = (woken) => {
1586
+ if (timer !== void 0) clearTimeout(timer);
1587
+ this.capacityWaiters.delete(wake);
1588
+ signal?.removeEventListener("abort", abort);
1589
+ resolve(woken);
1590
+ };
1591
+ const wake = () => settle(true);
1592
+ const abort = () => settle(false);
1593
+ this.capacityWaiters.add(wake);
1594
+ if (this.drained) {
1595
+ wake();
1596
+ return;
1597
+ }
1598
+ if (this.hasCapacity()) {
1599
+ wake();
1600
+ return;
1601
+ }
1602
+ if (!(timeoutMs > 0) || signal?.aborted) {
1603
+ abort();
1604
+ return;
1605
+ }
1606
+ timer = setTimeout(abort, Math.min(timeoutMs, MAX_TIMER_MS));
1607
+ timer.unref();
1608
+ signal?.addEventListener("abort", abort, { once: true });
1609
+ });
1610
+ }
1611
+ // acquireNow()'s admission test for a new key: below the cap, or an entry to evict.
1612
+ hasCapacity() {
1613
+ return this.entries.size + this.pending.size < this.maxPoolSize || this.findEvictable() !== void 0;
1614
+ }
1615
+ // The eviction rule, in one place: the first entry (insertion order) no caller holds. Both
1616
+ // acquireNow()'s eviction and hasCapacity() read it, so waitForCapacity's re-check can never
1617
+ // disagree with what an acquire would actually evict.
1618
+ findEvictable() {
1619
+ for (const e of this.entries.values()) {
1620
+ if (e.refCount === 0) return e;
1621
+ }
1622
+ return void 0;
1623
+ }
1624
+ notifyCapacity() {
1625
+ if (this.capacityWaiters.size === 0) return;
1626
+ const waiters = [...this.capacityWaiters];
1627
+ this.capacityWaiters.clear();
1628
+ for (const wake of waiters) wake();
1507
1629
  }
1508
1630
  get size() {
1509
1631
  return this.entries.size;
1510
1632
  }
1633
+ /** The connection cap (`maxPoolSize`), counting pooled entries plus in-flight dials. */
1634
+ get maxSize() {
1635
+ return this.maxPoolSize;
1636
+ }
1511
1637
  get stats() {
1512
1638
  let active = 0;
1513
1639
  let idle = 0;
@@ -1537,26 +1663,83 @@ function shellQuote(s) {
1537
1663
  return `'${s.replace(/'/g, "'\\''")}'`;
1538
1664
  }
1539
1665
  async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1540
- const results = await Promise.allSettled(
1541
- hosts.map(async (hostConfig) => {
1542
- return pool.withConnection(hostConfig, async (client) => {
1543
- const result = await exec(client, command, timeoutMs);
1544
- return { host: hostConfig.host, ...result };
1545
- });
1546
- })
1547
- );
1548
- return results.map((result, i) => {
1549
- if (result.status === "fulfilled") {
1550
- return result.value;
1551
- }
1552
- return {
1553
- host: hosts[i].host,
1554
- stdout: "",
1555
- stderr: "",
1556
- code: -1,
1557
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
1558
- };
1666
+ const cap = pool.maxSize;
1667
+ const limit = typeof cap === "number" && cap >= 1 ? Math.min(hosts.length, Math.floor(cap)) : hosts.length;
1668
+ let lastProgressAt = performance.now();
1669
+ let starvedError;
1670
+ const starvation = new AbortController();
1671
+ let running = 0;
1672
+ let parked = 0;
1673
+ const canWait = typeof pool.waitForCapacity === "function";
1674
+ const freshBudgetMs = timeoutMs >= 1 ? timeoutMs : 1;
1675
+ const errorResult = (host, error) => ({
1676
+ host,
1677
+ stdout: "",
1678
+ stderr: "",
1679
+ code: -1,
1680
+ error
1559
1681
  });
1682
+ const runHost = async (hostConfig) => {
1683
+ for (; ; ) {
1684
+ if (starvedError !== void 0) return errorResult(hostConfig.host, starvedError);
1685
+ let rejection;
1686
+ try {
1687
+ return await pool.withConnection(hostConfig, async (client) => {
1688
+ lastProgressAt = performance.now();
1689
+ try {
1690
+ const result = await exec(client, command, timeoutMs);
1691
+ return { host: hostConfig.host, ...result };
1692
+ } finally {
1693
+ lastProgressAt = performance.now();
1694
+ }
1695
+ });
1696
+ } catch (reason) {
1697
+ if (!isPoolFullError(reason)) {
1698
+ lastProgressAt = performance.now();
1699
+ return errorResult(hostConfig.host, reason instanceof Error ? reason.message : String(reason));
1700
+ }
1701
+ if (!canWait) return errorResult(hostConfig.host, reason.message);
1702
+ rejection = reason;
1703
+ }
1704
+ for (; ; ) {
1705
+ if (starvedError !== void 0) return errorResult(hostConfig.host, starvedError);
1706
+ const remaining = lastProgressAt + timeoutMs - performance.now();
1707
+ let waitMs;
1708
+ if (remaining > 0) {
1709
+ waitMs = Math.ceil(remaining);
1710
+ } else if (running - parked > 1) {
1711
+ waitMs = freshBudgetMs;
1712
+ } else {
1713
+ starvedError ??= new PoolFullError(rejection.maxPoolSize, timeoutMs).message;
1714
+ starvation.abort();
1715
+ return errorResult(hostConfig.host, starvedError);
1716
+ }
1717
+ parked++;
1718
+ let woken;
1719
+ try {
1720
+ woken = await pool.waitForCapacity(waitMs, starvation.signal);
1721
+ } finally {
1722
+ parked--;
1723
+ }
1724
+ if (woken) break;
1725
+ }
1726
+ }
1727
+ };
1728
+ const results = new Array(hosts.length);
1729
+ let next = 0;
1730
+ const worker = async () => {
1731
+ running++;
1732
+ try {
1733
+ while (next < hosts.length) {
1734
+ const i = next++;
1735
+ results[i] = await runHost(hosts[i]);
1736
+ }
1737
+ } finally {
1738
+ running--;
1739
+ }
1740
+ };
1741
+ await Promise.all(Array.from({ length: limit }, worker));
1742
+ return results;
1560
1743
  }
1561
1744
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1562
1745
  var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
@@ -1669,7 +1852,9 @@ var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1669
1852
  var PasswordSchema = z.string().optional().describe(
1670
1853
  "SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
1671
1854
  );
1672
- var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1855
+ var TimeoutSchema = z.number().int().positive().optional().describe(
1856
+ "Command timeout in milliseconds (default: 30000). Also the longest the call waits for a free connection-pool slot (SSH_MCP_MAX_POOL_SIZE, default 100) before the command starts; the wait and the command are bounded separately."
1857
+ );
1673
1858
  var EnvSchema = z.record(z.string(), z.string()).optional().describe(
1674
1859
  "Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. VALUES are POSIX-single-quoted, so any byte is safe in a value. KEYS cannot be quoted (a shell assignment prefix requires a bare name), so each key must match /^[A-Za-z_][A-Za-z0-9_]*$/ (the POSIX name grammar) \u2014 a key outside that grammar is rejected and the call fails before anything is sent to a host. Command policy is checked against the PREFIXED command, so a `^`-anchored whitelist pattern stops matching once this is set."
1675
1860
  );
@@ -1680,6 +1865,9 @@ var connectionParams = {
1680
1865
  privateKeyPath: KeyPathSchema,
1681
1866
  password: PasswordSchema
1682
1867
  };
1868
+ var DEFAULT_TIMEOUT_MS = 3e4;
1869
+ var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1870
+ 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.`;
1683
1871
  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.";
1684
1872
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1685
1873
  function applyEnvPrefix(command, env) {
@@ -1710,21 +1898,26 @@ function registerTools(server, pool) {
1710
1898
  async ({ command, env, timeout, ...conn }) => {
1711
1899
  const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
1712
1900
  enforcePolicy(finalCommand, { envPrefixApplied });
1713
- return connectionPool.withConnection(conn, async (client) => {
1714
- const result = await exec(client, finalCommand, timeout || 3e4);
1715
- const parts = [];
1716
- if (result.stdout) parts.push(result.stdout);
1717
- if (result.stderr) parts.push(`[stderr]
1901
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
1902
+ return connectionPool.withConnection(
1903
+ conn,
1904
+ async (client) => {
1905
+ 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]
1718
1909
  ${result.stderr}`);
1719
- if (result.signal) parts.push(`[signal: ${result.signal}]`);
1720
- parts.push(`[exit code: ${result.code}]`);
1721
- return { content: [{ type: "text", text: parts.join("\n") }] };
1722
- });
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") }] };
1913
+ },
1914
+ poolWait(timeoutMs)
1915
+ );
1723
1916
  }
1724
1917
  );
1725
1918
  server.tool(
1726
1919
  "ssh_read_file",
1727
- "Read a file from a remote host via SFTP.",
1920
+ `Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
1728
1921
  {
1729
1922
  ...connectionParams,
1730
1923
  path: z.string().refine((p) => p.startsWith("/"), {
@@ -1732,101 +1925,125 @@ ${result.stderr}`);
1732
1925
  }).describe("Absolute path to the remote file. Must start with /.")
1733
1926
  },
1734
1927
  async ({ path, ...conn }) => {
1735
- return connectionPool.withConnection(conn, async (client) => {
1736
- const content = await readFile(client, path);
1737
- return { content: [{ type: "text", text: content }] };
1738
- });
1928
+ return connectionPool.withConnection(
1929
+ conn,
1930
+ async (client) => {
1931
+ const content = await readFile(client, path);
1932
+ return { content: [{ type: "text", text: content }] };
1933
+ },
1934
+ poolWait()
1935
+ );
1739
1936
  }
1740
1937
  );
1741
1938
  server.tool(
1742
1939
  "ssh_write_file",
1743
- `Write content to a file on a remote host via SFTP. Creates or overwrites the file.${POLICY_EXEMPT_NOTE}`,
1940
+ `Write content to a file on a remote host via SFTP. Creates or overwrites the file.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1744
1941
  {
1745
1942
  ...connectionParams,
1746
1943
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1747
1944
  content: z.string().describe("File content to write")
1748
1945
  },
1749
1946
  async ({ path, content, ...conn }) => {
1750
- return connectionPool.withConnection(conn, async (client) => {
1751
- await writeFile(client, path, content);
1752
- const bytes = Buffer.byteLength(content, "utf8");
1753
- return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
1754
- });
1947
+ return connectionPool.withConnection(
1948
+ conn,
1949
+ async (client) => {
1950
+ await writeFile(client, path, content);
1951
+ const bytes = Buffer.byteLength(content, "utf8");
1952
+ return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
1953
+ },
1954
+ poolWait()
1955
+ );
1755
1956
  }
1756
1957
  );
1757
1958
  server.tool(
1758
1959
  "ssh_upload",
1759
- `Upload a local file to a remote host via SFTP.${POLICY_EXEMPT_NOTE}`,
1960
+ `Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1760
1961
  {
1761
1962
  ...connectionParams,
1762
1963
  localPath: z.string().describe("Path to the local file to upload"),
1763
1964
  remotePath: AbsoluteRemotePathSchema.describe("Absolute path on the remote host. Must start with /.")
1764
1965
  },
1765
1966
  async ({ localPath, remotePath, ...conn }) => {
1766
- return connectionPool.withConnection(conn, async (client) => {
1767
- await uploadFile(client, localPath, remotePath);
1768
- return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1769
- });
1967
+ return connectionPool.withConnection(
1968
+ conn,
1969
+ async (client) => {
1970
+ await uploadFile(client, localPath, remotePath);
1971
+ return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1972
+ },
1973
+ poolWait()
1974
+ );
1770
1975
  }
1771
1976
  );
1772
1977
  server.tool(
1773
1978
  "ssh_download",
1774
- "Download a file from a remote host to local filesystem via SFTP.",
1979
+ `Download a file from a remote host to local filesystem via SFTP.${SFTP_POOL_WAIT_NOTE}`,
1775
1980
  {
1776
1981
  ...connectionParams,
1777
1982
  remotePath: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1778
1983
  localPath: z.string().describe("Local path to save the downloaded file")
1779
1984
  },
1780
1985
  async ({ remotePath, localPath, ...conn }) => {
1781
- return connectionPool.withConnection(conn, async (client) => {
1782
- await downloadFile(client, remotePath, localPath);
1783
- return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1784
- });
1986
+ return connectionPool.withConnection(
1987
+ conn,
1988
+ async (client) => {
1989
+ await downloadFile(client, remotePath, localPath);
1990
+ return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1991
+ },
1992
+ poolWait()
1993
+ );
1785
1994
  }
1786
1995
  );
1787
1996
  server.tool(
1788
1997
  "ssh_ls",
1789
- "List files in a directory on a remote host via SFTP.",
1998
+ `List files in a directory on a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
1790
1999
  {
1791
2000
  ...connectionParams,
1792
2001
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
1793
2002
  },
1794
2003
  async ({ path, ...conn }) => {
1795
- return connectionPool.withConnection(conn, async (client) => {
1796
- const files = await listDir(client, path);
1797
- if (files.length === 0) {
1798
- return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
1799
- }
1800
- return { content: [{ type: "text", text: files.join("\n") }] };
1801
- });
2004
+ return connectionPool.withConnection(
2005
+ conn,
2006
+ async (client) => {
2007
+ const files = await listDir(client, path);
2008
+ if (files.length === 0) {
2009
+ return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
2010
+ }
2011
+ return { content: [{ type: "text", text: files.join("\n") }] };
2012
+ },
2013
+ poolWait()
2014
+ );
1802
2015
  }
1803
2016
  );
1804
2017
  server.tool(
1805
2018
  "ssh_stat",
1806
- "Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and the path type. Symlinks are reported as `symlink -> <target kind>`: the type describes the link itself while size/mode/mtime describe its TARGET, and a dangling symlink is reported rather than erroring. Use this instead of parsing `ls -la` output.",
2019
+ `Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and the path type. Symlinks are reported as \`symlink -> <target kind>\`: the type describes the link itself while size/mode/mtime describe its TARGET, and a dangling symlink is reported rather than erroring. Use this instead of parsing \`ls -la\` output.${SFTP_POOL_WAIT_NOTE}`,
1807
2020
  {
1808
2021
  ...connectionParams,
1809
2022
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
1810
2023
  },
1811
2024
  async ({ path, ...conn }) => {
1812
- return connectionPool.withConnection(conn, async (client) => {
1813
- const stats = await statFile(client, path);
1814
- const lines = [];
1815
- const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
1816
- const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
1817
- lines.push(`${path}: ${kind}`);
1818
- lines.push(` Size: ${stats.size} bytes`);
1819
- lines.push(` Mode: ${stats.modeOctal}`);
1820
- lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
1821
- lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
1822
- lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
1823
- return { content: [{ type: "text", text: lines.join("\n") }] };
1824
- });
2025
+ return connectionPool.withConnection(
2026
+ conn,
2027
+ async (client) => {
2028
+ const stats = await statFile(client, path);
2029
+ const lines = [];
2030
+ const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
2031
+ const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
2032
+ lines.push(`${path}: ${kind}`);
2033
+ lines.push(` Size: ${stats.size} bytes`);
2034
+ lines.push(` Mode: ${stats.modeOctal}`);
2035
+ lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
2036
+ lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
2037
+ lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
2038
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2039
+ },
2040
+ poolWait()
2041
+ );
1825
2042
  }
1826
2043
  );
1827
2044
  server.tool(
1828
2045
  "ssh_mkdir",
1829
- `Create a directory on a remote host via SFTP. Set \`recursive: true\` to create parent directories as needed (like \`mkdir -p\`). Existing intermediate dirs are tolerated; an existing leaf path is still an error. Unlike the other SFTP tools, the path may be relative.${POLICY_EXEMPT_NOTE}`,
2046
+ `Create a directory on a remote host via SFTP. Set \`recursive: true\` to create parent directories as needed (like \`mkdir -p\`). Existing intermediate dirs are tolerated; an existing leaf path is still an error. Unlike the other SFTP tools, the path may be relative.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1830
2047
  {
1831
2048
  ...connectionParams,
1832
2049
  path: z.string().describe(
@@ -1835,15 +2052,19 @@ ${result.stderr}`);
1835
2052
  recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
1836
2053
  },
1837
2054
  async ({ path, recursive, ...conn }) => {
1838
- return connectionPool.withConnection(conn, async (client) => {
1839
- await makeDir(client, path, recursive ?? false);
1840
- return { content: [{ type: "text", text: `Created directory ${path}` }] };
1841
- });
2055
+ return connectionPool.withConnection(
2056
+ conn,
2057
+ async (client) => {
2058
+ await makeDir(client, path, recursive ?? false);
2059
+ return { content: [{ type: "text", text: `Created directory ${path}` }] };
2060
+ },
2061
+ poolWait()
2062
+ );
1842
2063
  }
1843
2064
  );
1844
2065
  server.tool(
1845
2066
  "ssh_delete",
1846
- `Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with \`rm -rf\` explicitly so the destructive intent is visible in the tool trace.${POLICY_EXEMPT_NOTE}`,
2067
+ `Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with \`rm -rf\` explicitly so the destructive intent is visible in the tool trace.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1847
2068
  {
1848
2069
  ...connectionParams,
1849
2070
  path: AbsoluteRemotePathSchema.describe(
@@ -1851,10 +2072,14 @@ ${result.stderr}`);
1851
2072
  )
1852
2073
  },
1853
2074
  async ({ path, ...conn }) => {
1854
- return connectionPool.withConnection(conn, async (client) => {
1855
- await deleteFile(client, path);
1856
- return { content: [{ type: "text", text: `Deleted ${path}` }] };
1857
- });
2075
+ return connectionPool.withConnection(
2076
+ conn,
2077
+ async (client) => {
2078
+ await deleteFile(client, path);
2079
+ return { content: [{ type: "text", text: `Deleted ${path}` }] };
2080
+ },
2081
+ poolWait()
2082
+ );
1858
2083
  }
1859
2084
  );
1860
2085
  server.tool(
@@ -2044,7 +2269,7 @@ ${result.stderr}`);
2044
2269
  );
2045
2270
  server.tool(
2046
2271
  "ssh_multi_exec",
2047
- "Execute a command on multiple remote hosts in parallel. 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).",
2272
+ "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).",
2048
2273
  {
2049
2274
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
2050
2275
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -2053,13 +2278,17 @@ ${result.stderr}`);
2053
2278
  privateKeyPath: KeyPathSchema,
2054
2279
  password: PasswordSchema,
2055
2280
  env: EnvSchema,
2056
- timeout: TimeoutSchema
2281
+ // Not the shared text: here the pool wait is the call-level no-progress budget, not a
2282
+ // wait before one command starts.
2283
+ timeout: TimeoutSchema.describe(
2284
+ "Per-host command timeout in milliseconds (default: 30000): bounds each host's command, not the whole call. Also the call's no-progress budget on a full pool (see the tool description)."
2285
+ )
2057
2286
  },
2058
2287
  async ({ hosts, command, port, username, privateKeyPath, password, env, timeout }) => {
2059
2288
  const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
2060
2289
  enforcePolicy(finalCommand, { envPrefixApplied });
2061
2290
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
2062
- const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || 3e4);
2291
+ const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || DEFAULT_TIMEOUT_MS);
2063
2292
  const lines = [];
2064
2293
  for (const r of results) {
2065
2294
  lines.push(`--- ${r.host} ---`);
@@ -2092,14 +2321,19 @@ ${result.stderr}`);
2092
2321
  timeout: TimeoutSchema
2093
2322
  },
2094
2323
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2095
- return connectionPool.withConnection(conn, async (client) => {
2096
- const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
2097
- if (files.length === 0) {
2098
- return { content: [{ type: "text", text: "No files found." }] };
2099
- }
2100
- return { content: [{ type: "text", text: `Found ${files.length} result(s):
2324
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2325
+ return connectionPool.withConnection(
2326
+ conn,
2327
+ async (client) => {
2328
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeoutMs);
2329
+ if (files.length === 0) {
2330
+ return { content: [{ type: "text", text: "No files found." }] };
2331
+ }
2332
+ return { content: [{ type: "text", text: `Found ${files.length} result(s):
2101
2333
  ${files.join("\n")}` }] };
2102
- });
2334
+ },
2335
+ poolWait(timeoutMs)
2336
+ );
2103
2337
  }
2104
2338
  );
2105
2339
  server.tool(
@@ -2113,20 +2347,25 @@ ${files.join("\n")}` }] };
2113
2347
  timeout: TimeoutSchema
2114
2348
  },
2115
2349
  async ({ path, lines, grep, timeout, ...conn }) => {
2116
- return connectionPool.withConnection(conn, async (client) => {
2117
- const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
2118
- if (!output.trim()) {
2119
- return {
2120
- content: [
2121
- {
2122
- type: "text",
2123
- text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty (no content in the last lines read; whitespace-only counts as empty here)."
2124
- }
2125
- ]
2126
- };
2127
- }
2128
- return { content: [{ type: "text", text: output }] };
2129
- });
2350
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2351
+ return connectionPool.withConnection(
2352
+ conn,
2353
+ async (client) => {
2354
+ const output = await tail(client, path, lines || 100, grep, timeoutMs);
2355
+ if (!output.trim()) {
2356
+ return {
2357
+ content: [
2358
+ {
2359
+ type: "text",
2360
+ text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty (no content in the last lines read; whitespace-only counts as empty here)."
2361
+ }
2362
+ ]
2363
+ };
2364
+ }
2365
+ return { content: [{ type: "text", text: output }] };
2366
+ },
2367
+ poolWait(timeoutMs)
2368
+ );
2130
2369
  }
2131
2370
  );
2132
2371
  server.tool(
@@ -2138,18 +2377,23 @@ ${files.join("\n")}` }] };
2138
2377
  timeout: TimeoutSchema
2139
2378
  },
2140
2379
  async ({ service, timeout, ...conn }) => {
2141
- return connectionPool.withConnection(conn, async (client) => {
2142
- const status = await serviceStatus(client, service, timeout || 3e4);
2143
- const lines = [];
2144
- lines.push(`Service: ${status.name}`);
2145
- lines.push(`Status: ${status.status}`);
2146
- if (status.description) lines.push(`Description: ${status.description}`);
2147
- if (status.pid) lines.push(`PID: ${status.pid}`);
2148
- if (status.since) lines.push(`Since: ${status.since}`);
2149
- lines.push("");
2150
- lines.push(status.raw);
2151
- return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
2152
- });
2380
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2381
+ return connectionPool.withConnection(
2382
+ conn,
2383
+ async (client) => {
2384
+ const status = await serviceStatus(client, service, timeoutMs);
2385
+ const lines = [];
2386
+ lines.push(`Service: ${status.name}`);
2387
+ lines.push(`Status: ${status.status}`);
2388
+ if (status.description) lines.push(`Description: ${status.description}`);
2389
+ if (status.pid) lines.push(`PID: ${status.pid}`);
2390
+ if (status.since) lines.push(`Since: ${status.since}`);
2391
+ lines.push("");
2392
+ lines.push(status.raw);
2393
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
2394
+ },
2395
+ poolWait(timeoutMs)
2396
+ );
2153
2397
  }
2154
2398
  );
2155
2399
  }