@yawlabs/ssh-mcp 0.16.1 → 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/README.md +24 -2
- package/dist/index.js +380 -136
- package/dist/ops.d.ts +1 -1
- package/dist/pool.d.ts +75 -6
- package/dist/server.d.ts +2 -2
- package/dist/server.js +492 -245
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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 (
|
|
1405
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
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
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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(
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
-
|
|
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(
|
|
1736
|
-
|
|
1737
|
-
|
|
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(
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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(
|
|
1767
|
-
|
|
1768
|
-
|
|
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
|
-
|
|
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(
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
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(
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
-
|
|
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(
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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(
|
|
1839
|
-
|
|
1840
|
-
|
|
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(
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
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
|
}
|