@yawlabs/ssh-mcp 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/bin/ssh-mcp.mjs +81 -11
- package/dist/index.js +185 -54
- package/dist/policy.d.ts +1 -1
- package/dist/server.d.ts +12 -5
- package/dist/server.js +189 -54
- package/dist/ssh.d.ts +38 -2
- package/package.json +4 -1
package/dist/server.js
CHANGED
|
@@ -299,7 +299,16 @@ function diagnose(host, port = 22) {
|
|
|
299
299
|
|
|
300
300
|
// src/env.ts
|
|
301
301
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
302
|
-
import {
|
|
302
|
+
import {
|
|
303
|
+
appendFileSync,
|
|
304
|
+
closeSync,
|
|
305
|
+
existsSync as existsSync2,
|
|
306
|
+
constants as fsConstants,
|
|
307
|
+
fstatSync,
|
|
308
|
+
openSync,
|
|
309
|
+
readdirSync as readdirSync2,
|
|
310
|
+
readFileSync as readFileSync3
|
|
311
|
+
} from "fs";
|
|
303
312
|
import { homedir as homedir3 } from "os";
|
|
304
313
|
import { join as join3 } from "path";
|
|
305
314
|
|
|
@@ -440,8 +449,15 @@ function cachedKnownHostTypes(hosts, port) {
|
|
|
440
449
|
knownHostTypeCache.set(cacheKey, { at: now, types });
|
|
441
450
|
return types;
|
|
442
451
|
}
|
|
452
|
+
function isStrictHostKeyMode(env = process.env) {
|
|
453
|
+
const on = (v) => {
|
|
454
|
+
const t = (v ?? "").trim().toLowerCase();
|
|
455
|
+
return t === "1" || t === "true";
|
|
456
|
+
};
|
|
457
|
+
return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
|
|
458
|
+
}
|
|
443
459
|
function buildHostVerifier(hosts, port, rejection) {
|
|
444
|
-
const strict =
|
|
460
|
+
const strict = isStrictHostKeyMode();
|
|
445
461
|
const label = hosts.join(" / ");
|
|
446
462
|
const remediationHost = unbracketHost(hosts[0]);
|
|
447
463
|
return (key) => {
|
|
@@ -451,7 +467,7 @@ function buildHostVerifier(hosts, port, rejection) {
|
|
|
451
467
|
if (strict) {
|
|
452
468
|
rejection.current = {
|
|
453
469
|
reason: "unknown-host-strict",
|
|
454
|
-
message: `no known_hosts entry for ${label}, and
|
|
470
|
+
message: `no known_hosts entry for ${label}, and strict mode (SSH_MCP_STRICT_HOSTKEYS=1) requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
|
|
455
471
|
};
|
|
456
472
|
}
|
|
457
473
|
return !strict;
|
|
@@ -852,7 +868,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
852
868
|
});
|
|
853
869
|
if (stats.size > maxBytes) {
|
|
854
870
|
throw new Error(
|
|
855
|
-
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit.
|
|
871
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Read it a page at a time with ssh_read_file's offset and length arguments (byte offsets; the file is ${stats.size} bytes).`
|
|
856
872
|
);
|
|
857
873
|
}
|
|
858
874
|
return await new Promise((resolve, reject) => {
|
|
@@ -865,6 +881,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
865
881
|
sftp.end();
|
|
866
882
|
}
|
|
867
883
|
}
|
|
884
|
+
async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
885
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
886
|
+
throw new Error(`offset must be a non-negative integer, got ${offset}`);
|
|
887
|
+
if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
|
|
888
|
+
if (length > maxBytes) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
const sftp = await getSftp(client);
|
|
894
|
+
try {
|
|
895
|
+
const stats = await new Promise((resolve, reject) => {
|
|
896
|
+
sftp.stat(remotePath, (err, st) => {
|
|
897
|
+
if (err) return reject(err);
|
|
898
|
+
resolve(st);
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
const want = Math.max(0, Math.min(length, stats.size - offset));
|
|
902
|
+
if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
|
|
903
|
+
const handle = await new Promise((resolve, reject) => {
|
|
904
|
+
sftp.open(remotePath, "r", (err, h) => {
|
|
905
|
+
if (err) return reject(err);
|
|
906
|
+
resolve(h);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
try {
|
|
910
|
+
const buf = Buffer.alloc(want);
|
|
911
|
+
let got = 0;
|
|
912
|
+
while (got < want) {
|
|
913
|
+
const n = await new Promise((resolve, reject) => {
|
|
914
|
+
sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
|
|
915
|
+
if (err) return reject(err);
|
|
916
|
+
resolve(bytesRead ?? 0);
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
if (n === 0) break;
|
|
920
|
+
got += n;
|
|
921
|
+
}
|
|
922
|
+
return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
|
|
923
|
+
} finally {
|
|
924
|
+
sftp.close(handle, () => {
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
} finally {
|
|
928
|
+
sftp.end();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
868
931
|
async function writeFile(client, remotePath, content) {
|
|
869
932
|
const sftp = await getSftp(client);
|
|
870
933
|
try {
|
|
@@ -1092,7 +1155,7 @@ function ensureAgent() {
|
|
|
1092
1155
|
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
1093
1156
|
};
|
|
1094
1157
|
}
|
|
1095
|
-
function detectKeyType(filePath, fileName) {
|
|
1158
|
+
function detectKeyType(filePath, fileName, content) {
|
|
1096
1159
|
const pubPath = `${filePath}.pub`;
|
|
1097
1160
|
if (existsSync2(pubPath)) {
|
|
1098
1161
|
try {
|
|
@@ -1109,7 +1172,6 @@ function detectKeyType(filePath, fileName) {
|
|
|
1109
1172
|
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
1110
1173
|
if (fileName.includes("dsa")) return "dsa";
|
|
1111
1174
|
try {
|
|
1112
|
-
const content = readFileSync3(filePath, "utf8");
|
|
1113
1175
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
1114
1176
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
1115
1177
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
@@ -1148,12 +1210,13 @@ function listSshKeysDetailed() {
|
|
|
1148
1210
|
for (const file of files) {
|
|
1149
1211
|
if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
|
|
1150
1212
|
const filePath = join3(sshDir, file);
|
|
1213
|
+
let fd;
|
|
1151
1214
|
try {
|
|
1152
|
-
|
|
1153
|
-
if (!
|
|
1154
|
-
const content = readFileSync3(
|
|
1215
|
+
fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
|
|
1216
|
+
if (!fstatSync(fd).isFile()) continue;
|
|
1217
|
+
const content = readFileSync3(fd, "utf8");
|
|
1155
1218
|
if (!content.includes("PRIVATE KEY")) continue;
|
|
1156
|
-
const type = detectKeyType(filePath, file);
|
|
1219
|
+
const type = detectKeyType(filePath, file, content);
|
|
1157
1220
|
let fingerprint;
|
|
1158
1221
|
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
1159
1222
|
if (fpOk) {
|
|
@@ -1163,6 +1226,8 @@ function listSshKeysDetailed() {
|
|
|
1163
1226
|
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
1164
1227
|
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
1165
1228
|
} catch {
|
|
1229
|
+
} finally {
|
|
1230
|
+
if (fd !== void 0) closeSync(fd);
|
|
1166
1231
|
}
|
|
1167
1232
|
}
|
|
1168
1233
|
return { status: "ok", dir: sshDir, keys };
|
|
@@ -1318,7 +1383,7 @@ function testConnection(host, port = 22) {
|
|
|
1318
1383
|
}
|
|
1319
1384
|
|
|
1320
1385
|
// src/pool.ts
|
|
1321
|
-
import {
|
|
1386
|
+
import { createHmac, randomBytes, scryptSync } from "crypto";
|
|
1322
1387
|
var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
|
|
1323
1388
|
var PoolFullError = class extends Error {
|
|
1324
1389
|
code = POOL_FULL_ERROR_CODE;
|
|
@@ -1348,21 +1413,24 @@ function defaultMaxPoolSize() {
|
|
|
1348
1413
|
const parsed = Number.parseInt(raw, 10);
|
|
1349
1414
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1350
1415
|
}
|
|
1416
|
+
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
|
1417
|
+
var PASSWORD_SCRYPT_COST = 1024;
|
|
1418
|
+
function updateField(h, tag, value) {
|
|
1419
|
+
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
1420
|
+
const len = Buffer.alloc(4);
|
|
1421
|
+
len.writeUInt32BE(bytes.length);
|
|
1422
|
+
h.update(tag);
|
|
1423
|
+
h.update(len);
|
|
1424
|
+
h.update(bytes);
|
|
1425
|
+
}
|
|
1351
1426
|
function authFingerprint(cc) {
|
|
1352
|
-
const h =
|
|
1353
|
-
if (cc.privateKey)
|
|
1354
|
-
h.update("k");
|
|
1355
|
-
h.update(cc.privateKey);
|
|
1356
|
-
}
|
|
1427
|
+
const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
|
|
1428
|
+
if (cc.privateKey) updateField(h, "k", cc.privateKey);
|
|
1357
1429
|
if (cc.password !== void 0) {
|
|
1358
|
-
h
|
|
1359
|
-
h.update(cc.password);
|
|
1430
|
+
updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
|
|
1360
1431
|
}
|
|
1361
|
-
if (typeof cc.agent === "string")
|
|
1362
|
-
|
|
1363
|
-
h.update(cc.agent);
|
|
1364
|
-
}
|
|
1365
|
-
return h.digest("hex").slice(0, 16);
|
|
1432
|
+
if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
|
|
1433
|
+
return h.digest("hex").slice(0, 32);
|
|
1366
1434
|
}
|
|
1367
1435
|
function resolveOrDiagnose(config) {
|
|
1368
1436
|
try {
|
|
@@ -1522,7 +1590,7 @@ var ConnectionPool = class {
|
|
|
1522
1590
|
return client;
|
|
1523
1591
|
}
|
|
1524
1592
|
throw new Error(
|
|
1525
|
-
`Failed to acquire SSH connection for ${
|
|
1593
|
+
`Failed to acquire SSH connection for ${cc.username}@${cc.host}:${cc.port} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
1526
1594
|
);
|
|
1527
1595
|
}
|
|
1528
1596
|
release(client) {
|
|
@@ -1530,6 +1598,7 @@ var ConnectionPool = class {
|
|
|
1530
1598
|
if (entry.client === client) {
|
|
1531
1599
|
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
1532
1600
|
if (entry.refCount === 0 && !entry.dead) {
|
|
1601
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1533
1602
|
entry.idleTimer = setTimeout(() => {
|
|
1534
1603
|
try {
|
|
1535
1604
|
entry.client.end();
|
|
@@ -1746,7 +1815,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1746
1815
|
}
|
|
1747
1816
|
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1748
1817
|
var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
|
|
1818
|
+
var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
|
|
1819
|
+
function isNonNegativeInteger(n) {
|
|
1820
|
+
return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
1821
|
+
}
|
|
1749
1822
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1823
|
+
if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
|
|
1824
|
+
throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
|
|
1825
|
+
}
|
|
1826
|
+
if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
|
|
1827
|
+
throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
|
|
1828
|
+
}
|
|
1829
|
+
if (options.minsize !== void 0 && typeof options.minsize !== "string") {
|
|
1830
|
+
throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
|
|
1831
|
+
}
|
|
1832
|
+
if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
|
|
1833
|
+
throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
|
|
1834
|
+
}
|
|
1750
1835
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1751
1836
|
throw new Error(
|
|
1752
1837
|
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
|
|
@@ -1757,14 +1842,14 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1757
1842
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1758
1843
|
);
|
|
1759
1844
|
}
|
|
1760
|
-
const
|
|
1761
|
-
const args = [shellQuote(
|
|
1762
|
-
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1763
|
-
if (options.type) args.push("-type", options.type);
|
|
1845
|
+
const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
|
|
1846
|
+
const args = [shellQuote(defuseFindOperand(options.path))];
|
|
1847
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
|
|
1848
|
+
if (options.type) args.push("-type", shellQuote(options.type));
|
|
1764
1849
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
1765
|
-
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
1766
|
-
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
1767
|
-
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1850
|
+
if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
|
|
1851
|
+
if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
|
|
1852
|
+
if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
|
|
1768
1853
|
const command = `find ${args.join(" ")}`;
|
|
1769
1854
|
const result = await exec(client, command, timeoutMs);
|
|
1770
1855
|
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
@@ -1773,6 +1858,9 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1773
1858
|
return result.stdout.split("\n").filter(Boolean);
|
|
1774
1859
|
}
|
|
1775
1860
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
1861
|
+
if (!isNonNegativeInteger(lines) || lines === 0) {
|
|
1862
|
+
throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
|
|
1863
|
+
}
|
|
1776
1864
|
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
1777
1865
|
if (grep) {
|
|
1778
1866
|
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
@@ -1784,7 +1872,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
1784
1872
|
return result.stdout;
|
|
1785
1873
|
}
|
|
1786
1874
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
1787
|
-
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1875
|
+
const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1788
1876
|
const raw = result.stdout;
|
|
1789
1877
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1790
1878
|
const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
|
|
@@ -1874,6 +1962,9 @@ var connectionParams = {
|
|
|
1874
1962
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1875
1963
|
var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
|
|
1876
1964
|
var SFTP_POOL_WAIT_NOTE = ` If the connection pool is full (SSH_MCP_MAX_POOL_SIZE, default 100), waits up to ${DEFAULT_TIMEOUT_MS / 1e3}s for a free slot before starting.`;
|
|
1965
|
+
var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
|
|
1966
|
+
var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
|
|
1967
|
+
var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
1877
1968
|
var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
|
|
1878
1969
|
var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1879
1970
|
function applyEnvPrefix(command, env) {
|
|
@@ -1894,7 +1985,7 @@ function registerTools(server, pool) {
|
|
|
1894
1985
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1895
1986
|
server.tool(
|
|
1896
1987
|
"ssh_exec",
|
|
1897
|
-
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns
|
|
1988
|
+
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns up to three content blocks in this order: the status (`[exit code: N]`, plus the signal and any truncation), then `[stderr]`, then `[stdout]`. Status comes first so a proxy that cuts long results (Yaw MCP keeps about 100 KB) never drops the exit code; for big output, filter or page on the remote (head, tail, grep). Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
|
|
1898
1989
|
{
|
|
1899
1990
|
...connectionParams,
|
|
1900
1991
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
@@ -1909,13 +2000,16 @@ function registerTools(server, pool) {
|
|
|
1909
2000
|
conn,
|
|
1910
2001
|
async (client) => {
|
|
1911
2002
|
const result = await exec(client, finalCommand, timeoutMs);
|
|
1912
|
-
const
|
|
1913
|
-
if (result.
|
|
1914
|
-
if (result.
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2003
|
+
const status = [`[exit code: ${result.code}]`];
|
|
2004
|
+
if (result.signal) status.push(`[signal: ${result.signal}]`);
|
|
2005
|
+
if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
|
|
2006
|
+
if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
|
|
2007
|
+
const content = [{ type: "text", text: status.join("\n") }];
|
|
2008
|
+
if (result.stderr) content.push({ type: "text", text: `[stderr]
|
|
2009
|
+
${result.stderr}` });
|
|
2010
|
+
if (result.stdout) content.push({ type: "text", text: `[stdout]
|
|
2011
|
+
${result.stdout}` });
|
|
2012
|
+
return { content };
|
|
1919
2013
|
},
|
|
1920
2014
|
poolWait(timeoutMs)
|
|
1921
2015
|
);
|
|
@@ -1923,19 +2017,30 @@ ${result.stderr}`);
|
|
|
1923
2017
|
);
|
|
1924
2018
|
server.tool(
|
|
1925
2019
|
"ssh_read_file",
|
|
1926
|
-
`Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
|
|
2020
|
+
`Read a file from a remote host via SFTP. A whole-file read is limited to 10 MB. For a large file -- or behind a proxy that caps results, such as Yaw MCP at about 100 KB -- read it in pages with \`offset\` and \`length\` (bytes): a paged read returns a status block first (\`[bytes A-B of SIZE]\` and the next offset) and then the text.${SFTP_POOL_WAIT_NOTE}`,
|
|
1927
2021
|
{
|
|
1928
2022
|
...connectionParams,
|
|
1929
|
-
path:
|
|
1930
|
-
|
|
1931
|
-
|
|
2023
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
2024
|
+
offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
|
|
2025
|
+
length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
|
|
2026
|
+
"Number of bytes to read (default: the rest of the file, up to 10 MB). Behind Yaw MCP keep it at or under about 90000 so the page is not cut. A page boundary can split a multi-byte UTF-8 character."
|
|
2027
|
+
)
|
|
1932
2028
|
},
|
|
1933
|
-
|
|
2029
|
+
READ_ONLY_REMOTE,
|
|
2030
|
+
async ({ path, offset, length, ...conn }) => {
|
|
1934
2031
|
return connectionPool.withConnection(
|
|
1935
2032
|
conn,
|
|
1936
2033
|
async (client) => {
|
|
1937
|
-
|
|
1938
|
-
|
|
2034
|
+
if (offset === void 0 && length === void 0) {
|
|
2035
|
+
const content2 = await readFile(client, path);
|
|
2036
|
+
return { content: [{ type: "text", text: content2 }] };
|
|
2037
|
+
}
|
|
2038
|
+
const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
|
|
2039
|
+
const end = page.offset + page.bytesRead;
|
|
2040
|
+
const where = page.bytesRead === 0 ? `[no bytes at offset ${page.offset}: the file is ${page.size} bytes]` : end < page.size ? `[bytes ${page.offset}-${end} of ${page.size}; next offset: ${end}]` : `[bytes ${page.offset}-${end} of ${page.size}; end of file]`;
|
|
2041
|
+
const content = [{ type: "text", text: where }];
|
|
2042
|
+
if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
|
|
2043
|
+
return { content };
|
|
1939
2044
|
},
|
|
1940
2045
|
poolWait()
|
|
1941
2046
|
);
|
|
@@ -1949,6 +2054,7 @@ ${result.stderr}`);
|
|
|
1949
2054
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1950
2055
|
content: z.string().describe("File content to write")
|
|
1951
2056
|
},
|
|
2057
|
+
DESTRUCTIVE_REMOTE,
|
|
1952
2058
|
async ({ path, content, ...conn }) => {
|
|
1953
2059
|
return connectionPool.withConnection(
|
|
1954
2060
|
conn,
|
|
@@ -1963,7 +2069,7 @@ ${result.stderr}`);
|
|
|
1963
2069
|
);
|
|
1964
2070
|
server.tool(
|
|
1965
2071
|
"ssh_upload",
|
|
1966
|
-
`Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
2072
|
+
`Upload a local file to a remote host via SFTP. Reads an arbitrary local file at an agent-chosen \`localPath\` -- any file readable by this process leaves the machine toward the remote host (the mirror direction of ssh_download's local write).${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
1967
2073
|
{
|
|
1968
2074
|
...connectionParams,
|
|
1969
2075
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
@@ -2006,6 +2112,7 @@ ${result.stderr}`);
|
|
|
2006
2112
|
...connectionParams,
|
|
2007
2113
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
|
|
2008
2114
|
},
|
|
2115
|
+
READ_ONLY_REMOTE,
|
|
2009
2116
|
async ({ path, ...conn }) => {
|
|
2010
2117
|
return connectionPool.withConnection(
|
|
2011
2118
|
conn,
|
|
@@ -2027,6 +2134,7 @@ ${result.stderr}`);
|
|
|
2027
2134
|
...connectionParams,
|
|
2028
2135
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
2029
2136
|
},
|
|
2137
|
+
READ_ONLY_REMOTE,
|
|
2030
2138
|
async ({ path, ...conn }) => {
|
|
2031
2139
|
return connectionPool.withConnection(
|
|
2032
2140
|
conn,
|
|
@@ -2077,6 +2185,7 @@ ${result.stderr}`);
|
|
|
2077
2185
|
"Absolute path of the file or empty directory to delete. Must start with /."
|
|
2078
2186
|
)
|
|
2079
2187
|
},
|
|
2188
|
+
DESTRUCTIVE_REMOTE,
|
|
2080
2189
|
async ({ path, ...conn }) => {
|
|
2081
2190
|
return connectionPool.withConnection(
|
|
2082
2191
|
conn,
|
|
@@ -2095,6 +2204,7 @@ ${result.stderr}`);
|
|
|
2095
2204
|
host: HostSchema,
|
|
2096
2205
|
port: PortSchema
|
|
2097
2206
|
},
|
|
2207
|
+
READ_ONLY_REMOTE,
|
|
2098
2208
|
async ({ host, port }) => {
|
|
2099
2209
|
const report = diagnose(host, port || 22);
|
|
2100
2210
|
const lines = [];
|
|
@@ -2141,6 +2251,7 @@ ${result.stderr}`);
|
|
|
2141
2251
|
"ssh_key_list",
|
|
2142
2252
|
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
|
|
2143
2253
|
{},
|
|
2254
|
+
READ_ONLY_LOCAL,
|
|
2144
2255
|
async () => {
|
|
2145
2256
|
const listing = listSshKeysDetailed();
|
|
2146
2257
|
if (listing.status === "unreadable") {
|
|
@@ -2205,6 +2316,7 @@ ${result.stderr}`);
|
|
|
2205
2316
|
{
|
|
2206
2317
|
host: HostSchema
|
|
2207
2318
|
},
|
|
2319
|
+
READ_ONLY_LOCAL,
|
|
2208
2320
|
async ({ host }) => {
|
|
2209
2321
|
const result = configLookup(host);
|
|
2210
2322
|
if ("error" in result) {
|
|
@@ -2277,7 +2389,9 @@ ${result.stderr}`);
|
|
|
2277
2389
|
"ssh_multi_exec",
|
|
2278
2390
|
"Execute a command on multiple remote hosts in parallel. Runs at most SSH_MCP_MAX_POOL_SIZE hosts at once (default 100) and works through a longer list as slots free up. `timeout` is per host: it bounds each host's command, not the whole call. Each parallel slot works through its share of the list one host at a time, so with the pool to itself a call can take up to about ceil(hosts / SSH_MCP_MAX_POOL_SIZE) x (`timeout` + connect time). The connection pool is shared with every other tool; when it is full this call waits for slots instead of failing, and gives up only when none of its own hosts holds a slot and a full `timeout` has passed with none of them starting or finishing. The hosts waiting at that point (up to one per parallel slot) and every host still queued then report `Connection pool is full`; the queued ones are never attempted. Rerun those hosts once the other calls finish, or raise SSH_MCP_MAX_POOL_SIZE. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
|
|
2279
2391
|
{
|
|
2280
|
-
|
|
2392
|
+
// .min(1): `hosts: []` used to return an empty success block indistinguishable from a
|
|
2393
|
+
// no-op -- the same boundary rejection ssh_git_check applies to its empty host.
|
|
2394
|
+
hosts: z.array(z.string()).min(1, "hosts must not be empty. Name at least one host -- an empty list runs the command on nothing.").describe("List of SSH hostnames or IPs"),
|
|
2281
2395
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
2282
2396
|
port: PortSchema,
|
|
2283
2397
|
username: UsernameSchema,
|
|
@@ -2320,12 +2434,13 @@ ${result.stderr}`);
|
|
|
2320
2434
|
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
2321
2435
|
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
2322
2436
|
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
2323
|
-
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
2437
|
+
maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
|
|
2324
2438
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
2325
2439
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
2326
2440
|
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
2327
2441
|
timeout: TimeoutSchema
|
|
2328
2442
|
},
|
|
2443
|
+
READ_ONLY_REMOTE,
|
|
2329
2444
|
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
2330
2445
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2331
2446
|
return connectionPool.withConnection(
|
|
@@ -2352,6 +2467,7 @@ ${files.join("\n")}` }] };
|
|
|
2352
2467
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
2353
2468
|
timeout: TimeoutSchema
|
|
2354
2469
|
},
|
|
2470
|
+
READ_ONLY_REMOTE,
|
|
2355
2471
|
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
2356
2472
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2357
2473
|
return connectionPool.withConnection(
|
|
@@ -2382,6 +2498,7 @@ ${files.join("\n")}` }] };
|
|
|
2382
2498
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
2383
2499
|
timeout: TimeoutSchema
|
|
2384
2500
|
},
|
|
2501
|
+
READ_ONLY_REMOTE,
|
|
2385
2502
|
async ({ service, timeout, ...conn }) => {
|
|
2386
2503
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2387
2504
|
return connectionPool.withConnection(
|
|
@@ -2406,11 +2523,25 @@ ${files.join("\n")}` }] };
|
|
|
2406
2523
|
|
|
2407
2524
|
// src/server.ts
|
|
2408
2525
|
var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
2526
|
+
var SERVER_INSTRUCTIONS = [
|
|
2527
|
+
"ssh-mcp runs commands and moves files on remote hosts over SSH, using ~/.ssh/config, the ssh-agent and known_hosts the way the OpenSSH client does.",
|
|
2528
|
+
"",
|
|
2529
|
+
"- After an auth, connect or host-key failure, run ssh_diagnose on that host before retrying. ssh_test is the quick check; ssh_key_list and ssh_key_load fix a key missing from the agent; ssh_known_hosts_fix replaces a stale host key.",
|
|
2530
|
+
"- Prefer the purpose-built tools over ssh_exec: ssh_read_file, ssh_ls, ssh_stat, ssh_find, ssh_tail, ssh_service_status. Use ssh_multi_exec for the same command on several hosts.",
|
|
2531
|
+
"- ssh_exec returns the exit code first, then stderr, then stdout. Proxies may cut long results (Yaw MCP keeps about 100 KB), so filter on the remote (grep, head, tail) instead of printing everything. Page a large file with ssh_read_file offset and length.",
|
|
2532
|
+
"- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
|
|
2533
|
+
"- Host keys: a host already in known_hosts must match. An unknown host is trusted on every connection unless strict mode (SSH_MCP_STRICT_HOSTKEYS=1) is on; add it with ssh_known_hosts_fix first.",
|
|
2534
|
+
"- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
|
|
2535
|
+
"- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
|
|
2536
|
+
].join("\n");
|
|
2409
2537
|
function createServer(pool) {
|
|
2410
|
-
const server = new McpServer(
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2538
|
+
const server = new McpServer(
|
|
2539
|
+
{
|
|
2540
|
+
name: "ssh-mcp",
|
|
2541
|
+
version
|
|
2542
|
+
},
|
|
2543
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
2544
|
+
);
|
|
2414
2545
|
registerTools(server, pool);
|
|
2415
2546
|
return server;
|
|
2416
2547
|
}
|
|
@@ -2418,6 +2549,7 @@ export {
|
|
|
2418
2549
|
ConnectionPool,
|
|
2419
2550
|
POOL_FULL_ERROR_CODE,
|
|
2420
2551
|
PoolFullError,
|
|
2552
|
+
SERVER_INSTRUCTIONS,
|
|
2421
2553
|
checkConnectivity,
|
|
2422
2554
|
checkGitSsh,
|
|
2423
2555
|
checkKnownHosts,
|
|
@@ -2442,14 +2574,17 @@ export {
|
|
|
2442
2574
|
isPoolFullError,
|
|
2443
2575
|
listDir,
|
|
2444
2576
|
listSshKeys,
|
|
2577
|
+
listSshKeysDetailed,
|
|
2445
2578
|
loadKey,
|
|
2446
2579
|
makeDir,
|
|
2447
2580
|
multiExec,
|
|
2448
2581
|
readFile,
|
|
2582
|
+
readFileRange,
|
|
2449
2583
|
readKnownHostsKeys,
|
|
2450
2584
|
registerTools,
|
|
2451
2585
|
resolveConfig,
|
|
2452
2586
|
serviceStatus,
|
|
2587
|
+
shellQuote,
|
|
2453
2588
|
statFile,
|
|
2454
2589
|
tail,
|
|
2455
2590
|
testConnection,
|
package/dist/ssh.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface ExecResult {
|
|
|
20
20
|
}
|
|
21
21
|
/** Why the hostVerifier turned a server's host key down. */
|
|
22
22
|
export type HostKeyRejectionReason =
|
|
23
|
-
/** No known_hosts entry at all, and
|
|
23
|
+
/** No known_hosts entry at all, and strict mode is on (SSH_MCP_STRICT_HOSTKEYS, see isStrictHostKeyMode). */
|
|
24
24
|
"unknown-host-strict"
|
|
25
25
|
/** known_hosts has entries for this host, but none of the algorithm the server offered. */
|
|
26
26
|
| "algorithm-not-in-known-hosts"
|
|
@@ -107,6 +107,21 @@ export declare function hostKeyBlobType(key: Buffer): string | null;
|
|
|
107
107
|
export declare function hostKeyAlgorithmOrder(knownHostTypes: ReadonlyArray<string>): string[] | null;
|
|
108
108
|
/** Test-only: drop the memoized known_hosts key types. */
|
|
109
109
|
export declare function clearKnownHostTypeCache(): void;
|
|
110
|
+
/**
|
|
111
|
+
* Strict host-key mode: reject a host with no known_hosts entry instead of trusting it.
|
|
112
|
+
*
|
|
113
|
+
* Two names, one switch. SSH_MCP_STRICT_HOSTKEYS is the documented one.
|
|
114
|
+
* SSH_MCP_STRICT_HOST_KEY is the original name and keeps working, but it is
|
|
115
|
+
* credential-shaped -- `KEY` is a whole underscore segment -- so Yaw MCP's doctor
|
|
116
|
+
* listed `SSH_MCP_STRICT_HOST_KEY=1` as a plaintext secret and suggested moving it
|
|
117
|
+
* into the vault. `HOSTKEYS` is not a credential segment, so the new name is not
|
|
118
|
+
* flagged. Either one turns strict mode on.
|
|
119
|
+
*
|
|
120
|
+
* Both are read the way Yaw MCP reads an opt-in: trimmed, case-insensitive `1` or
|
|
121
|
+
* `true`. Only the exact string "1" used to count, so `=true` silently left strict
|
|
122
|
+
* mode OFF -- the unsafe direction for a security switch.
|
|
123
|
+
*/
|
|
124
|
+
export declare function isStrictHostKeyMode(env?: NodeJS.ProcessEnv): boolean;
|
|
110
125
|
export declare function resolveConfig(config: SSHConfig): ResolvedConfig;
|
|
111
126
|
export declare function formatDiagnostics(host: string): string;
|
|
112
127
|
export declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
@@ -136,7 +151,7 @@ export interface JumpHop {
|
|
|
136
151
|
*
|
|
137
152
|
* It is also a host-key issue, not only a connectivity one: `knownHostsTargets`
|
|
138
153
|
* rejects those mangled spellings and returns [], and a verifier with zero known
|
|
139
|
-
* entries accepts ANY key unless
|
|
154
|
+
* entries accepts ANY key unless SSH_MCP_STRICT_HOSTKEYS=1. Parsing correctly is
|
|
140
155
|
* what puts the bastion hop back under real known_hosts checking.
|
|
141
156
|
*
|
|
142
157
|
* Grammar (OpenSSH ssh_config(5)): `[user@]host[:port]`, or the equivalent
|
|
@@ -173,7 +188,28 @@ export declare function enhanceSshError(err: unknown, host: string, resolved?: R
|
|
|
173
188
|
export declare function connect(config: SSHConfig): Promise<Client>;
|
|
174
189
|
export declare const DEFAULT_MAX_EXEC_BYTES: number;
|
|
175
190
|
export declare function exec(client: Client, command: string, timeoutMs?: number, maxBytes?: number): Promise<ExecResult>;
|
|
191
|
+
export declare const DEFAULT_MAX_READ_BYTES: number;
|
|
176
192
|
export declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
193
|
+
/** One page of a remote file, plus where it sits in the whole. */
|
|
194
|
+
export interface FileRange {
|
|
195
|
+
/** The bytes read, decoded as UTF-8. A page boundary can split a multi-byte character. */
|
|
196
|
+
text: string;
|
|
197
|
+
/** Byte offset the page starts at (as requested). */
|
|
198
|
+
offset: number;
|
|
199
|
+
/** Number of bytes actually read: less than requested at end of file. */
|
|
200
|
+
bytesRead: number;
|
|
201
|
+
/** Size of the whole file, from the same stat. */
|
|
202
|
+
size: number;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Read `length` bytes of a remote file starting at byte `offset`.
|
|
206
|
+
*
|
|
207
|
+
* The paging half of ssh_read_file. A whole-file read is capped at 10 MB here, but a
|
|
208
|
+
* proxy in front of this server can cap far lower -- Yaw MCP keeps 100 KB of a result
|
|
209
|
+
* by default and cuts the rest -- so a large file has to be readable in pages without
|
|
210
|
+
* turning a cap off. `length` is bounded by the same `maxBytes` as a whole read.
|
|
211
|
+
*/
|
|
212
|
+
export declare function readFileRange(client: Client, remotePath: string, offset: number, length: number, maxBytes?: number): Promise<FileRange>;
|
|
177
213
|
export declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
178
214
|
export declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
179
215
|
export declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"mcpName": "io.github.YawLabs/ssh-mcp",
|
|
5
5
|
"description": "SSH MCP server: run remote commands, transfer files over SFTP, manage ssh-agent keys and known_hosts, and auto-diagnose SSH failures.",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"test": "vitest run",
|
|
29
29
|
"test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
|
|
30
30
|
"test:ci": "npm run build && npm test",
|
|
31
|
+
"check:compliance": "node scripts/check-compliance.mjs",
|
|
32
|
+
"check:oam-floor": "node scripts/check-oam-floor.mjs",
|
|
31
33
|
"prepublishOnly": "npm run build"
|
|
32
34
|
},
|
|
33
35
|
"keywords": [
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"@types/node": "^26.1.1",
|
|
73
75
|
"@types/ssh2": "^1.15.5",
|
|
74
76
|
"@vitest/coverage-v8": "^4.1.11",
|
|
77
|
+
"@yawlabs/mcp-compliance": "^0.20.4",
|
|
75
78
|
"esbuild": "^0.28.1",
|
|
76
79
|
"postject": "^1.0.0-alpha.6",
|
|
77
80
|
"tsup": "^8.5.1",
|