@yawlabs/ssh-mcp 0.17.1 → 0.17.3
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 +7 -7
- package/bin/ssh-mcp.mjs +3 -3
- package/dist/index.js +59 -26
- package/dist/server.js +59 -26
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -326,23 +326,23 @@ for (const check of report.checks) {
|
|
|
326
326
|
|
|
327
327
|
## Runtime selection
|
|
328
328
|
|
|
329
|
-
The `ssh-mcp` command — what `npx @yawlabs/ssh-mcp` runs — is a small launcher. It prefers the [oam](https://oamjs.org) runtime and otherwise runs the server on Node, so oam is optional. Its minimum is the **latest oam release, currently 0.
|
|
329
|
+
The `ssh-mcp` command — what `npx @yawlabs/ssh-mcp` runs — is a small launcher. It prefers the [oam](https://oamjs.org) runtime and otherwise runs the server on Node, so oam is optional. Its minimum is the **latest oam release, currently 0.18.0**, and it never serves on an older oam.
|
|
330
330
|
|
|
331
331
|
Two environment variables control the choice; set them in your MCP client's `env` block:
|
|
332
332
|
|
|
333
333
|
- `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
|
|
334
|
-
- `auto` (default) — use the newest oam it can find at 0.
|
|
334
|
+
- `auto` (default) — use the newest oam it can find at 0.18.0 or newer, otherwise run on Node.
|
|
335
335
|
- `oam` — require oam. Fails loudly: if no usable oam is found, the launcher prints what it found and why each one was passed over to stderr, and exits with status 1 instead of starting the server.
|
|
336
336
|
- `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
|
|
337
|
-
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.
|
|
337
|
+
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.18.0 or newer. If it does not exist, is older, or will not run, the launcher says so on stderr and carries on with discovery.
|
|
338
338
|
|
|
339
|
-
Discovery looks in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.
|
|
339
|
+
Discovery looks in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.18.0 or newer. On a tie the one found first wins, so an installed copy beats one on `PATH`. On Windows only `oam.exe` counts: an `oam.cmd`/`oam.bat` shim on `PATH` is never run, and is named on stderr when no usable oam is found.
|
|
340
340
|
|
|
341
|
-
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.
|
|
341
|
+
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.18.0, not runnable, or a shim. A chosen oam that then fails to start is named on stderr too. When an older oam starts the launcher, the handoff to Node is always noted on stderr (see below). With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
|
|
342
342
|
|
|
343
343
|
When the launcher is itself started by oam (`oam run <path>/bin/ssh-mcp.mjs`, which is how Yaw MCP starts an `npx @yawlabs/ssh-mcp` entry when a recent oam is installed):
|
|
344
344
|
|
|
345
|
-
- On oam 0.
|
|
345
|
+
- On oam 0.18.0 or newer it runs the server on that oam, in the same process. Nothing is discovered or spawned, `OAM_BIN` is not read, and `SSH_MCP_RUNTIME=oam` counts it as the oam it requires.
|
|
346
346
|
- On an older oam it never runs the server there. It hands the server off to the newest usable oam it can find, or to Node on `PATH`, or exits with status 1 when there is neither (`SSH_MCP_RUNTIME=oam` exits with status 1 rather than handing off to Node).
|
|
347
347
|
- With `SSH_MCP_RUNTIME=node` it hands the server off to Node on `PATH`, whatever the oam version.
|
|
348
348
|
|
|
@@ -366,7 +366,7 @@ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#qu
|
|
|
366
366
|
|
|
367
367
|
- Node.js 18+
|
|
368
368
|
- SSH client installed (for diagnostics and environment management)
|
|
369
|
-
- Optional: [oam](https://oamjs.org) 0.
|
|
369
|
+
- Optional: [oam](https://oamjs.org) 0.18.0+ — see [Runtime selection](#runtime-selection)
|
|
370
370
|
|
|
371
371
|
## License
|
|
372
372
|
|
package/bin/ssh-mcp.mjs
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* named on stderr, and discovery carries on. It used to stop everything: a typo
|
|
41
41
|
* in OAM_BIN meant Node, with no hint why. The discovered binaries that were
|
|
42
42
|
* passed over, and any .cmd/.bat shim on PATH, are named only when NO usable
|
|
43
|
-
* oam is found -- with 0.9.0 installed and 0.
|
|
43
|
+
* oam is found -- with 0.9.0 installed and 0.18.0 on PATH, stderr stays empty.
|
|
44
44
|
*
|
|
45
45
|
* ALREADY RUNNING ON OAM
|
|
46
46
|
* A host can resolve this package's `bin` and launch `oam run <this file>`
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
* wired up here.
|
|
78
78
|
*
|
|
79
79
|
* MINIMUM OAM VERSION
|
|
80
|
-
* The latest oam release, 0.
|
|
80
|
+
* The latest oam release, 0.18.0 -- bump OAM_MIN when oam ships a newer one.
|
|
81
81
|
* Only the current oam is used and verified; an older one is passed over.
|
|
82
82
|
* The floor is not cosmetic: before 0.9.0 `child_process.execFile` ran its
|
|
83
83
|
* arguments through a SHELL, `exec` accepted `timeout` and ignored it,
|
|
@@ -104,7 +104,7 @@ import { delimiter, join } from "node:path";
|
|
|
104
104
|
import { fileURLToPath } from "node:url";
|
|
105
105
|
|
|
106
106
|
/** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
|
|
107
|
-
const OAM_MIN = [0,
|
|
107
|
+
const OAM_MIN = [0, 18, 0];
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
|
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
|
|
|
@@ -1083,7 +1092,7 @@ function ensureAgent() {
|
|
|
1083
1092
|
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
1084
1093
|
};
|
|
1085
1094
|
}
|
|
1086
|
-
function detectKeyType(filePath, fileName) {
|
|
1095
|
+
function detectKeyType(filePath, fileName, content) {
|
|
1087
1096
|
const pubPath = `${filePath}.pub`;
|
|
1088
1097
|
if (existsSync2(pubPath)) {
|
|
1089
1098
|
try {
|
|
@@ -1100,7 +1109,6 @@ function detectKeyType(filePath, fileName) {
|
|
|
1100
1109
|
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
1101
1110
|
if (fileName.includes("dsa")) return "dsa";
|
|
1102
1111
|
try {
|
|
1103
|
-
const content = readFileSync3(filePath, "utf8");
|
|
1104
1112
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
1105
1113
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
1106
1114
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
@@ -1136,12 +1144,13 @@ function listSshKeysDetailed() {
|
|
|
1136
1144
|
for (const file of files) {
|
|
1137
1145
|
if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
|
|
1138
1146
|
const filePath = join3(sshDir, file);
|
|
1147
|
+
let fd;
|
|
1139
1148
|
try {
|
|
1140
|
-
|
|
1141
|
-
if (!
|
|
1142
|
-
const content = readFileSync3(
|
|
1149
|
+
fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
|
|
1150
|
+
if (!fstatSync(fd).isFile()) continue;
|
|
1151
|
+
const content = readFileSync3(fd, "utf8");
|
|
1143
1152
|
if (!content.includes("PRIVATE KEY")) continue;
|
|
1144
|
-
const type = detectKeyType(filePath, file);
|
|
1153
|
+
const type = detectKeyType(filePath, file, content);
|
|
1145
1154
|
let fingerprint;
|
|
1146
1155
|
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
1147
1156
|
if (fpOk) {
|
|
@@ -1151,6 +1160,8 @@ function listSshKeysDetailed() {
|
|
|
1151
1160
|
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
1152
1161
|
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
1153
1162
|
} catch {
|
|
1163
|
+
} finally {
|
|
1164
|
+
if (fd !== void 0) closeSync(fd);
|
|
1154
1165
|
}
|
|
1155
1166
|
}
|
|
1156
1167
|
return { status: "ok", dir: sshDir, keys };
|
|
@@ -1306,7 +1317,7 @@ function testConnection(host, port = 22) {
|
|
|
1306
1317
|
}
|
|
1307
1318
|
|
|
1308
1319
|
// src/pool.ts
|
|
1309
|
-
import {
|
|
1320
|
+
import { createHmac, randomBytes, scryptSync } from "crypto";
|
|
1310
1321
|
var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
|
|
1311
1322
|
var PoolFullError = class extends Error {
|
|
1312
1323
|
code = POOL_FULL_ERROR_CODE;
|
|
@@ -1336,21 +1347,24 @@ function defaultMaxPoolSize() {
|
|
|
1336
1347
|
const parsed = Number.parseInt(raw, 10);
|
|
1337
1348
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1338
1349
|
}
|
|
1350
|
+
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
|
1351
|
+
var PASSWORD_SCRYPT_COST = 1024;
|
|
1352
|
+
function updateField(h, tag, value) {
|
|
1353
|
+
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
1354
|
+
const len = Buffer.alloc(4);
|
|
1355
|
+
len.writeUInt32BE(bytes.length);
|
|
1356
|
+
h.update(tag);
|
|
1357
|
+
h.update(len);
|
|
1358
|
+
h.update(bytes);
|
|
1359
|
+
}
|
|
1339
1360
|
function authFingerprint(cc) {
|
|
1340
|
-
const h =
|
|
1341
|
-
if (cc.privateKey)
|
|
1342
|
-
h.update("k");
|
|
1343
|
-
h.update(cc.privateKey);
|
|
1344
|
-
}
|
|
1361
|
+
const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
|
|
1362
|
+
if (cc.privateKey) updateField(h, "k", cc.privateKey);
|
|
1345
1363
|
if (cc.password !== void 0) {
|
|
1346
|
-
h
|
|
1347
|
-
h.update(cc.password);
|
|
1348
|
-
}
|
|
1349
|
-
if (typeof cc.agent === "string") {
|
|
1350
|
-
h.update("a");
|
|
1351
|
-
h.update(cc.agent);
|
|
1364
|
+
updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
|
|
1352
1365
|
}
|
|
1353
|
-
|
|
1366
|
+
if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
|
|
1367
|
+
return h.digest("hex").slice(0, 32);
|
|
1354
1368
|
}
|
|
1355
1369
|
function resolveOrDiagnose(config) {
|
|
1356
1370
|
try {
|
|
@@ -1510,7 +1524,7 @@ var ConnectionPool = class {
|
|
|
1510
1524
|
return client;
|
|
1511
1525
|
}
|
|
1512
1526
|
throw new Error(
|
|
1513
|
-
`Failed to acquire SSH connection for ${
|
|
1527
|
+
`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
1528
|
);
|
|
1515
1529
|
}
|
|
1516
1530
|
release(client) {
|
|
@@ -1743,7 +1757,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1743
1757
|
}
|
|
1744
1758
|
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1745
1759
|
var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
|
|
1760
|
+
var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
|
|
1761
|
+
function isNonNegativeInteger(n) {
|
|
1762
|
+
return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
1763
|
+
}
|
|
1746
1764
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1765
|
+
if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
|
|
1766
|
+
throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
|
|
1767
|
+
}
|
|
1768
|
+
if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
|
|
1769
|
+
throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
|
|
1770
|
+
}
|
|
1771
|
+
if (options.minsize !== void 0 && typeof options.minsize !== "string") {
|
|
1772
|
+
throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
|
|
1773
|
+
}
|
|
1774
|
+
if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
|
|
1775
|
+
throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
|
|
1776
|
+
}
|
|
1747
1777
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1748
1778
|
throw new Error(
|
|
1749
1779
|
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
|
|
@@ -1756,11 +1786,11 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1756
1786
|
}
|
|
1757
1787
|
const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
|
|
1758
1788
|
const args = [shellQuote(pathOperand)];
|
|
1759
|
-
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1760
|
-
if (options.type) args.push("-type", options.type);
|
|
1789
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
|
|
1790
|
+
if (options.type) args.push("-type", shellQuote(options.type));
|
|
1761
1791
|
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}`);
|
|
1792
|
+
if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
|
|
1793
|
+
if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
|
|
1764
1794
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1765
1795
|
const command = `find ${args.join(" ")}`;
|
|
1766
1796
|
const result = await exec(client, command, timeoutMs);
|
|
@@ -1770,6 +1800,9 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1770
1800
|
return result.stdout.split("\n").filter(Boolean);
|
|
1771
1801
|
}
|
|
1772
1802
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
1803
|
+
if (!isNonNegativeInteger(lines) || lines === 0) {
|
|
1804
|
+
throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
|
|
1805
|
+
}
|
|
1773
1806
|
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
1774
1807
|
if (grep) {
|
|
1775
1808
|
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
@@ -2314,7 +2347,7 @@ ${result.stderr}`);
|
|
|
2314
2347
|
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
2315
2348
|
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
2316
2349
|
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"),
|
|
2350
|
+
maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
|
|
2318
2351
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
2319
2352
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
2320
2353
|
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
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
|
|
|
@@ -1092,7 +1101,7 @@ function ensureAgent() {
|
|
|
1092
1101
|
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
1093
1102
|
};
|
|
1094
1103
|
}
|
|
1095
|
-
function detectKeyType(filePath, fileName) {
|
|
1104
|
+
function detectKeyType(filePath, fileName, content) {
|
|
1096
1105
|
const pubPath = `${filePath}.pub`;
|
|
1097
1106
|
if (existsSync2(pubPath)) {
|
|
1098
1107
|
try {
|
|
@@ -1109,7 +1118,6 @@ function detectKeyType(filePath, fileName) {
|
|
|
1109
1118
|
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
1110
1119
|
if (fileName.includes("dsa")) return "dsa";
|
|
1111
1120
|
try {
|
|
1112
|
-
const content = readFileSync3(filePath, "utf8");
|
|
1113
1121
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
1114
1122
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
1115
1123
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
@@ -1148,12 +1156,13 @@ function listSshKeysDetailed() {
|
|
|
1148
1156
|
for (const file of files) {
|
|
1149
1157
|
if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
|
|
1150
1158
|
const filePath = join3(sshDir, file);
|
|
1159
|
+
let fd;
|
|
1151
1160
|
try {
|
|
1152
|
-
|
|
1153
|
-
if (!
|
|
1154
|
-
const content = readFileSync3(
|
|
1161
|
+
fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
|
|
1162
|
+
if (!fstatSync(fd).isFile()) continue;
|
|
1163
|
+
const content = readFileSync3(fd, "utf8");
|
|
1155
1164
|
if (!content.includes("PRIVATE KEY")) continue;
|
|
1156
|
-
const type = detectKeyType(filePath, file);
|
|
1165
|
+
const type = detectKeyType(filePath, file, content);
|
|
1157
1166
|
let fingerprint;
|
|
1158
1167
|
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
1159
1168
|
if (fpOk) {
|
|
@@ -1163,6 +1172,8 @@ function listSshKeysDetailed() {
|
|
|
1163
1172
|
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
1164
1173
|
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
1165
1174
|
} catch {
|
|
1175
|
+
} finally {
|
|
1176
|
+
if (fd !== void 0) closeSync(fd);
|
|
1166
1177
|
}
|
|
1167
1178
|
}
|
|
1168
1179
|
return { status: "ok", dir: sshDir, keys };
|
|
@@ -1318,7 +1329,7 @@ function testConnection(host, port = 22) {
|
|
|
1318
1329
|
}
|
|
1319
1330
|
|
|
1320
1331
|
// src/pool.ts
|
|
1321
|
-
import {
|
|
1332
|
+
import { createHmac, randomBytes, scryptSync } from "crypto";
|
|
1322
1333
|
var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
|
|
1323
1334
|
var PoolFullError = class extends Error {
|
|
1324
1335
|
code = POOL_FULL_ERROR_CODE;
|
|
@@ -1348,21 +1359,24 @@ function defaultMaxPoolSize() {
|
|
|
1348
1359
|
const parsed = Number.parseInt(raw, 10);
|
|
1349
1360
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1350
1361
|
}
|
|
1362
|
+
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
|
1363
|
+
var PASSWORD_SCRYPT_COST = 1024;
|
|
1364
|
+
function updateField(h, tag, value) {
|
|
1365
|
+
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
1366
|
+
const len = Buffer.alloc(4);
|
|
1367
|
+
len.writeUInt32BE(bytes.length);
|
|
1368
|
+
h.update(tag);
|
|
1369
|
+
h.update(len);
|
|
1370
|
+
h.update(bytes);
|
|
1371
|
+
}
|
|
1351
1372
|
function authFingerprint(cc) {
|
|
1352
|
-
const h =
|
|
1353
|
-
if (cc.privateKey)
|
|
1354
|
-
h.update("k");
|
|
1355
|
-
h.update(cc.privateKey);
|
|
1356
|
-
}
|
|
1373
|
+
const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
|
|
1374
|
+
if (cc.privateKey) updateField(h, "k", cc.privateKey);
|
|
1357
1375
|
if (cc.password !== void 0) {
|
|
1358
|
-
h
|
|
1359
|
-
h.update(cc.password);
|
|
1360
|
-
}
|
|
1361
|
-
if (typeof cc.agent === "string") {
|
|
1362
|
-
h.update("a");
|
|
1363
|
-
h.update(cc.agent);
|
|
1376
|
+
updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
|
|
1364
1377
|
}
|
|
1365
|
-
|
|
1378
|
+
if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
|
|
1379
|
+
return h.digest("hex").slice(0, 32);
|
|
1366
1380
|
}
|
|
1367
1381
|
function resolveOrDiagnose(config) {
|
|
1368
1382
|
try {
|
|
@@ -1522,7 +1536,7 @@ var ConnectionPool = class {
|
|
|
1522
1536
|
return client;
|
|
1523
1537
|
}
|
|
1524
1538
|
throw new Error(
|
|
1525
|
-
`Failed to acquire SSH connection for ${
|
|
1539
|
+
`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
1540
|
);
|
|
1527
1541
|
}
|
|
1528
1542
|
release(client) {
|
|
@@ -1746,7 +1760,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1746
1760
|
}
|
|
1747
1761
|
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1748
1762
|
var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
|
|
1763
|
+
var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
|
|
1764
|
+
function isNonNegativeInteger(n) {
|
|
1765
|
+
return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
1766
|
+
}
|
|
1749
1767
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1768
|
+
if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
|
|
1769
|
+
throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
|
|
1770
|
+
}
|
|
1771
|
+
if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
|
|
1772
|
+
throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
|
|
1773
|
+
}
|
|
1774
|
+
if (options.minsize !== void 0 && typeof options.minsize !== "string") {
|
|
1775
|
+
throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
|
|
1776
|
+
}
|
|
1777
|
+
if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
|
|
1778
|
+
throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
|
|
1779
|
+
}
|
|
1750
1780
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1751
1781
|
throw new Error(
|
|
1752
1782
|
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
|
|
@@ -1759,11 +1789,11 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1759
1789
|
}
|
|
1760
1790
|
const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
|
|
1761
1791
|
const args = [shellQuote(pathOperand)];
|
|
1762
|
-
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1763
|
-
if (options.type) args.push("-type", options.type);
|
|
1792
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
|
|
1793
|
+
if (options.type) args.push("-type", shellQuote(options.type));
|
|
1764
1794
|
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}`);
|
|
1795
|
+
if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
|
|
1796
|
+
if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
|
|
1767
1797
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1768
1798
|
const command = `find ${args.join(" ")}`;
|
|
1769
1799
|
const result = await exec(client, command, timeoutMs);
|
|
@@ -1773,6 +1803,9 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1773
1803
|
return result.stdout.split("\n").filter(Boolean);
|
|
1774
1804
|
}
|
|
1775
1805
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
1806
|
+
if (!isNonNegativeInteger(lines) || lines === 0) {
|
|
1807
|
+
throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
|
|
1808
|
+
}
|
|
1776
1809
|
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
1777
1810
|
if (grep) {
|
|
1778
1811
|
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
@@ -2320,7 +2353,7 @@ ${result.stderr}`);
|
|
|
2320
2353
|
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
2321
2354
|
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
2322
2355
|
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"),
|
|
2356
|
+
maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
|
|
2324
2357
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
2325
2358
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
2326
2359
|
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
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",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"node": ">=18"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
66
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
67
67
|
"ssh2": "^1.17.0",
|
|
68
68
|
"zod": "^4.4.3"
|
|
69
69
|
},
|