@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/server.js CHANGED
@@ -1317,137 +1317,31 @@ function testConnection(host, port = 22) {
1317
1317
  }
1318
1318
  }
1319
1319
 
1320
- // src/ops.ts
1321
- function shellQuote(s) {
1322
- return `'${s.replace(/'/g, "'\\''")}'`;
1323
- }
1324
- async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1325
- const results = await Promise.allSettled(
1326
- hosts.map(async (hostConfig) => {
1327
- return pool.withConnection(hostConfig, async (client) => {
1328
- const result = await exec(client, command, timeoutMs);
1329
- return { host: hostConfig.host, ...result };
1330
- });
1331
- })
1332
- );
1333
- return results.map((result, i) => {
1334
- if (result.status === "fulfilled") {
1335
- return result.value;
1336
- }
1337
- return {
1338
- host: hosts[i].host,
1339
- stdout: "",
1340
- stderr: "",
1341
- code: -1,
1342
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
1343
- };
1344
- });
1345
- }
1346
- var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1347
- var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1348
- async function find(client, options, timeoutMs = 3e4) {
1349
- if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1350
- throw new Error(
1351
- `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
1352
- );
1353
- }
1354
- if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
1355
- throw new Error(
1356
- `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1357
- );
1358
- }
1359
- const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1360
- const args = [shellQuote(pathOperand)];
1361
- if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1362
- if (options.type) args.push("-type", options.type);
1363
- if (options.name) args.push("-name", shellQuote(options.name));
1364
- if (options.minsize) args.push("-size", `+${options.minsize}`);
1365
- if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1366
- if (options.newer) args.push("-newer", shellQuote(options.newer));
1367
- const command = `find ${args.join(" ")}`;
1368
- const result = await exec(client, command, timeoutMs);
1369
- if (!result.stdout.trim() && result.stderr.trim()) {
1370
- throw new Error(result.stderr.trim());
1371
- }
1372
- return result.stdout.split("\n").filter(Boolean);
1373
- }
1374
- async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1375
- let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1376
- if (grep) {
1377
- command += ` | grep -i -e ${shellQuote(grep)}`;
1378
- }
1379
- const result = await exec(client, command, timeoutMs);
1380
- if (result.stderr.trim()) {
1381
- throw new Error(result.stderr.trim());
1382
- }
1383
- return result.stdout;
1384
- }
1385
- async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1386
- const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1387
- const raw = result.stdout;
1388
- const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1389
- const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
1390
- const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1391
- const sinceMatch = raw.match(/since\s+(.+?);/);
1392
- const unknown = !activeMatch && result.code !== 0;
1393
- const fallbackStatus = result.code === 0 ? "active" : "inactive";
1394
- return {
1395
- name: serviceName,
1396
- active: activeMatch?.[1] === "active",
1397
- status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
1398
- description: descMatch?.[1]?.trim(),
1399
- since: sinceMatch?.[1]?.trim(),
1400
- pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
1401
- raw,
1402
- unknown
1403
- };
1404
- }
1405
-
1406
- // src/policy.ts
1407
- function parsePatterns(raw, envVarName) {
1408
- if (!raw) return [];
1409
- const patterns = [];
1410
- const malformed = [];
1411
- for (const p of raw.split(",")) {
1412
- const cleaned = p.replace(/^\s+/, "");
1413
- if (!cleaned) continue;
1414
- try {
1415
- patterns.push(new RegExp(cleaned));
1416
- } catch {
1417
- malformed.push(cleaned);
1418
- console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1419
- }
1420
- }
1421
- if (patterns.length === 0 && raw.trim() !== "") {
1422
- const detail = malformed.length > 0 ? `Malformed pattern(s): ${malformed.map((m) => `"${m}"`).join(", ")}.` : `The value ${JSON.stringify(raw)} contains only separators, so it declares no patterns.`;
1423
- throw new Error(
1424
- `Command blocked -- ssh-mcp command policy is MISCONFIGURED: ${envVarName} is set, but not one usable regex could be compiled from it, so ${envVarName} is NOT IN EFFECT. Every ssh_exec / ssh_multi_exec call is refused until it is fixed (failing closed: a policy that cannot be compiled must not be read as "no policy"). ${detail} Each comma-separated entry must be a valid JavaScript regex -- note that comma is the delimiter, so a pattern needing a literal comma must write it as \\x2c or [,]. Fix or unset the variable to restore service.`
1320
+ // src/pool.ts
1321
+ import { createHash } from "crypto";
1322
+ var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
1323
+ var PoolFullError = class extends Error {
1324
+ code = POOL_FULL_ERROR_CODE;
1325
+ maxPoolSize;
1326
+ waitedMs;
1327
+ constructor(maxPoolSize, waitedMs) {
1328
+ const base = `Connection pool is full (${maxPoolSize} connections in use or dialing, the SSH_MCP_MAX_POOL_SIZE cap)`;
1329
+ super(
1330
+ waitedMs === void 0 ? base : (
1331
+ // "became available to this call", not "freed up": every capacity signal wakes every
1332
+ // parked caller, so a slot can free during the wait and still go to someone else.
1333
+ `${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).`
1334
+ )
1425
1335
  );
1336
+ this.name = "PoolFullError";
1337
+ this.maxPoolSize = maxPoolSize;
1338
+ if (waitedMs !== void 0) this.waitedMs = waitedMs;
1426
1339
  }
1427
- return patterns;
1428
- }
1429
- function enforcePolicy(command, context = {}) {
1430
- const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST, "SSH_MCP_COMMAND_WHITELIST");
1431
- const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST, "SSH_MCP_COMMAND_BLACKLIST");
1432
- if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1433
- let message = `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`;
1434
- if (context.envPrefixApplied) {
1435
- message += ". NOTE: an `env` prefix was applied, and policy is checked against the PREFIXED command -- the string starts with the first `KEY='value'` assignment, not the command verb, so a `^`-anchored pattern that matches without `env` stops matching with it. Either set the variables inside the command string instead of passing `env`, or add a pattern that tolerates the prefix (e.g. `^([A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|\\\\')+ )*ls( |$)`). Keep the trailing `( |$)`: without a tail anchor the suggestion is a PREFIX match, so `^(...)*ls` would also admit `lsof -i` and `ls; <anything>`. The value group is `(?:'[^']*'|\\\\')+`, not `'[^']*'`: an env VALUE containing an apostrophe is emitted by shellQuote in the close-escape-reopen form (`O'Brien` -> `'O'\\''Brien'`), which a single `'[^']*'` cannot match -- a suggestion built on it would block exactly the calls it claims to allow.";
1436
- }
1437
- throw new Error(message);
1438
- }
1439
- for (const pattern of blacklist) {
1440
- if (pattern.test(command)) {
1441
- throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
1442
- }
1443
- }
1444
- }
1445
- function isPolicyConfigured() {
1446
- return Boolean(process.env.SSH_MCP_COMMAND_WHITELIST?.trim() || process.env.SSH_MCP_COMMAND_BLACKLIST?.trim());
1340
+ };
1341
+ function isPoolFullError(err) {
1342
+ return typeof err === "object" && err !== null && err.code === POOL_FULL_ERROR_CODE;
1447
1343
  }
1448
-
1449
- // src/pool.ts
1450
- import { createHash } from "crypto";
1344
+ var MAX_TIMER_MS = 2147483647;
1451
1345
  function defaultMaxPoolSize() {
1452
1346
  const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1453
1347
  if (!raw) return 100;
@@ -1500,11 +1394,42 @@ var ConnectionPool = class {
1500
1394
  // factory closes the freshly-connected client instead of registering it.
1501
1395
  // Consumers must construct a new pool to use again.
1502
1396
  drained = false;
1397
+ // Callers parked in waitForCapacity(). Every site that can free a slot for a NEW key calls
1398
+ // notifyCapacity(), which wakes all of them: an entry dropping to refCount 0 (it becomes
1399
+ // evictable), an entry leaving the map (markDead, idle expiry), a dial failing without
1400
+ // registering, and drain(). Waking all rather than one means a woken caller that does not
1401
+ // take the slot (its retry fails for another reason, or joins a same-key dial) cannot
1402
+ // strand the rest; the losers of a wake just find the pool full again and re-park.
1403
+ capacityWaiters = /* @__PURE__ */ new Set();
1503
1404
  constructor(options) {
1504
1405
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
1505
1406
  this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
1506
1407
  }
1507
- async acquire(config) {
1408
+ /**
1409
+ * Checks out a connection for `config`, dialing one if needed. Fails fast on a full pool
1410
+ * unless `options.waitForCapacityMs` is set, in which case the caller parks and retries on
1411
+ * every capacity signal until it wins a slot or the budget is spent. The wait is bounded,
1412
+ * never a reservation: a woken caller can lose the slot to another and simply parks again
1413
+ * for whatever budget is left. The final rejection carries the budget in its message.
1414
+ */
1415
+ async acquire(config, options) {
1416
+ const budgetMs = options?.waitForCapacityMs ?? 0;
1417
+ if (!(budgetMs > 0)) return this.acquireNow(config);
1418
+ const deadline = performance.now() + budgetMs;
1419
+ for (; ; ) {
1420
+ try {
1421
+ return await this.acquireNow(config);
1422
+ } catch (err) {
1423
+ if (!isPoolFullError(err)) throw err;
1424
+ const remaining = deadline - performance.now();
1425
+ if (remaining <= 0) throw new PoolFullError(this.maxPoolSize, budgetMs);
1426
+ await this.waitForCapacity(remaining);
1427
+ }
1428
+ }
1429
+ }
1430
+ // The fail-fast acquire: one admission check, one dial (or a join on an in-flight one).
1431
+ async acquireNow(config) {
1432
+ if (this.drained) throw new Error("ConnectionPool was drained");
1508
1433
  const resolved = resolveOrDiagnose(config);
1509
1434
  const cc = resolved.connectConfig;
1510
1435
  const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
@@ -1528,25 +1453,20 @@ var ConnectionPool = class {
1528
1453
  }
1529
1454
  let inflight = this.pending.get(key);
1530
1455
  if (!inflight) {
1531
- if (this.entries.size >= this.maxPoolSize) {
1532
- let evicted = false;
1533
- for (const [k, e] of this.entries) {
1534
- if (e.refCount === 0) {
1535
- if (e.idleTimer) clearTimeout(e.idleTimer);
1536
- try {
1537
- e.client.end();
1538
- } catch {
1539
- }
1540
- this.entries.delete(k);
1541
- evicted = true;
1542
- break;
1543
- }
1456
+ if (this.entries.size + this.pending.size >= this.maxPoolSize) {
1457
+ const victim = this.findEvictable();
1458
+ if (!victim) {
1459
+ throw new PoolFullError(this.maxPoolSize);
1544
1460
  }
1545
- if (!evicted) {
1546
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1461
+ if (victim.idleTimer) clearTimeout(victim.idleTimer);
1462
+ try {
1463
+ victim.client.end();
1464
+ } catch {
1547
1465
  }
1466
+ this.entries.delete(victim.key);
1548
1467
  }
1549
1468
  const promise = (async () => {
1469
+ let registered = false;
1550
1470
  try {
1551
1471
  const client2 = await connectWithProxy(resolved);
1552
1472
  if (this.drained) {
@@ -1566,15 +1486,18 @@ var ConnectionPool = class {
1566
1486
  }
1567
1487
  if (this.entries.get(key) === entry2) {
1568
1488
  this.entries.delete(key);
1489
+ this.notifyCapacity();
1569
1490
  }
1570
1491
  };
1571
1492
  client2.on("close", markDead);
1572
1493
  client2.on("end", markDead);
1573
1494
  client2.on("error", markDead);
1574
1495
  this.entries.set(key, entry2);
1496
+ registered = true;
1575
1497
  return client2;
1576
1498
  } finally {
1577
1499
  this.pending.delete(key);
1500
+ if (!registered) this.notifyCapacity();
1578
1501
  }
1579
1502
  })();
1580
1503
  inflight = { promise, resolved };
@@ -1613,8 +1536,10 @@ var ConnectionPool = class {
1613
1536
  } catch {
1614
1537
  }
1615
1538
  this.entries.delete(entry.key);
1539
+ this.notifyCapacity();
1616
1540
  }, this.idleTtlMs);
1617
1541
  entry.idleTimer.unref();
1542
+ this.notifyCapacity();
1618
1543
  }
1619
1544
  return;
1620
1545
  }
@@ -1624,8 +1549,9 @@ var ConnectionPool = class {
1624
1549
  } catch {
1625
1550
  }
1626
1551
  }
1627
- async withConnection(config, fn) {
1628
- const client = await this.acquire(config);
1552
+ /** `acquire()` + `fn` + `release()`, with the same optional bounded wait for a slot. */
1553
+ async withConnection(config, fn, options) {
1554
+ const client = await this.acquire(config, options);
1629
1555
  try {
1630
1556
  return await fn(client);
1631
1557
  } finally {
@@ -1645,10 +1571,81 @@ var ConnectionPool = class {
1645
1571
  }
1646
1572
  this.entries.clear();
1647
1573
  this.pending.clear();
1574
+ this.notifyCapacity();
1575
+ }
1576
+ /**
1577
+ * Parks until the pool's capacity may have changed, for a caller whose `acquire()` just
1578
+ * rejected with a {@link PoolFullError} and that would rather wait than fail. This is the
1579
+ * primitive behind `acquire()`'s `waitForCapacityMs` option; a bare `acquire()` never waits.
1580
+ *
1581
+ * Resolves `true` when a slot may be free (woken by a release, an entry closing or expiring,
1582
+ * a failed dial, or `drain()`), or at once if a new host would fit right now or the pool is
1583
+ * already drained. Resolves `false` after `timeoutMs` with nothing having changed. Never
1584
+ * rejects. A `true` is a hint, not a reservation: another caller can take the slot first,
1585
+ * so retry `acquire()` and wait again on another `PoolFullError`. The timer is cleared on
1586
+ * wake and unref'd, so a parked caller never holds the process open.
1587
+ *
1588
+ * Aborting `signal` ends the park early with `false`, as if the timer had run out; an
1589
+ * already-aborted signal resolves `false` without parking, unless the pool is drained or a
1590
+ * new host would fit right now. A wake that lands first still resolves `true`. A caller that
1591
+ * gives up on its own (`ssh_multi_exec` declaring the call starved) aborts rather than racing
1592
+ * this promise against another: the race would add a promise hop to every wake.
1593
+ */
1594
+ waitForCapacity(timeoutMs, signal) {
1595
+ return new Promise((resolve) => {
1596
+ let timer;
1597
+ const settle = (woken) => {
1598
+ if (timer !== void 0) clearTimeout(timer);
1599
+ this.capacityWaiters.delete(wake);
1600
+ signal?.removeEventListener("abort", abort);
1601
+ resolve(woken);
1602
+ };
1603
+ const wake = () => settle(true);
1604
+ const abort = () => settle(false);
1605
+ this.capacityWaiters.add(wake);
1606
+ if (this.drained) {
1607
+ wake();
1608
+ return;
1609
+ }
1610
+ if (this.hasCapacity()) {
1611
+ wake();
1612
+ return;
1613
+ }
1614
+ if (!(timeoutMs > 0) || signal?.aborted) {
1615
+ abort();
1616
+ return;
1617
+ }
1618
+ timer = setTimeout(abort, Math.min(timeoutMs, MAX_TIMER_MS));
1619
+ timer.unref();
1620
+ signal?.addEventListener("abort", abort, { once: true });
1621
+ });
1622
+ }
1623
+ // acquireNow()'s admission test for a new key: below the cap, or an entry to evict.
1624
+ hasCapacity() {
1625
+ return this.entries.size + this.pending.size < this.maxPoolSize || this.findEvictable() !== void 0;
1626
+ }
1627
+ // The eviction rule, in one place: the first entry (insertion order) no caller holds. Both
1628
+ // acquireNow()'s eviction and hasCapacity() read it, so waitForCapacity's re-check can never
1629
+ // disagree with what an acquire would actually evict.
1630
+ findEvictable() {
1631
+ for (const e of this.entries.values()) {
1632
+ if (e.refCount === 0) return e;
1633
+ }
1634
+ return void 0;
1635
+ }
1636
+ notifyCapacity() {
1637
+ if (this.capacityWaiters.size === 0) return;
1638
+ const waiters = [...this.capacityWaiters];
1639
+ this.capacityWaiters.clear();
1640
+ for (const wake of waiters) wake();
1648
1641
  }
1649
1642
  get size() {
1650
1643
  return this.entries.size;
1651
1644
  }
1645
+ /** The connection cap (`maxPoolSize`), counting pooled entries plus in-flight dials. */
1646
+ get maxSize() {
1647
+ return this.maxPoolSize;
1648
+ }
1652
1649
  get stats() {
1653
1650
  let active = 0;
1654
1651
  let idle = 0;
@@ -1664,6 +1661,192 @@ var ConnectionPool = class {
1664
1661
  }
1665
1662
  };
1666
1663
 
1664
+ // src/ops.ts
1665
+ function shellQuote(s) {
1666
+ return `'${s.replace(/'/g, "'\\''")}'`;
1667
+ }
1668
+ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1669
+ const cap = pool.maxSize;
1670
+ const limit = typeof cap === "number" && cap >= 1 ? Math.min(hosts.length, Math.floor(cap)) : hosts.length;
1671
+ let lastProgressAt = performance.now();
1672
+ let starvedError;
1673
+ const starvation = new AbortController();
1674
+ let running = 0;
1675
+ let parked = 0;
1676
+ const canWait = typeof pool.waitForCapacity === "function";
1677
+ const freshBudgetMs = timeoutMs >= 1 ? timeoutMs : 1;
1678
+ const errorResult = (host, error) => ({
1679
+ host,
1680
+ stdout: "",
1681
+ stderr: "",
1682
+ code: -1,
1683
+ error
1684
+ });
1685
+ const runHost = async (hostConfig) => {
1686
+ for (; ; ) {
1687
+ if (starvedError !== void 0) return errorResult(hostConfig.host, starvedError);
1688
+ let rejection;
1689
+ try {
1690
+ return await pool.withConnection(hostConfig, async (client) => {
1691
+ lastProgressAt = performance.now();
1692
+ try {
1693
+ const result = await exec(client, command, timeoutMs);
1694
+ return { host: hostConfig.host, ...result };
1695
+ } finally {
1696
+ lastProgressAt = performance.now();
1697
+ }
1698
+ });
1699
+ } catch (reason) {
1700
+ if (!isPoolFullError(reason)) {
1701
+ lastProgressAt = performance.now();
1702
+ return errorResult(hostConfig.host, reason instanceof Error ? reason.message : String(reason));
1703
+ }
1704
+ if (!canWait) return errorResult(hostConfig.host, reason.message);
1705
+ rejection = reason;
1706
+ }
1707
+ for (; ; ) {
1708
+ if (starvedError !== void 0) return errorResult(hostConfig.host, starvedError);
1709
+ const remaining = lastProgressAt + timeoutMs - performance.now();
1710
+ let waitMs;
1711
+ if (remaining > 0) {
1712
+ waitMs = Math.ceil(remaining);
1713
+ } else if (running - parked > 1) {
1714
+ waitMs = freshBudgetMs;
1715
+ } else {
1716
+ starvedError ??= new PoolFullError(rejection.maxPoolSize, timeoutMs).message;
1717
+ starvation.abort();
1718
+ return errorResult(hostConfig.host, starvedError);
1719
+ }
1720
+ parked++;
1721
+ let woken;
1722
+ try {
1723
+ woken = await pool.waitForCapacity(waitMs, starvation.signal);
1724
+ } finally {
1725
+ parked--;
1726
+ }
1727
+ if (woken) break;
1728
+ }
1729
+ }
1730
+ };
1731
+ const results = new Array(hosts.length);
1732
+ let next = 0;
1733
+ const worker = async () => {
1734
+ running++;
1735
+ try {
1736
+ while (next < hosts.length) {
1737
+ const i = next++;
1738
+ results[i] = await runHost(hosts[i]);
1739
+ }
1740
+ } finally {
1741
+ running--;
1742
+ }
1743
+ };
1744
+ await Promise.all(Array.from({ length: limit }, worker));
1745
+ return results;
1746
+ }
1747
+ var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1748
+ var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1749
+ async function find(client, options, timeoutMs = 3e4) {
1750
+ if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1751
+ throw new Error(
1752
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
1753
+ );
1754
+ }
1755
+ if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
1756
+ throw new Error(
1757
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1758
+ );
1759
+ }
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);
1764
+ 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));
1768
+ const command = `find ${args.join(" ")}`;
1769
+ const result = await exec(client, command, timeoutMs);
1770
+ if (!result.stdout.trim() && result.stderr.trim()) {
1771
+ throw new Error(result.stderr.trim());
1772
+ }
1773
+ return result.stdout.split("\n").filter(Boolean);
1774
+ }
1775
+ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1776
+ let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1777
+ if (grep) {
1778
+ command += ` | grep -i -e ${shellQuote(grep)}`;
1779
+ }
1780
+ const result = await exec(client, command, timeoutMs);
1781
+ if (result.stderr.trim()) {
1782
+ throw new Error(result.stderr.trim());
1783
+ }
1784
+ return result.stdout;
1785
+ }
1786
+ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1787
+ const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1788
+ const raw = result.stdout;
1789
+ const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1790
+ const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
1791
+ const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1792
+ const sinceMatch = raw.match(/since\s+(.+?);/);
1793
+ const unknown = !activeMatch && result.code !== 0;
1794
+ const fallbackStatus = result.code === 0 ? "active" : "inactive";
1795
+ return {
1796
+ name: serviceName,
1797
+ active: activeMatch?.[1] === "active",
1798
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
1799
+ description: descMatch?.[1]?.trim(),
1800
+ since: sinceMatch?.[1]?.trim(),
1801
+ pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
1802
+ raw,
1803
+ unknown
1804
+ };
1805
+ }
1806
+
1807
+ // src/policy.ts
1808
+ function parsePatterns(raw, envVarName) {
1809
+ if (!raw) return [];
1810
+ const patterns = [];
1811
+ const malformed = [];
1812
+ for (const p of raw.split(",")) {
1813
+ const cleaned = p.replace(/^\s+/, "");
1814
+ if (!cleaned) continue;
1815
+ try {
1816
+ patterns.push(new RegExp(cleaned));
1817
+ } catch {
1818
+ malformed.push(cleaned);
1819
+ console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1820
+ }
1821
+ }
1822
+ if (patterns.length === 0 && raw.trim() !== "") {
1823
+ const detail = malformed.length > 0 ? `Malformed pattern(s): ${malformed.map((m) => `"${m}"`).join(", ")}.` : `The value ${JSON.stringify(raw)} contains only separators, so it declares no patterns.`;
1824
+ throw new Error(
1825
+ `Command blocked -- ssh-mcp command policy is MISCONFIGURED: ${envVarName} is set, but not one usable regex could be compiled from it, so ${envVarName} is NOT IN EFFECT. Every ssh_exec / ssh_multi_exec call is refused until it is fixed (failing closed: a policy that cannot be compiled must not be read as "no policy"). ${detail} Each comma-separated entry must be a valid JavaScript regex -- note that comma is the delimiter, so a pattern needing a literal comma must write it as \\x2c or [,]. Fix or unset the variable to restore service.`
1826
+ );
1827
+ }
1828
+ return patterns;
1829
+ }
1830
+ function enforcePolicy(command, context = {}) {
1831
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST, "SSH_MCP_COMMAND_WHITELIST");
1832
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST, "SSH_MCP_COMMAND_BLACKLIST");
1833
+ if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1834
+ let message = `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`;
1835
+ if (context.envPrefixApplied) {
1836
+ message += ". NOTE: an `env` prefix was applied, and policy is checked against the PREFIXED command -- the string starts with the first `KEY='value'` assignment, not the command verb, so a `^`-anchored pattern that matches without `env` stops matching with it. Either set the variables inside the command string instead of passing `env`, or add a pattern that tolerates the prefix (e.g. `^([A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|\\\\')+ )*ls( |$)`). Keep the trailing `( |$)`: without a tail anchor the suggestion is a PREFIX match, so `^(...)*ls` would also admit `lsof -i` and `ls; <anything>`. The value group is `(?:'[^']*'|\\\\')+`, not `'[^']*'`: an env VALUE containing an apostrophe is emitted by shellQuote in the close-escape-reopen form (`O'Brien` -> `'O'\\''Brien'`), which a single `'[^']*'` cannot match -- a suggestion built on it would block exactly the calls it claims to allow.";
1837
+ }
1838
+ throw new Error(message);
1839
+ }
1840
+ for (const pattern of blacklist) {
1841
+ if (pattern.test(command)) {
1842
+ throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
1843
+ }
1844
+ }
1845
+ }
1846
+ function isPolicyConfigured() {
1847
+ return Boolean(process.env.SSH_MCP_COMMAND_WHITELIST?.trim() || process.env.SSH_MCP_COMMAND_BLACKLIST?.trim());
1848
+ }
1849
+
1667
1850
  // src/tools.ts
1668
1851
  var HostSchema = z.string().describe("SSH hostname or IP address");
1669
1852
  var AbsoluteRemotePathSchema = z.string().refine((p) => p.startsWith("/"), {
@@ -1675,7 +1858,9 @@ var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1675
1858
  var PasswordSchema = z.string().optional().describe(
1676
1859
  "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."
1677
1860
  );
1678
- var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1861
+ var TimeoutSchema = z.number().int().positive().optional().describe(
1862
+ "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."
1863
+ );
1679
1864
  var EnvSchema = z.record(z.string(), z.string()).optional().describe(
1680
1865
  "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."
1681
1866
  );
@@ -1686,6 +1871,9 @@ var connectionParams = {
1686
1871
  privateKeyPath: KeyPathSchema,
1687
1872
  password: PasswordSchema
1688
1873
  };
1874
+ var DEFAULT_TIMEOUT_MS = 3e4;
1875
+ var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
1876
+ 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.`;
1689
1877
  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.";
1690
1878
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1691
1879
  function applyEnvPrefix(command, env) {
@@ -1716,21 +1904,26 @@ function registerTools(server, pool) {
1716
1904
  async ({ command, env, timeout, ...conn }) => {
1717
1905
  const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
1718
1906
  enforcePolicy(finalCommand, { envPrefixApplied });
1719
- return connectionPool.withConnection(conn, async (client) => {
1720
- const result = await exec(client, finalCommand, timeout || 3e4);
1721
- const parts = [];
1722
- if (result.stdout) parts.push(result.stdout);
1723
- if (result.stderr) parts.push(`[stderr]
1907
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
1908
+ return connectionPool.withConnection(
1909
+ conn,
1910
+ async (client) => {
1911
+ 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]
1724
1915
  ${result.stderr}`);
1725
- if (result.signal) parts.push(`[signal: ${result.signal}]`);
1726
- parts.push(`[exit code: ${result.code}]`);
1727
- return { content: [{ type: "text", text: parts.join("\n") }] };
1728
- });
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") }] };
1919
+ },
1920
+ poolWait(timeoutMs)
1921
+ );
1729
1922
  }
1730
1923
  );
1731
1924
  server.tool(
1732
1925
  "ssh_read_file",
1733
- "Read a file from a remote host via SFTP.",
1926
+ `Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
1734
1927
  {
1735
1928
  ...connectionParams,
1736
1929
  path: z.string().refine((p) => p.startsWith("/"), {
@@ -1738,101 +1931,125 @@ ${result.stderr}`);
1738
1931
  }).describe("Absolute path to the remote file. Must start with /.")
1739
1932
  },
1740
1933
  async ({ path, ...conn }) => {
1741
- return connectionPool.withConnection(conn, async (client) => {
1742
- const content = await readFile(client, path);
1743
- return { content: [{ type: "text", text: content }] };
1744
- });
1934
+ return connectionPool.withConnection(
1935
+ conn,
1936
+ async (client) => {
1937
+ const content = await readFile(client, path);
1938
+ return { content: [{ type: "text", text: content }] };
1939
+ },
1940
+ poolWait()
1941
+ );
1745
1942
  }
1746
1943
  );
1747
1944
  server.tool(
1748
1945
  "ssh_write_file",
1749
- `Write content to a file on a remote host via SFTP. Creates or overwrites the file.${POLICY_EXEMPT_NOTE}`,
1946
+ `Write content to a file on a remote host via SFTP. Creates or overwrites the file.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1750
1947
  {
1751
1948
  ...connectionParams,
1752
1949
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1753
1950
  content: z.string().describe("File content to write")
1754
1951
  },
1755
1952
  async ({ path, content, ...conn }) => {
1756
- return connectionPool.withConnection(conn, async (client) => {
1757
- await writeFile(client, path, content);
1758
- const bytes = Buffer.byteLength(content, "utf8");
1759
- return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
1760
- });
1953
+ return connectionPool.withConnection(
1954
+ conn,
1955
+ async (client) => {
1956
+ await writeFile(client, path, content);
1957
+ const bytes = Buffer.byteLength(content, "utf8");
1958
+ return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
1959
+ },
1960
+ poolWait()
1961
+ );
1761
1962
  }
1762
1963
  );
1763
1964
  server.tool(
1764
1965
  "ssh_upload",
1765
- `Upload a local file to a remote host via SFTP.${POLICY_EXEMPT_NOTE}`,
1966
+ `Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
1766
1967
  {
1767
1968
  ...connectionParams,
1768
1969
  localPath: z.string().describe("Path to the local file to upload"),
1769
1970
  remotePath: AbsoluteRemotePathSchema.describe("Absolute path on the remote host. Must start with /.")
1770
1971
  },
1771
1972
  async ({ localPath, remotePath, ...conn }) => {
1772
- return connectionPool.withConnection(conn, async (client) => {
1773
- await uploadFile(client, localPath, remotePath);
1774
- return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1775
- });
1973
+ return connectionPool.withConnection(
1974
+ conn,
1975
+ async (client) => {
1976
+ await uploadFile(client, localPath, remotePath);
1977
+ return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1978
+ },
1979
+ poolWait()
1980
+ );
1776
1981
  }
1777
1982
  );
1778
1983
  server.tool(
1779
1984
  "ssh_download",
1780
- "Download a file from a remote host to local filesystem via SFTP.",
1985
+ `Download a file from a remote host to local filesystem via SFTP.${SFTP_POOL_WAIT_NOTE}`,
1781
1986
  {
1782
1987
  ...connectionParams,
1783
1988
  remotePath: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1784
1989
  localPath: z.string().describe("Local path to save the downloaded file")
1785
1990
  },
1786
1991
  async ({ remotePath, localPath, ...conn }) => {
1787
- return connectionPool.withConnection(conn, async (client) => {
1788
- await downloadFile(client, remotePath, localPath);
1789
- return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1790
- });
1992
+ return connectionPool.withConnection(
1993
+ conn,
1994
+ async (client) => {
1995
+ await downloadFile(client, remotePath, localPath);
1996
+ return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1997
+ },
1998
+ poolWait()
1999
+ );
1791
2000
  }
1792
2001
  );
1793
2002
  server.tool(
1794
2003
  "ssh_ls",
1795
- "List files in a directory on a remote host via SFTP.",
2004
+ `List files in a directory on a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
1796
2005
  {
1797
2006
  ...connectionParams,
1798
2007
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
1799
2008
  },
1800
2009
  async ({ path, ...conn }) => {
1801
- return connectionPool.withConnection(conn, async (client) => {
1802
- const files = await listDir(client, path);
1803
- if (files.length === 0) {
1804
- return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
1805
- }
1806
- return { content: [{ type: "text", text: files.join("\n") }] };
1807
- });
2010
+ return connectionPool.withConnection(
2011
+ conn,
2012
+ async (client) => {
2013
+ const files = await listDir(client, path);
2014
+ if (files.length === 0) {
2015
+ return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
2016
+ }
2017
+ return { content: [{ type: "text", text: files.join("\n") }] };
2018
+ },
2019
+ poolWait()
2020
+ );
1808
2021
  }
1809
2022
  );
1810
2023
  server.tool(
1811
2024
  "ssh_stat",
1812
- "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.",
2025
+ `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}`,
1813
2026
  {
1814
2027
  ...connectionParams,
1815
2028
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
1816
2029
  },
1817
2030
  async ({ path, ...conn }) => {
1818
- return connectionPool.withConnection(conn, async (client) => {
1819
- const stats = await statFile(client, path);
1820
- const lines = [];
1821
- const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
1822
- const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
1823
- lines.push(`${path}: ${kind}`);
1824
- lines.push(` Size: ${stats.size} bytes`);
1825
- lines.push(` Mode: ${stats.modeOctal}`);
1826
- lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
1827
- lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
1828
- lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
1829
- return { content: [{ type: "text", text: lines.join("\n") }] };
1830
- });
2031
+ return connectionPool.withConnection(
2032
+ conn,
2033
+ async (client) => {
2034
+ const stats = await statFile(client, path);
2035
+ const lines = [];
2036
+ const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
2037
+ const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
2038
+ lines.push(`${path}: ${kind}`);
2039
+ lines.push(` Size: ${stats.size} bytes`);
2040
+ lines.push(` Mode: ${stats.modeOctal}`);
2041
+ lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
2042
+ lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
2043
+ lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
2044
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2045
+ },
2046
+ poolWait()
2047
+ );
1831
2048
  }
1832
2049
  );
1833
2050
  server.tool(
1834
2051
  "ssh_mkdir",
1835
- `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}`,
2052
+ `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}`,
1836
2053
  {
1837
2054
  ...connectionParams,
1838
2055
  path: z.string().describe(
@@ -1841,15 +2058,19 @@ ${result.stderr}`);
1841
2058
  recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
1842
2059
  },
1843
2060
  async ({ path, recursive, ...conn }) => {
1844
- return connectionPool.withConnection(conn, async (client) => {
1845
- await makeDir(client, path, recursive ?? false);
1846
- return { content: [{ type: "text", text: `Created directory ${path}` }] };
1847
- });
2061
+ return connectionPool.withConnection(
2062
+ conn,
2063
+ async (client) => {
2064
+ await makeDir(client, path, recursive ?? false);
2065
+ return { content: [{ type: "text", text: `Created directory ${path}` }] };
2066
+ },
2067
+ poolWait()
2068
+ );
1848
2069
  }
1849
2070
  );
1850
2071
  server.tool(
1851
2072
  "ssh_delete",
1852
- `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}`,
2073
+ `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}`,
1853
2074
  {
1854
2075
  ...connectionParams,
1855
2076
  path: AbsoluteRemotePathSchema.describe(
@@ -1857,10 +2078,14 @@ ${result.stderr}`);
1857
2078
  )
1858
2079
  },
1859
2080
  async ({ path, ...conn }) => {
1860
- return connectionPool.withConnection(conn, async (client) => {
1861
- await deleteFile(client, path);
1862
- return { content: [{ type: "text", text: `Deleted ${path}` }] };
1863
- });
2081
+ return connectionPool.withConnection(
2082
+ conn,
2083
+ async (client) => {
2084
+ await deleteFile(client, path);
2085
+ return { content: [{ type: "text", text: `Deleted ${path}` }] };
2086
+ },
2087
+ poolWait()
2088
+ );
1864
2089
  }
1865
2090
  );
1866
2091
  server.tool(
@@ -2050,7 +2275,7 @@ ${result.stderr}`);
2050
2275
  );
2051
2276
  server.tool(
2052
2277
  "ssh_multi_exec",
2053
- "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).",
2278
+ "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).",
2054
2279
  {
2055
2280
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
2056
2281
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -2059,13 +2284,17 @@ ${result.stderr}`);
2059
2284
  privateKeyPath: KeyPathSchema,
2060
2285
  password: PasswordSchema,
2061
2286
  env: EnvSchema,
2062
- timeout: TimeoutSchema
2287
+ // Not the shared text: here the pool wait is the call-level no-progress budget, not a
2288
+ // wait before one command starts.
2289
+ timeout: TimeoutSchema.describe(
2290
+ "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)."
2291
+ )
2063
2292
  },
2064
2293
  async ({ hosts, command, port, username, privateKeyPath, password, env, timeout }) => {
2065
2294
  const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
2066
2295
  enforcePolicy(finalCommand, { envPrefixApplied });
2067
2296
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
2068
- const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || 3e4);
2297
+ const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || DEFAULT_TIMEOUT_MS);
2069
2298
  const lines = [];
2070
2299
  for (const r of results) {
2071
2300
  lines.push(`--- ${r.host} ---`);
@@ -2098,14 +2327,19 @@ ${result.stderr}`);
2098
2327
  timeout: TimeoutSchema
2099
2328
  },
2100
2329
  async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
2101
- return connectionPool.withConnection(conn, async (client) => {
2102
- const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
2103
- if (files.length === 0) {
2104
- return { content: [{ type: "text", text: "No files found." }] };
2105
- }
2106
- return { content: [{ type: "text", text: `Found ${files.length} result(s):
2330
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2331
+ return connectionPool.withConnection(
2332
+ conn,
2333
+ async (client) => {
2334
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeoutMs);
2335
+ if (files.length === 0) {
2336
+ return { content: [{ type: "text", text: "No files found." }] };
2337
+ }
2338
+ return { content: [{ type: "text", text: `Found ${files.length} result(s):
2107
2339
  ${files.join("\n")}` }] };
2108
- });
2340
+ },
2341
+ poolWait(timeoutMs)
2342
+ );
2109
2343
  }
2110
2344
  );
2111
2345
  server.tool(
@@ -2119,20 +2353,25 @@ ${files.join("\n")}` }] };
2119
2353
  timeout: TimeoutSchema
2120
2354
  },
2121
2355
  async ({ path, lines, grep, timeout, ...conn }) => {
2122
- return connectionPool.withConnection(conn, async (client) => {
2123
- const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
2124
- if (!output.trim()) {
2125
- return {
2126
- content: [
2127
- {
2128
- type: "text",
2129
- 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)."
2130
- }
2131
- ]
2132
- };
2133
- }
2134
- return { content: [{ type: "text", text: output }] };
2135
- });
2356
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2357
+ return connectionPool.withConnection(
2358
+ conn,
2359
+ async (client) => {
2360
+ const output = await tail(client, path, lines || 100, grep, timeoutMs);
2361
+ if (!output.trim()) {
2362
+ return {
2363
+ content: [
2364
+ {
2365
+ type: "text",
2366
+ 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)."
2367
+ }
2368
+ ]
2369
+ };
2370
+ }
2371
+ return { content: [{ type: "text", text: output }] };
2372
+ },
2373
+ poolWait(timeoutMs)
2374
+ );
2136
2375
  }
2137
2376
  );
2138
2377
  server.tool(
@@ -2144,18 +2383,23 @@ ${files.join("\n")}` }] };
2144
2383
  timeout: TimeoutSchema
2145
2384
  },
2146
2385
  async ({ service, timeout, ...conn }) => {
2147
- return connectionPool.withConnection(conn, async (client) => {
2148
- const status = await serviceStatus(client, service, timeout || 3e4);
2149
- const lines = [];
2150
- lines.push(`Service: ${status.name}`);
2151
- lines.push(`Status: ${status.status}`);
2152
- if (status.description) lines.push(`Description: ${status.description}`);
2153
- if (status.pid) lines.push(`PID: ${status.pid}`);
2154
- if (status.since) lines.push(`Since: ${status.since}`);
2155
- lines.push("");
2156
- lines.push(status.raw);
2157
- return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
2158
- });
2386
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
2387
+ return connectionPool.withConnection(
2388
+ conn,
2389
+ async (client) => {
2390
+ const status = await serviceStatus(client, service, timeoutMs);
2391
+ const lines = [];
2392
+ lines.push(`Service: ${status.name}`);
2393
+ lines.push(`Status: ${status.status}`);
2394
+ if (status.description) lines.push(`Description: ${status.description}`);
2395
+ if (status.pid) lines.push(`PID: ${status.pid}`);
2396
+ if (status.since) lines.push(`Since: ${status.since}`);
2397
+ lines.push("");
2398
+ lines.push(status.raw);
2399
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
2400
+ },
2401
+ poolWait(timeoutMs)
2402
+ );
2159
2403
  }
2160
2404
  );
2161
2405
  }
@@ -2172,6 +2416,8 @@ function createServer(pool) {
2172
2416
  }
2173
2417
  export {
2174
2418
  ConnectionPool,
2419
+ POOL_FULL_ERROR_CODE,
2420
+ PoolFullError,
2175
2421
  checkConnectivity,
2176
2422
  checkGitSsh,
2177
2423
  checkKnownHosts,
@@ -2193,6 +2439,7 @@ export {
2193
2439
  fixKnownHosts,
2194
2440
  formatDiagnostics,
2195
2441
  isPolicyConfigured,
2442
+ isPoolFullError,
2196
2443
  listDir,
2197
2444
  listSshKeys,
2198
2445
  loadKey,