@yawlabs/ssh-mcp 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +36 -17
- package/dist/server.d.ts +13 -1
- package/dist/server.js +36 -17
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -202,8 +202,9 @@ function checkSshConfig(host) {
|
|
|
202
202
|
inHostBlock = patterns.some((p) => {
|
|
203
203
|
if (p === "*") return true;
|
|
204
204
|
if (p === host) return true;
|
|
205
|
-
if (p.includes("*")) {
|
|
206
|
-
const
|
|
205
|
+
if (p.includes("*") || p.includes("?")) {
|
|
206
|
+
const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
|
|
207
|
+
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
207
208
|
return regex.test(host);
|
|
208
209
|
}
|
|
209
210
|
return false;
|
|
@@ -339,7 +340,7 @@ function ensureAgent() {
|
|
|
339
340
|
const result = probeAgent(sock, "ssh-agent");
|
|
340
341
|
if (result) return result;
|
|
341
342
|
}
|
|
342
|
-
if (
|
|
343
|
+
if (process.platform === "win32") {
|
|
343
344
|
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
344
345
|
if (result) return result;
|
|
345
346
|
}
|
|
@@ -458,10 +459,10 @@ function loadKey(keyPath) {
|
|
|
458
459
|
if (ok) {
|
|
459
460
|
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
460
461
|
}
|
|
461
|
-
if (stdout.includes("
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
462
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
|
|
463
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
464
|
+
}
|
|
465
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
|
|
465
466
|
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
466
467
|
}
|
|
467
468
|
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
@@ -749,10 +750,16 @@ async function connectWithProxy(resolved) {
|
|
|
749
750
|
const jumpClient = await connectWithProxy(jumpResolved);
|
|
750
751
|
const targetHost = resolved.connectConfig.host;
|
|
751
752
|
const targetPort = resolved.connectConfig.port;
|
|
753
|
+
const endJump = () => {
|
|
754
|
+
try {
|
|
755
|
+
jumpClient.end();
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
};
|
|
752
759
|
const stream = await new Promise((resolve, reject) => {
|
|
753
760
|
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
754
761
|
if (err) {
|
|
755
|
-
|
|
762
|
+
endJump();
|
|
756
763
|
return reject(err);
|
|
757
764
|
}
|
|
758
765
|
resolve(stream2);
|
|
@@ -761,10 +768,10 @@ async function connectWithProxy(resolved) {
|
|
|
761
768
|
return new Promise((resolve, reject) => {
|
|
762
769
|
const client = new Client();
|
|
763
770
|
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
764
|
-
|
|
771
|
+
endJump();
|
|
765
772
|
reject(err);
|
|
766
773
|
}).on("close", () => {
|
|
767
|
-
|
|
774
|
+
endJump();
|
|
768
775
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
769
776
|
});
|
|
770
777
|
}
|
|
@@ -832,14 +839,19 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
832
839
|
stderrTruncated = true;
|
|
833
840
|
}
|
|
834
841
|
};
|
|
835
|
-
stream.on("close", (code) => {
|
|
842
|
+
stream.on("close", (code, signal) => {
|
|
836
843
|
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
837
844
|
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
838
845
|
if (stdoutTruncated) stdout += `
|
|
839
846
|
[output truncated at ${maxBytes} bytes]`;
|
|
840
847
|
if (stderrTruncated) stderr += `
|
|
841
848
|
[stderr truncated at ${maxBytes} bytes]`;
|
|
842
|
-
|
|
849
|
+
const exitCode = typeof code === "number" ? code : -1;
|
|
850
|
+
const result = { stdout, stderr, code: exitCode };
|
|
851
|
+
if (stdoutTruncated) result.stdoutTruncated = true;
|
|
852
|
+
if (stderrTruncated) result.stderrTruncated = true;
|
|
853
|
+
if (signal) result.signal = signal;
|
|
854
|
+
settle(() => resolve(result));
|
|
843
855
|
}).on("data", appendStdout).on("error", (err2) => {
|
|
844
856
|
settle(() => reject(err2));
|
|
845
857
|
});
|
|
@@ -936,6 +948,12 @@ async function listDir(client, remotePath) {
|
|
|
936
948
|
}
|
|
937
949
|
|
|
938
950
|
// src/pool.ts
|
|
951
|
+
function defaultMaxPoolSize() {
|
|
952
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
953
|
+
if (!raw) return 100;
|
|
954
|
+
const parsed = Number.parseInt(raw, 10);
|
|
955
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
956
|
+
}
|
|
939
957
|
var ConnectionPool = class {
|
|
940
958
|
entries = /* @__PURE__ */ new Map();
|
|
941
959
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
@@ -952,7 +970,7 @@ var ConnectionPool = class {
|
|
|
952
970
|
drained = false;
|
|
953
971
|
constructor(options) {
|
|
954
972
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
955
|
-
this.maxPoolSize = options?.maxPoolSize ??
|
|
973
|
+
this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
|
|
956
974
|
}
|
|
957
975
|
async acquire(config) {
|
|
958
976
|
const resolved = resolveConfig(config);
|
|
@@ -1523,11 +1541,12 @@ ${result.stderr}`);
|
|
|
1523
1541
|
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1524
1542
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1525
1543
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1544
|
+
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
1526
1545
|
timeout: TimeoutSchema
|
|
1527
1546
|
},
|
|
1528
|
-
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1547
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
1529
1548
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1530
|
-
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1549
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
|
|
1531
1550
|
if (files.length === 0) {
|
|
1532
1551
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
1533
1552
|
}
|
|
@@ -1542,7 +1561,7 @@ ${files.join("\n")}` }] };
|
|
|
1542
1561
|
{
|
|
1543
1562
|
...connectionParams,
|
|
1544
1563
|
path: z.string().describe("Absolute path to the file to tail"),
|
|
1545
|
-
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1564
|
+
lines: z.number().int().positive().optional().describe("Number of lines to read from the end (default: 100). Must be a positive integer."),
|
|
1546
1565
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1547
1566
|
timeout: TimeoutSchema
|
|
1548
1567
|
},
|
|
@@ -1582,7 +1601,7 @@ ${files.join("\n")}` }] };
|
|
|
1582
1601
|
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1583
1602
|
lines.push("");
|
|
1584
1603
|
lines.push(status.raw);
|
|
1585
|
-
return { content: [{ type: "text", text: lines.join("\n") }]
|
|
1604
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1586
1605
|
});
|
|
1587
1606
|
}
|
|
1588
1607
|
);
|
package/dist/server.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ interface ExecResult {
|
|
|
13
13
|
stdout: string;
|
|
14
14
|
stderr: string;
|
|
15
15
|
code: number;
|
|
16
|
+
/** True when stdout was truncated at the byte cap. */
|
|
17
|
+
stdoutTruncated?: boolean;
|
|
18
|
+
/** True when stderr was truncated at the byte cap. */
|
|
19
|
+
stderrTruncated?: boolean;
|
|
20
|
+
/** Signal name (e.g. "TERM") if the remote channel closed via signal instead of exit. */
|
|
21
|
+
signal?: string;
|
|
16
22
|
}
|
|
17
23
|
interface ResolvedConfig {
|
|
18
24
|
connectConfig: ConnectConfig;
|
|
@@ -34,7 +40,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
|
34
40
|
interface PoolOptions {
|
|
35
41
|
/** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
|
|
36
42
|
idleTtlMs?: number;
|
|
37
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Maximum number of connections in the pool. Default: 100, overridable via the
|
|
45
|
+
* `SSH_MCP_MAX_POOL_SIZE` env var. When at capacity, the pool first tries to evict
|
|
46
|
+
* an idle entry; if every entry is in use, `acquire()` rejects with
|
|
47
|
+
* "Connection pool is full". Bump this for fan-out workloads against many distinct
|
|
48
|
+
* hosts (e.g. `ssh_multi_exec` across a large fleet).
|
|
49
|
+
*/
|
|
38
50
|
maxPoolSize?: number;
|
|
39
51
|
}
|
|
40
52
|
declare class ConnectionPool {
|
package/dist/server.js
CHANGED
|
@@ -200,8 +200,9 @@ function checkSshConfig(host) {
|
|
|
200
200
|
inHostBlock = patterns.some((p) => {
|
|
201
201
|
if (p === "*") return true;
|
|
202
202
|
if (p === host) return true;
|
|
203
|
-
if (p.includes("*")) {
|
|
204
|
-
const
|
|
203
|
+
if (p.includes("*") || p.includes("?")) {
|
|
204
|
+
const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
|
|
205
|
+
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
205
206
|
return regex.test(host);
|
|
206
207
|
}
|
|
207
208
|
return false;
|
|
@@ -335,7 +336,7 @@ function ensureAgent() {
|
|
|
335
336
|
const result = probeAgent(sock, "ssh-agent");
|
|
336
337
|
if (result) return result;
|
|
337
338
|
}
|
|
338
|
-
if (
|
|
339
|
+
if (process.platform === "win32") {
|
|
339
340
|
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
340
341
|
if (result) return result;
|
|
341
342
|
}
|
|
@@ -454,10 +455,10 @@ function loadKey(keyPath) {
|
|
|
454
455
|
if (ok) {
|
|
455
456
|
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
456
457
|
}
|
|
457
|
-
if (stdout.includes("
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
458
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
|
|
459
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
460
|
+
}
|
|
461
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
|
|
461
462
|
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
462
463
|
}
|
|
463
464
|
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
@@ -745,10 +746,16 @@ async function connectWithProxy(resolved) {
|
|
|
745
746
|
const jumpClient = await connectWithProxy(jumpResolved);
|
|
746
747
|
const targetHost = resolved.connectConfig.host;
|
|
747
748
|
const targetPort = resolved.connectConfig.port;
|
|
749
|
+
const endJump = () => {
|
|
750
|
+
try {
|
|
751
|
+
jumpClient.end();
|
|
752
|
+
} catch {
|
|
753
|
+
}
|
|
754
|
+
};
|
|
748
755
|
const stream = await new Promise((resolve, reject) => {
|
|
749
756
|
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
750
757
|
if (err) {
|
|
751
|
-
|
|
758
|
+
endJump();
|
|
752
759
|
return reject(err);
|
|
753
760
|
}
|
|
754
761
|
resolve(stream2);
|
|
@@ -757,10 +764,10 @@ async function connectWithProxy(resolved) {
|
|
|
757
764
|
return new Promise((resolve, reject) => {
|
|
758
765
|
const client = new Client();
|
|
759
766
|
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
760
|
-
|
|
767
|
+
endJump();
|
|
761
768
|
reject(err);
|
|
762
769
|
}).on("close", () => {
|
|
763
|
-
|
|
770
|
+
endJump();
|
|
764
771
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
765
772
|
});
|
|
766
773
|
}
|
|
@@ -846,14 +853,19 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
846
853
|
stderrTruncated = true;
|
|
847
854
|
}
|
|
848
855
|
};
|
|
849
|
-
stream.on("close", (code) => {
|
|
856
|
+
stream.on("close", (code, signal) => {
|
|
850
857
|
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
851
858
|
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
852
859
|
if (stdoutTruncated) stdout += `
|
|
853
860
|
[output truncated at ${maxBytes} bytes]`;
|
|
854
861
|
if (stderrTruncated) stderr += `
|
|
855
862
|
[stderr truncated at ${maxBytes} bytes]`;
|
|
856
|
-
|
|
863
|
+
const exitCode = typeof code === "number" ? code : -1;
|
|
864
|
+
const result = { stdout, stderr, code: exitCode };
|
|
865
|
+
if (stdoutTruncated) result.stdoutTruncated = true;
|
|
866
|
+
if (stderrTruncated) result.stderrTruncated = true;
|
|
867
|
+
if (signal) result.signal = signal;
|
|
868
|
+
settle(() => resolve(result));
|
|
857
869
|
}).on("data", appendStdout).on("error", (err2) => {
|
|
858
870
|
settle(() => reject(err2));
|
|
859
871
|
});
|
|
@@ -1032,6 +1044,12 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1032
1044
|
}
|
|
1033
1045
|
|
|
1034
1046
|
// src/pool.ts
|
|
1047
|
+
function defaultMaxPoolSize() {
|
|
1048
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1049
|
+
if (!raw) return 100;
|
|
1050
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1051
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1052
|
+
}
|
|
1035
1053
|
var ConnectionPool = class {
|
|
1036
1054
|
entries = /* @__PURE__ */ new Map();
|
|
1037
1055
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
@@ -1048,7 +1066,7 @@ var ConnectionPool = class {
|
|
|
1048
1066
|
drained = false;
|
|
1049
1067
|
constructor(options) {
|
|
1050
1068
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
1051
|
-
this.maxPoolSize = options?.maxPoolSize ??
|
|
1069
|
+
this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
|
|
1052
1070
|
}
|
|
1053
1071
|
async acquire(config) {
|
|
1054
1072
|
const resolved = resolveConfig(config);
|
|
@@ -1528,11 +1546,12 @@ ${result.stderr}`);
|
|
|
1528
1546
|
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1529
1547
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1530
1548
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1549
|
+
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
1531
1550
|
timeout: TimeoutSchema
|
|
1532
1551
|
},
|
|
1533
|
-
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1552
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
1534
1553
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1535
|
-
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1554
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
|
|
1536
1555
|
if (files.length === 0) {
|
|
1537
1556
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
1538
1557
|
}
|
|
@@ -1547,7 +1566,7 @@ ${files.join("\n")}` }] };
|
|
|
1547
1566
|
{
|
|
1548
1567
|
...connectionParams,
|
|
1549
1568
|
path: z.string().describe("Absolute path to the file to tail"),
|
|
1550
|
-
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1569
|
+
lines: z.number().int().positive().optional().describe("Number of lines to read from the end (default: 100). Must be a positive integer."),
|
|
1551
1570
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1552
1571
|
timeout: TimeoutSchema
|
|
1553
1572
|
},
|
|
@@ -1587,7 +1606,7 @@ ${files.join("\n")}` }] };
|
|
|
1587
1606
|
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1588
1607
|
lines.push("");
|
|
1589
1608
|
lines.push(status.raw);
|
|
1590
|
-
return { content: [{ type: "text", text: lines.join("\n") }]
|
|
1609
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1591
1610
|
});
|
|
1592
1611
|
}
|
|
1593
1612
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
-
"ssh2": "^1.
|
|
51
|
-
"zod": "^4.3
|
|
50
|
+
"ssh2": "^1.17.0",
|
|
51
|
+
"zod": "^4.4.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@biomejs/biome": "^2.4.
|
|
55
|
-
"@types/node": "^25.
|
|
56
|
-
"@types/ssh2": "^1.15.
|
|
54
|
+
"@biomejs/biome": "^2.4.15",
|
|
55
|
+
"@types/node": "^25.7.0",
|
|
56
|
+
"@types/ssh2": "^1.15.5",
|
|
57
57
|
"tsup": "^8.5.1",
|
|
58
58
|
"typescript": "^6.0.3",
|
|
59
|
-
"vitest": "^4.1.
|
|
59
|
+
"vitest": "^4.1.6"
|
|
60
60
|
}
|
|
61
61
|
}
|