@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/index.js
CHANGED
|
@@ -11,7 +11,16 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
11
11
|
|
|
12
12
|
// src/env.ts
|
|
13
13
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
appendFileSync,
|
|
16
|
+
closeSync,
|
|
17
|
+
existsSync as existsSync2,
|
|
18
|
+
constants as fsConstants,
|
|
19
|
+
fstatSync,
|
|
20
|
+
openSync,
|
|
21
|
+
readdirSync as readdirSync2,
|
|
22
|
+
readFileSync as readFileSync3
|
|
23
|
+
} from "fs";
|
|
15
24
|
import { homedir as homedir3 } from "os";
|
|
16
25
|
import { join as join3 } from "path";
|
|
17
26
|
|
|
@@ -432,8 +441,15 @@ function cachedKnownHostTypes(hosts, port) {
|
|
|
432
441
|
knownHostTypeCache.set(cacheKey, { at: now, types });
|
|
433
442
|
return types;
|
|
434
443
|
}
|
|
444
|
+
function isStrictHostKeyMode(env = process.env) {
|
|
445
|
+
const on = (v) => {
|
|
446
|
+
const t = (v ?? "").trim().toLowerCase();
|
|
447
|
+
return t === "1" || t === "true";
|
|
448
|
+
};
|
|
449
|
+
return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
|
|
450
|
+
}
|
|
435
451
|
function buildHostVerifier(hosts, port, rejection) {
|
|
436
|
-
const strict =
|
|
452
|
+
const strict = isStrictHostKeyMode();
|
|
437
453
|
const label = hosts.join(" / ");
|
|
438
454
|
const remediationHost = unbracketHost(hosts[0]);
|
|
439
455
|
return (key) => {
|
|
@@ -443,7 +459,7 @@ function buildHostVerifier(hosts, port, rejection) {
|
|
|
443
459
|
if (strict) {
|
|
444
460
|
rejection.current = {
|
|
445
461
|
reason: "unknown-host-strict",
|
|
446
|
-
message: `no known_hosts entry for ${label}, and
|
|
462
|
+
message: `no known_hosts entry for ${label}, and strict mode (SSH_MCP_STRICT_HOSTKEYS=1) requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
|
|
447
463
|
};
|
|
448
464
|
}
|
|
449
465
|
return !strict;
|
|
@@ -835,7 +851,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
835
851
|
});
|
|
836
852
|
if (stats.size > maxBytes) {
|
|
837
853
|
throw new Error(
|
|
838
|
-
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit.
|
|
854
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Read it a page at a time with ssh_read_file's offset and length arguments (byte offsets; the file is ${stats.size} bytes).`
|
|
839
855
|
);
|
|
840
856
|
}
|
|
841
857
|
return await new Promise((resolve, reject) => {
|
|
@@ -848,6 +864,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
848
864
|
sftp.end();
|
|
849
865
|
}
|
|
850
866
|
}
|
|
867
|
+
async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
868
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
869
|
+
throw new Error(`offset must be a non-negative integer, got ${offset}`);
|
|
870
|
+
if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
|
|
871
|
+
if (length > maxBytes) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
const sftp = await getSftp(client);
|
|
877
|
+
try {
|
|
878
|
+
const stats = await new Promise((resolve, reject) => {
|
|
879
|
+
sftp.stat(remotePath, (err, st) => {
|
|
880
|
+
if (err) return reject(err);
|
|
881
|
+
resolve(st);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
const want = Math.max(0, Math.min(length, stats.size - offset));
|
|
885
|
+
if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
|
|
886
|
+
const handle = await new Promise((resolve, reject) => {
|
|
887
|
+
sftp.open(remotePath, "r", (err, h) => {
|
|
888
|
+
if (err) return reject(err);
|
|
889
|
+
resolve(h);
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
try {
|
|
893
|
+
const buf = Buffer.alloc(want);
|
|
894
|
+
let got = 0;
|
|
895
|
+
while (got < want) {
|
|
896
|
+
const n = await new Promise((resolve, reject) => {
|
|
897
|
+
sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
|
|
898
|
+
if (err) return reject(err);
|
|
899
|
+
resolve(bytesRead ?? 0);
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
if (n === 0) break;
|
|
903
|
+
got += n;
|
|
904
|
+
}
|
|
905
|
+
return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
|
|
906
|
+
} finally {
|
|
907
|
+
sftp.close(handle, () => {
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
} finally {
|
|
911
|
+
sftp.end();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
851
914
|
async function writeFile(client, remotePath, content) {
|
|
852
915
|
const sftp = await getSftp(client);
|
|
853
916
|
try {
|
|
@@ -1083,7 +1146,7 @@ function ensureAgent() {
|
|
|
1083
1146
|
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
1084
1147
|
};
|
|
1085
1148
|
}
|
|
1086
|
-
function detectKeyType(filePath, fileName) {
|
|
1149
|
+
function detectKeyType(filePath, fileName, content) {
|
|
1087
1150
|
const pubPath = `${filePath}.pub`;
|
|
1088
1151
|
if (existsSync2(pubPath)) {
|
|
1089
1152
|
try {
|
|
@@ -1100,7 +1163,6 @@ function detectKeyType(filePath, fileName) {
|
|
|
1100
1163
|
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
1101
1164
|
if (fileName.includes("dsa")) return "dsa";
|
|
1102
1165
|
try {
|
|
1103
|
-
const content = readFileSync3(filePath, "utf8");
|
|
1104
1166
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
1105
1167
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
1106
1168
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
@@ -1136,12 +1198,13 @@ function listSshKeysDetailed() {
|
|
|
1136
1198
|
for (const file of files) {
|
|
1137
1199
|
if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
|
|
1138
1200
|
const filePath = join3(sshDir, file);
|
|
1201
|
+
let fd;
|
|
1139
1202
|
try {
|
|
1140
|
-
|
|
1141
|
-
if (!
|
|
1142
|
-
const content = readFileSync3(
|
|
1203
|
+
fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
|
|
1204
|
+
if (!fstatSync(fd).isFile()) continue;
|
|
1205
|
+
const content = readFileSync3(fd, "utf8");
|
|
1143
1206
|
if (!content.includes("PRIVATE KEY")) continue;
|
|
1144
|
-
const type = detectKeyType(filePath, file);
|
|
1207
|
+
const type = detectKeyType(filePath, file, content);
|
|
1145
1208
|
let fingerprint;
|
|
1146
1209
|
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
1147
1210
|
if (fpOk) {
|
|
@@ -1151,6 +1214,8 @@ function listSshKeysDetailed() {
|
|
|
1151
1214
|
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
1152
1215
|
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
1153
1216
|
} catch {
|
|
1217
|
+
} finally {
|
|
1218
|
+
if (fd !== void 0) closeSync(fd);
|
|
1154
1219
|
}
|
|
1155
1220
|
}
|
|
1156
1221
|
return { status: "ok", dir: sshDir, keys };
|
|
@@ -1306,7 +1371,7 @@ function testConnection(host, port = 22) {
|
|
|
1306
1371
|
}
|
|
1307
1372
|
|
|
1308
1373
|
// src/pool.ts
|
|
1309
|
-
import {
|
|
1374
|
+
import { createHmac, randomBytes, scryptSync } from "crypto";
|
|
1310
1375
|
var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
|
|
1311
1376
|
var PoolFullError = class extends Error {
|
|
1312
1377
|
code = POOL_FULL_ERROR_CODE;
|
|
@@ -1336,21 +1401,24 @@ function defaultMaxPoolSize() {
|
|
|
1336
1401
|
const parsed = Number.parseInt(raw, 10);
|
|
1337
1402
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1338
1403
|
}
|
|
1404
|
+
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
|
1405
|
+
var PASSWORD_SCRYPT_COST = 1024;
|
|
1406
|
+
function updateField(h, tag, value) {
|
|
1407
|
+
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
1408
|
+
const len = Buffer.alloc(4);
|
|
1409
|
+
len.writeUInt32BE(bytes.length);
|
|
1410
|
+
h.update(tag);
|
|
1411
|
+
h.update(len);
|
|
1412
|
+
h.update(bytes);
|
|
1413
|
+
}
|
|
1339
1414
|
function authFingerprint(cc) {
|
|
1340
|
-
const h =
|
|
1341
|
-
if (cc.privateKey)
|
|
1342
|
-
h.update("k");
|
|
1343
|
-
h.update(cc.privateKey);
|
|
1344
|
-
}
|
|
1415
|
+
const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
|
|
1416
|
+
if (cc.privateKey) updateField(h, "k", cc.privateKey);
|
|
1345
1417
|
if (cc.password !== void 0) {
|
|
1346
|
-
h
|
|
1347
|
-
h.update(cc.password);
|
|
1418
|
+
updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
|
|
1348
1419
|
}
|
|
1349
|
-
if (typeof cc.agent === "string")
|
|
1350
|
-
|
|
1351
|
-
h.update(cc.agent);
|
|
1352
|
-
}
|
|
1353
|
-
return h.digest("hex").slice(0, 16);
|
|
1420
|
+
if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
|
|
1421
|
+
return h.digest("hex").slice(0, 32);
|
|
1354
1422
|
}
|
|
1355
1423
|
function resolveOrDiagnose(config) {
|
|
1356
1424
|
try {
|
|
@@ -1510,7 +1578,7 @@ var ConnectionPool = class {
|
|
|
1510
1578
|
return client;
|
|
1511
1579
|
}
|
|
1512
1580
|
throw new Error(
|
|
1513
|
-
`Failed to acquire SSH connection for ${
|
|
1581
|
+
`Failed to acquire SSH connection for ${cc.username}@${cc.host}:${cc.port} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
1514
1582
|
);
|
|
1515
1583
|
}
|
|
1516
1584
|
release(client) {
|
|
@@ -1518,6 +1586,7 @@ var ConnectionPool = class {
|
|
|
1518
1586
|
if (entry.client === client) {
|
|
1519
1587
|
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
1520
1588
|
if (entry.refCount === 0 && !entry.dead) {
|
|
1589
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1521
1590
|
entry.idleTimer = setTimeout(() => {
|
|
1522
1591
|
try {
|
|
1523
1592
|
entry.client.end();
|
|
@@ -1743,7 +1812,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1743
1812
|
}
|
|
1744
1813
|
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1745
1814
|
var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
|
|
1815
|
+
var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
|
|
1816
|
+
function isNonNegativeInteger(n) {
|
|
1817
|
+
return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
1818
|
+
}
|
|
1746
1819
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1820
|
+
if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
|
|
1821
|
+
throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
|
|
1822
|
+
}
|
|
1823
|
+
if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
|
|
1824
|
+
throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
|
|
1825
|
+
}
|
|
1826
|
+
if (options.minsize !== void 0 && typeof options.minsize !== "string") {
|
|
1827
|
+
throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
|
|
1828
|
+
}
|
|
1829
|
+
if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
|
|
1830
|
+
throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
|
|
1831
|
+
}
|
|
1747
1832
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1748
1833
|
throw new Error(
|
|
1749
1834
|
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
|
|
@@ -1754,14 +1839,14 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1754
1839
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1755
1840
|
);
|
|
1756
1841
|
}
|
|
1757
|
-
const
|
|
1758
|
-
const args = [shellQuote(
|
|
1759
|
-
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1760
|
-
if (options.type) args.push("-type", options.type);
|
|
1842
|
+
const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
|
|
1843
|
+
const args = [shellQuote(defuseFindOperand(options.path))];
|
|
1844
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
|
|
1845
|
+
if (options.type) args.push("-type", shellQuote(options.type));
|
|
1761
1846
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
1762
|
-
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
1763
|
-
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
1764
|
-
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1847
|
+
if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
|
|
1848
|
+
if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
|
|
1849
|
+
if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
|
|
1765
1850
|
const command = `find ${args.join(" ")}`;
|
|
1766
1851
|
const result = await exec(client, command, timeoutMs);
|
|
1767
1852
|
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
@@ -1770,6 +1855,9 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1770
1855
|
return result.stdout.split("\n").filter(Boolean);
|
|
1771
1856
|
}
|
|
1772
1857
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
1858
|
+
if (!isNonNegativeInteger(lines) || lines === 0) {
|
|
1859
|
+
throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
|
|
1860
|
+
}
|
|
1773
1861
|
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
1774
1862
|
if (grep) {
|
|
1775
1863
|
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
@@ -1781,7 +1869,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
1781
1869
|
return result.stdout;
|
|
1782
1870
|
}
|
|
1783
1871
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
1784
|
-
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1872
|
+
const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1785
1873
|
const raw = result.stdout;
|
|
1786
1874
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1787
1875
|
const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
|
|
@@ -1868,6 +1956,9 @@ var connectionParams = {
|
|
|
1868
1956
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1869
1957
|
var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
|
|
1870
1958
|
var SFTP_POOL_WAIT_NOTE = ` If the connection pool is full (SSH_MCP_MAX_POOL_SIZE, default 100), waits up to ${DEFAULT_TIMEOUT_MS / 1e3}s for a free slot before starting.`;
|
|
1959
|
+
var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
|
|
1960
|
+
var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
|
|
1961
|
+
var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
1871
1962
|
var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
|
|
1872
1963
|
var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1873
1964
|
function applyEnvPrefix(command, env) {
|
|
@@ -1888,7 +1979,7 @@ function registerTools(server, pool) {
|
|
|
1888
1979
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1889
1980
|
server.tool(
|
|
1890
1981
|
"ssh_exec",
|
|
1891
|
-
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns
|
|
1982
|
+
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns up to three content blocks in this order: the status (`[exit code: N]`, plus the signal and any truncation), then `[stderr]`, then `[stdout]`. Status comes first so a proxy that cuts long results (Yaw MCP keeps about 100 KB) never drops the exit code; for big output, filter or page on the remote (head, tail, grep). Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
|
|
1892
1983
|
{
|
|
1893
1984
|
...connectionParams,
|
|
1894
1985
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
@@ -1903,13 +1994,16 @@ function registerTools(server, pool) {
|
|
|
1903
1994
|
conn,
|
|
1904
1995
|
async (client) => {
|
|
1905
1996
|
const result = await exec(client, finalCommand, timeoutMs);
|
|
1906
|
-
const
|
|
1907
|
-
if (result.
|
|
1908
|
-
if (result.
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1997
|
+
const status = [`[exit code: ${result.code}]`];
|
|
1998
|
+
if (result.signal) status.push(`[signal: ${result.signal}]`);
|
|
1999
|
+
if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
|
|
2000
|
+
if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
|
|
2001
|
+
const content = [{ type: "text", text: status.join("\n") }];
|
|
2002
|
+
if (result.stderr) content.push({ type: "text", text: `[stderr]
|
|
2003
|
+
${result.stderr}` });
|
|
2004
|
+
if (result.stdout) content.push({ type: "text", text: `[stdout]
|
|
2005
|
+
${result.stdout}` });
|
|
2006
|
+
return { content };
|
|
1913
2007
|
},
|
|
1914
2008
|
poolWait(timeoutMs)
|
|
1915
2009
|
);
|
|
@@ -1917,19 +2011,30 @@ ${result.stderr}`);
|
|
|
1917
2011
|
);
|
|
1918
2012
|
server.tool(
|
|
1919
2013
|
"ssh_read_file",
|
|
1920
|
-
`Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
|
|
2014
|
+
`Read a file from a remote host via SFTP. A whole-file read is limited to 10 MB. For a large file -- or behind a proxy that caps results, such as Yaw MCP at about 100 KB -- read it in pages with \`offset\` and \`length\` (bytes): a paged read returns a status block first (\`[bytes A-B of SIZE]\` and the next offset) and then the text.${SFTP_POOL_WAIT_NOTE}`,
|
|
1921
2015
|
{
|
|
1922
2016
|
...connectionParams,
|
|
1923
|
-
path:
|
|
1924
|
-
|
|
1925
|
-
|
|
2017
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
2018
|
+
offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
|
|
2019
|
+
length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
|
|
2020
|
+
"Number of bytes to read (default: the rest of the file, up to 10 MB). Behind Yaw MCP keep it at or under about 90000 so the page is not cut. A page boundary can split a multi-byte UTF-8 character."
|
|
2021
|
+
)
|
|
1926
2022
|
},
|
|
1927
|
-
|
|
2023
|
+
READ_ONLY_REMOTE,
|
|
2024
|
+
async ({ path, offset, length, ...conn }) => {
|
|
1928
2025
|
return connectionPool.withConnection(
|
|
1929
2026
|
conn,
|
|
1930
2027
|
async (client) => {
|
|
1931
|
-
|
|
1932
|
-
|
|
2028
|
+
if (offset === void 0 && length === void 0) {
|
|
2029
|
+
const content2 = await readFile(client, path);
|
|
2030
|
+
return { content: [{ type: "text", text: content2 }] };
|
|
2031
|
+
}
|
|
2032
|
+
const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
|
|
2033
|
+
const end = page.offset + page.bytesRead;
|
|
2034
|
+
const where = page.bytesRead === 0 ? `[no bytes at offset ${page.offset}: the file is ${page.size} bytes]` : end < page.size ? `[bytes ${page.offset}-${end} of ${page.size}; next offset: ${end}]` : `[bytes ${page.offset}-${end} of ${page.size}; end of file]`;
|
|
2035
|
+
const content = [{ type: "text", text: where }];
|
|
2036
|
+
if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
|
|
2037
|
+
return { content };
|
|
1933
2038
|
},
|
|
1934
2039
|
poolWait()
|
|
1935
2040
|
);
|
|
@@ -1943,6 +2048,7 @@ ${result.stderr}`);
|
|
|
1943
2048
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1944
2049
|
content: z.string().describe("File content to write")
|
|
1945
2050
|
},
|
|
2051
|
+
DESTRUCTIVE_REMOTE,
|
|
1946
2052
|
async ({ path, content, ...conn }) => {
|
|
1947
2053
|
return connectionPool.withConnection(
|
|
1948
2054
|
conn,
|
|
@@ -1957,7 +2063,7 @@ ${result.stderr}`);
|
|
|
1957
2063
|
);
|
|
1958
2064
|
server.tool(
|
|
1959
2065
|
"ssh_upload",
|
|
1960
|
-
`Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
2066
|
+
`Upload a local file to a remote host via SFTP. Reads an arbitrary local file at an agent-chosen \`localPath\` -- any file readable by this process leaves the machine toward the remote host (the mirror direction of ssh_download's local write).${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
1961
2067
|
{
|
|
1962
2068
|
...connectionParams,
|
|
1963
2069
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
@@ -2000,6 +2106,7 @@ ${result.stderr}`);
|
|
|
2000
2106
|
...connectionParams,
|
|
2001
2107
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
|
|
2002
2108
|
},
|
|
2109
|
+
READ_ONLY_REMOTE,
|
|
2003
2110
|
async ({ path, ...conn }) => {
|
|
2004
2111
|
return connectionPool.withConnection(
|
|
2005
2112
|
conn,
|
|
@@ -2021,6 +2128,7 @@ ${result.stderr}`);
|
|
|
2021
2128
|
...connectionParams,
|
|
2022
2129
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
2023
2130
|
},
|
|
2131
|
+
READ_ONLY_REMOTE,
|
|
2024
2132
|
async ({ path, ...conn }) => {
|
|
2025
2133
|
return connectionPool.withConnection(
|
|
2026
2134
|
conn,
|
|
@@ -2071,6 +2179,7 @@ ${result.stderr}`);
|
|
|
2071
2179
|
"Absolute path of the file or empty directory to delete. Must start with /."
|
|
2072
2180
|
)
|
|
2073
2181
|
},
|
|
2182
|
+
DESTRUCTIVE_REMOTE,
|
|
2074
2183
|
async ({ path, ...conn }) => {
|
|
2075
2184
|
return connectionPool.withConnection(
|
|
2076
2185
|
conn,
|
|
@@ -2089,6 +2198,7 @@ ${result.stderr}`);
|
|
|
2089
2198
|
host: HostSchema,
|
|
2090
2199
|
port: PortSchema
|
|
2091
2200
|
},
|
|
2201
|
+
READ_ONLY_REMOTE,
|
|
2092
2202
|
async ({ host, port }) => {
|
|
2093
2203
|
const report = diagnose(host, port || 22);
|
|
2094
2204
|
const lines = [];
|
|
@@ -2135,6 +2245,7 @@ ${result.stderr}`);
|
|
|
2135
2245
|
"ssh_key_list",
|
|
2136
2246
|
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
|
|
2137
2247
|
{},
|
|
2248
|
+
READ_ONLY_LOCAL,
|
|
2138
2249
|
async () => {
|
|
2139
2250
|
const listing = listSshKeysDetailed();
|
|
2140
2251
|
if (listing.status === "unreadable") {
|
|
@@ -2199,6 +2310,7 @@ ${result.stderr}`);
|
|
|
2199
2310
|
{
|
|
2200
2311
|
host: HostSchema
|
|
2201
2312
|
},
|
|
2313
|
+
READ_ONLY_LOCAL,
|
|
2202
2314
|
async ({ host }) => {
|
|
2203
2315
|
const result = configLookup(host);
|
|
2204
2316
|
if ("error" in result) {
|
|
@@ -2271,7 +2383,9 @@ ${result.stderr}`);
|
|
|
2271
2383
|
"ssh_multi_exec",
|
|
2272
2384
|
"Execute a command on multiple remote hosts in parallel. Runs at most SSH_MCP_MAX_POOL_SIZE hosts at once (default 100) and works through a longer list as slots free up. `timeout` is per host: it bounds each host's command, not the whole call. Each parallel slot works through its share of the list one host at a time, so with the pool to itself a call can take up to about ceil(hosts / SSH_MCP_MAX_POOL_SIZE) x (`timeout` + connect time). The connection pool is shared with every other tool; when it is full this call waits for slots instead of failing, and gives up only when none of its own hosts holds a slot and a full `timeout` has passed with none of them starting or finishing. The hosts waiting at that point (up to one per parallel slot) and every host still queued then report `Connection pool is full`; the queued ones are never attempted. Rerun those hosts once the other calls finish, or raise SSH_MCP_MAX_POOL_SIZE. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
|
|
2273
2385
|
{
|
|
2274
|
-
|
|
2386
|
+
// .min(1): `hosts: []` used to return an empty success block indistinguishable from a
|
|
2387
|
+
// no-op -- the same boundary rejection ssh_git_check applies to its empty host.
|
|
2388
|
+
hosts: z.array(z.string()).min(1, "hosts must not be empty. Name at least one host -- an empty list runs the command on nothing.").describe("List of SSH hostnames or IPs"),
|
|
2275
2389
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
2276
2390
|
port: PortSchema,
|
|
2277
2391
|
username: UsernameSchema,
|
|
@@ -2314,12 +2428,13 @@ ${result.stderr}`);
|
|
|
2314
2428
|
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
2315
2429
|
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
2316
2430
|
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
2317
|
-
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
2431
|
+
maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
|
|
2318
2432
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
2319
2433
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
2320
2434
|
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
2321
2435
|
timeout: TimeoutSchema
|
|
2322
2436
|
},
|
|
2437
|
+
READ_ONLY_REMOTE,
|
|
2323
2438
|
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
2324
2439
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2325
2440
|
return connectionPool.withConnection(
|
|
@@ -2346,6 +2461,7 @@ ${files.join("\n")}` }] };
|
|
|
2346
2461
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
2347
2462
|
timeout: TimeoutSchema
|
|
2348
2463
|
},
|
|
2464
|
+
READ_ONLY_REMOTE,
|
|
2349
2465
|
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
2350
2466
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2351
2467
|
return connectionPool.withConnection(
|
|
@@ -2376,6 +2492,7 @@ ${files.join("\n")}` }] };
|
|
|
2376
2492
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
2377
2493
|
timeout: TimeoutSchema
|
|
2378
2494
|
},
|
|
2495
|
+
READ_ONLY_REMOTE,
|
|
2379
2496
|
async ({ service, timeout, ...conn }) => {
|
|
2380
2497
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2381
2498
|
return connectionPool.withConnection(
|
|
@@ -2400,11 +2517,25 @@ ${files.join("\n")}` }] };
|
|
|
2400
2517
|
|
|
2401
2518
|
// src/server.ts
|
|
2402
2519
|
var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
2520
|
+
var SERVER_INSTRUCTIONS = [
|
|
2521
|
+
"ssh-mcp runs commands and moves files on remote hosts over SSH, using ~/.ssh/config, the ssh-agent and known_hosts the way the OpenSSH client does.",
|
|
2522
|
+
"",
|
|
2523
|
+
"- After an auth, connect or host-key failure, run ssh_diagnose on that host before retrying. ssh_test is the quick check; ssh_key_list and ssh_key_load fix a key missing from the agent; ssh_known_hosts_fix replaces a stale host key.",
|
|
2524
|
+
"- Prefer the purpose-built tools over ssh_exec: ssh_read_file, ssh_ls, ssh_stat, ssh_find, ssh_tail, ssh_service_status. Use ssh_multi_exec for the same command on several hosts.",
|
|
2525
|
+
"- ssh_exec returns the exit code first, then stderr, then stdout. Proxies may cut long results (Yaw MCP keeps about 100 KB), so filter on the remote (grep, head, tail) instead of printing everything. Page a large file with ssh_read_file offset and length.",
|
|
2526
|
+
"- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
|
|
2527
|
+
"- Host keys: a host already in known_hosts must match. An unknown host is trusted on every connection unless strict mode (SSH_MCP_STRICT_HOSTKEYS=1) is on; add it with ssh_known_hosts_fix first.",
|
|
2528
|
+
"- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
|
|
2529
|
+
"- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
|
|
2530
|
+
].join("\n");
|
|
2403
2531
|
function createServer(pool) {
|
|
2404
|
-
const server = new McpServer(
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2532
|
+
const server = new McpServer(
|
|
2533
|
+
{
|
|
2534
|
+
name: "ssh-mcp",
|
|
2535
|
+
version
|
|
2536
|
+
},
|
|
2537
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
2538
|
+
);
|
|
2408
2539
|
registerTools(server, pool);
|
|
2409
2540
|
return server;
|
|
2410
2541
|
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -18,5 +18,5 @@ export interface PolicyContext {
|
|
|
18
18
|
* routed through here at all.
|
|
19
19
|
*/
|
|
20
20
|
export declare function enforcePolicy(command: string, context?: PolicyContext): void;
|
|
21
|
-
/** Returns true if any policy is currently configured.
|
|
21
|
+
/** Returns true if any policy is currently configured. Exported for library consumers and tests; tool descriptions use static "if configured" text instead. */
|
|
22
22
|
export declare function isPolicyConfigured(): boolean;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { ConnectionPool } from "./pool.js";
|
|
3
3
|
export declare const version: string;
|
|
4
|
+
/**
|
|
5
|
+
* Routing guidance sent once, at initialize. Yaw MCP shows it when this server is
|
|
6
|
+
* activated, capped at 2000 UTF-8 bytes, so it stays plain ASCII, well under that, and
|
|
7
|
+
* about these tools only (src/tests/server.test.ts pins both). It must not carry
|
|
8
|
+
* Yaw MCP's own framing markers, which the proxy strips or rejects.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
4
11
|
export declare function createServer(pool?: ConnectionPool): McpServer;
|
|
5
12
|
export type { DiagnosticReport, DiagnosticResult } from "./diagnose.js";
|
|
6
13
|
export { checkConnectivity, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, diagnose, } from "./diagnose.js";
|
|
7
|
-
export type { AgentResult, ConfigLookupResult, KeyInfo } from "./env.js";
|
|
8
|
-
export { checkGitSsh, configLookup, ensureAgent, fixKnownHosts, listSshKeys, loadKey, testConnection, } from "./env.js";
|
|
14
|
+
export type { AgentResult, ConfigLookupResult, KeyInfo, SshKeyListing } from "./env.js";
|
|
15
|
+
export { checkGitSsh, configLookup, ensureAgent, fixKnownHosts, listSshKeys, listSshKeysDetailed, loadKey, testConnection, } from "./env.js";
|
|
9
16
|
export type { FindOptions, MultiExecHost, MultiExecResult, ServiceStatus } from "./ops.js";
|
|
10
|
-
export { find, multiExec, serviceStatus, tail } from "./ops.js";
|
|
17
|
+
export { find, multiExec, serviceStatus, shellQuote, tail } from "./ops.js";
|
|
11
18
|
export type { PolicyContext } from "./policy.js";
|
|
12
19
|
export { enforcePolicy, isPolicyConfigured } from "./policy.js";
|
|
13
20
|
export type { AcquireOptions, PoolOptions } from "./pool.js";
|
|
14
21
|
export { ConnectionPool, isPoolFullError, POOL_FULL_ERROR_CODE, PoolFullError } from "./pool.js";
|
|
15
|
-
export type { ExecResult, FileStats, HostKeyRejection, HostKeyRejectionReason, ResolvedConfig, SSHConfig, } from "./ssh.js";
|
|
16
|
-
export { connect, connectRaw, connectWithProxy, deleteFile, downloadFile, exec, formatDiagnostics, listDir, makeDir, readFile, readKnownHostsKeys, resolveConfig, statFile, uploadFile, writeFile, } from "./ssh.js";
|
|
22
|
+
export type { ExecResult, FileRange, FileStats, HostKeyRejection, HostKeyRejectionReason, ResolvedConfig, SSHConfig, } from "./ssh.js";
|
|
23
|
+
export { connect, connectRaw, connectWithProxy, deleteFile, downloadFile, exec, formatDiagnostics, listDir, makeDir, readFile, readFileRange, readKnownHostsKeys, resolveConfig, statFile, uploadFile, writeFile, } from "./ssh.js";
|
|
17
24
|
export { registerTools } from "./tools.js";
|