@yawlabs/ssh-mcp 0.4.0 → 0.6.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 +18 -1
- package/dist/index.js +313 -19
- package/dist/server.d.ts +50 -4
- package/dist/server.js +321 -21
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -73,6 +73,17 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
|
|
|
73
73
|
| `ssh_download` | Download a file from a remote host to local filesystem. |
|
|
74
74
|
| `ssh_ls` | List files in a directory on a remote host. |
|
|
75
75
|
|
|
76
|
+
### Higher-level operations
|
|
77
|
+
|
|
78
|
+
Tools that wrap common patterns agents build with ssh_exec — faster and less error-prone.
|
|
79
|
+
|
|
80
|
+
| Tool | Description |
|
|
81
|
+
|------|-------------|
|
|
82
|
+
| `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
|
|
83
|
+
| `ssh_find` | Search for files remotely with structured parameters (name, type, size, depth). |
|
|
84
|
+
| `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
|
|
85
|
+
| `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
|
|
86
|
+
|
|
76
87
|
### Auto-diagnostics
|
|
77
88
|
|
|
78
89
|
When any remote operation fails, ssh-mcp automatically runs diagnostics and includes the results in the error response. Your agent doesn't need to call `ssh_diagnose` separately — it gets told what's wrong and how to fix it right in the error message.
|
|
@@ -83,7 +94,13 @@ Remote operations reuse SSH connections automatically. When your agent makes mul
|
|
|
83
94
|
|
|
84
95
|
### SSH config support
|
|
85
96
|
|
|
86
|
-
All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames,
|
|
97
|
+
All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
|
|
98
|
+
|
|
99
|
+
**ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
|
|
100
|
+
|
|
101
|
+
### Windows support
|
|
102
|
+
|
|
103
|
+
On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
87
104
|
|
|
88
105
|
## Authentication
|
|
89
106
|
|
package/dist/index.js
CHANGED
|
@@ -15,21 +15,43 @@ import { existsSync, readFileSync, readdirSync } from "fs";
|
|
|
15
15
|
import { homedir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
function isValidHostname(host) {
|
|
18
|
-
|
|
18
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
19
|
+
if (host.startsWith("[")) {
|
|
20
|
+
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
21
|
+
}
|
|
22
|
+
return /^[a-zA-Z0-9._\-]+$/.test(host);
|
|
19
23
|
}
|
|
20
24
|
function runArgs(cmd, args) {
|
|
21
25
|
try {
|
|
22
26
|
const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
23
27
|
return { stdout: stdout.trim(), ok: true };
|
|
24
28
|
} catch (e) {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
29
|
+
const err = e;
|
|
30
|
+
const stdout = err.stdout?.toString().trim() || "";
|
|
31
|
+
const stderr = err.stderr?.toString().trim() || "";
|
|
32
|
+
const output = [stdout, stderr].filter(Boolean).join("\n") || err.message || "";
|
|
28
33
|
return { stdout: output, ok: false };
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
function checkSshAgent() {
|
|
32
37
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
38
|
+
if (!sock && process.platform === "win32") {
|
|
39
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
40
|
+
if (ok2) {
|
|
41
|
+
return { status: "ok", message: `Windows OpenSSH agent running with keys:
|
|
42
|
+
${stdout2}` };
|
|
43
|
+
}
|
|
44
|
+
if (stdout2.includes("no identities") || stdout2.includes("The agent has no identities")) {
|
|
45
|
+
return {
|
|
46
|
+
status: "warning",
|
|
47
|
+
message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status: "error",
|
|
52
|
+
message: "Windows OpenSSH Authentication Agent is not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
53
|
+
};
|
|
54
|
+
}
|
|
33
55
|
if (!sock) {
|
|
34
56
|
return {
|
|
35
57
|
status: "error",
|
|
@@ -92,6 +114,9 @@ function checkSshKeys() {
|
|
|
92
114
|
return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
|
|
93
115
|
}
|
|
94
116
|
function checkKnownHosts(host) {
|
|
117
|
+
if (!isValidHostname(host)) {
|
|
118
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
119
|
+
}
|
|
95
120
|
const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
|
|
96
121
|
if (!existsSync(knownHostsPath)) {
|
|
97
122
|
return {
|
|
@@ -99,9 +124,6 @@ function checkKnownHosts(host) {
|
|
|
99
124
|
message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
|
|
100
125
|
};
|
|
101
126
|
}
|
|
102
|
-
if (!isValidHostname(host)) {
|
|
103
|
-
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
104
|
-
}
|
|
105
127
|
const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
|
|
106
128
|
if (!ok || !stdout.trim()) {
|
|
107
129
|
return {
|
|
@@ -261,7 +283,8 @@ function resolveFromSshConfig(host) {
|
|
|
261
283
|
hostname: config.hostname || host,
|
|
262
284
|
user: config.user || "",
|
|
263
285
|
port: config.port || "22",
|
|
264
|
-
identityFiles
|
|
286
|
+
identityFiles,
|
|
287
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
265
288
|
};
|
|
266
289
|
} catch {
|
|
267
290
|
return null;
|
|
@@ -276,7 +299,7 @@ function resolveConfig(config) {
|
|
|
276
299
|
keepaliveInterval: 15e3,
|
|
277
300
|
keepaliveCountMax: 3
|
|
278
301
|
};
|
|
279
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
|
|
302
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
280
303
|
if (agentSock) {
|
|
281
304
|
connectConfig.agent = agentSock;
|
|
282
305
|
}
|
|
@@ -296,7 +319,7 @@ function resolveConfig(config) {
|
|
|
296
319
|
}
|
|
297
320
|
}
|
|
298
321
|
}
|
|
299
|
-
return connectConfig;
|
|
322
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
300
323
|
}
|
|
301
324
|
function formatDiagnostics(host) {
|
|
302
325
|
try {
|
|
@@ -334,6 +357,33 @@ function connectRaw(connectConfig) {
|
|
|
334
357
|
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
335
358
|
});
|
|
336
359
|
}
|
|
360
|
+
async function connectWithProxy(resolved) {
|
|
361
|
+
if (!resolved.proxyJump) {
|
|
362
|
+
return connectRaw(resolved.connectConfig);
|
|
363
|
+
}
|
|
364
|
+
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
365
|
+
const jumpClient = await connectWithProxy(jumpResolved);
|
|
366
|
+
const targetHost = resolved.connectConfig.host;
|
|
367
|
+
const targetPort = resolved.connectConfig.port;
|
|
368
|
+
const stream = await new Promise((resolve, reject) => {
|
|
369
|
+
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
370
|
+
if (err) {
|
|
371
|
+
jumpClient.end();
|
|
372
|
+
return reject(err);
|
|
373
|
+
}
|
|
374
|
+
resolve(stream2);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
const client = new Client();
|
|
379
|
+
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
380
|
+
jumpClient.end();
|
|
381
|
+
reject(err);
|
|
382
|
+
}).on("close", () => {
|
|
383
|
+
jumpClient.end();
|
|
384
|
+
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
385
|
+
});
|
|
386
|
+
}
|
|
337
387
|
function exec(client, command, timeoutMs = 3e4) {
|
|
338
388
|
return new Promise((resolve, reject) => {
|
|
339
389
|
let settled = false;
|
|
@@ -376,9 +426,21 @@ function getSftp(client) {
|
|
|
376
426
|
});
|
|
377
427
|
});
|
|
378
428
|
}
|
|
379
|
-
|
|
429
|
+
var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
430
|
+
async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
380
431
|
const sftp = await getSftp(client);
|
|
381
432
|
try {
|
|
433
|
+
const stats = await new Promise((resolve, reject) => {
|
|
434
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
435
|
+
if (err) return reject(err);
|
|
436
|
+
resolve(stats2);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
if (stats.size > maxBytes) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
382
444
|
return await new Promise((resolve, reject) => {
|
|
383
445
|
sftp.readFile(remotePath, (err, data) => {
|
|
384
446
|
if (err) return reject(err);
|
|
@@ -446,12 +508,15 @@ async function listDir(client, remotePath) {
|
|
|
446
508
|
var ConnectionPool = class {
|
|
447
509
|
entries = /* @__PURE__ */ new Map();
|
|
448
510
|
idleTtlMs;
|
|
511
|
+
maxPoolSize;
|
|
449
512
|
constructor(options) {
|
|
450
513
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
514
|
+
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
451
515
|
}
|
|
452
516
|
async acquire(config) {
|
|
453
|
-
const
|
|
454
|
-
const
|
|
517
|
+
const resolved = resolveConfig(config);
|
|
518
|
+
const cc = resolved.connectConfig;
|
|
519
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
455
520
|
const existing = this.entries.get(key);
|
|
456
521
|
if (existing && !existing.dead) {
|
|
457
522
|
existing.refCount++;
|
|
@@ -464,8 +529,26 @@ var ConnectionPool = class {
|
|
|
464
529
|
if (existing?.dead) {
|
|
465
530
|
this.entries.delete(key);
|
|
466
531
|
}
|
|
532
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
533
|
+
let evicted = false;
|
|
534
|
+
for (const [k, e] of this.entries) {
|
|
535
|
+
if (e.refCount === 0) {
|
|
536
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
537
|
+
try {
|
|
538
|
+
e.client.end();
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
541
|
+
this.entries.delete(k);
|
|
542
|
+
evicted = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!evicted) {
|
|
547
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
467
550
|
try {
|
|
468
|
-
const client = await
|
|
551
|
+
const client = await connectWithProxy(resolved);
|
|
469
552
|
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
470
553
|
const markDead = () => {
|
|
471
554
|
entry.dead = true;
|
|
@@ -579,6 +662,21 @@ function ensureAgent() {
|
|
|
579
662
|
};
|
|
580
663
|
}
|
|
581
664
|
}
|
|
665
|
+
if (!sock && process.platform === "win32") {
|
|
666
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
667
|
+
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
668
|
+
if (ok2 || noIdentities) {
|
|
669
|
+
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
670
|
+
return {
|
|
671
|
+
running: true,
|
|
672
|
+
reachable: true,
|
|
673
|
+
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
674
|
+
keys,
|
|
675
|
+
started: false,
|
|
676
|
+
message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
582
680
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
583
681
|
if (ok) {
|
|
584
682
|
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
@@ -602,7 +700,7 @@ function ensureAgent() {
|
|
|
602
700
|
reachable: false,
|
|
603
701
|
keys: [],
|
|
604
702
|
started: false,
|
|
605
|
-
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
703
|
+
message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
606
704
|
};
|
|
607
705
|
}
|
|
608
706
|
function detectKeyType(filePath, fileName) {
|
|
@@ -835,13 +933,91 @@ function testConnection(host, port = 22) {
|
|
|
835
933
|
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
836
934
|
}
|
|
837
935
|
|
|
936
|
+
// src/ops.ts
|
|
937
|
+
function shellQuote(s) {
|
|
938
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
939
|
+
}
|
|
940
|
+
async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
941
|
+
const results = await Promise.allSettled(
|
|
942
|
+
hosts.map(async (hostConfig) => {
|
|
943
|
+
return pool.withConnection(hostConfig, async (client) => {
|
|
944
|
+
const result = await exec(client, command, timeoutMs);
|
|
945
|
+
return { host: hostConfig.host, ...result };
|
|
946
|
+
});
|
|
947
|
+
})
|
|
948
|
+
);
|
|
949
|
+
return results.map((result, i) => {
|
|
950
|
+
if (result.status === "fulfilled") {
|
|
951
|
+
return result.value;
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
host: hosts[i].host,
|
|
955
|
+
stdout: "",
|
|
956
|
+
stderr: "",
|
|
957
|
+
code: -1,
|
|
958
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
959
|
+
};
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
|
|
963
|
+
async function find(client, options, timeoutMs = 3e4) {
|
|
964
|
+
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
965
|
+
throw new Error(
|
|
966
|
+
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
const args = [shellQuote(options.path)];
|
|
975
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
976
|
+
if (options.type) args.push("-type", options.type);
|
|
977
|
+
if (options.name) args.push("-name", shellQuote(options.name));
|
|
978
|
+
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
979
|
+
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
980
|
+
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
981
|
+
const command = `find ${args.join(" ")} 2>/dev/null`;
|
|
982
|
+
const result = await exec(client, command, timeoutMs);
|
|
983
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
984
|
+
}
|
|
985
|
+
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
986
|
+
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
987
|
+
if (grep) {
|
|
988
|
+
command += ` | grep -i ${shellQuote(grep)}`;
|
|
989
|
+
}
|
|
990
|
+
const result = await exec(client, command, timeoutMs);
|
|
991
|
+
if (result.code !== 0 && result.stderr && !grep) {
|
|
992
|
+
throw new Error(result.stderr.trim());
|
|
993
|
+
}
|
|
994
|
+
return result.stdout;
|
|
995
|
+
}
|
|
996
|
+
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
997
|
+
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
998
|
+
const raw = result.stdout;
|
|
999
|
+
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1000
|
+
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
1001
|
+
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1002
|
+
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1003
|
+
return {
|
|
1004
|
+
name: serviceName,
|
|
1005
|
+
active: activeMatch?.[1] === "active",
|
|
1006
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
|
|
1007
|
+
description: descMatch?.[1]?.trim(),
|
|
1008
|
+
since: sinceMatch?.[1]?.trim(),
|
|
1009
|
+
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
1010
|
+
raw
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
838
1014
|
// src/tools.ts
|
|
839
1015
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
840
|
-
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
1016
|
+
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
841
1017
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
842
1018
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
843
1019
|
var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
|
|
844
|
-
var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1020
|
+
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
845
1021
|
var connectionParams = {
|
|
846
1022
|
host: HostSchema,
|
|
847
1023
|
port: PortSchema,
|
|
@@ -969,7 +1145,7 @@ ${result.stderr}`);
|
|
|
969
1145
|
lines.push(` - ${s}`);
|
|
970
1146
|
}
|
|
971
1147
|
}
|
|
972
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1148
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
|
|
973
1149
|
}
|
|
974
1150
|
);
|
|
975
1151
|
server.tool(
|
|
@@ -1100,13 +1276,131 @@ ${result.stderr}`);
|
|
|
1100
1276
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
1101
1277
|
}
|
|
1102
1278
|
);
|
|
1279
|
+
server.tool(
|
|
1280
|
+
"ssh_multi_exec",
|
|
1281
|
+
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
|
|
1282
|
+
{
|
|
1283
|
+
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1284
|
+
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1285
|
+
port: PortSchema,
|
|
1286
|
+
username: UsernameSchema,
|
|
1287
|
+
privateKeyPath: KeyPathSchema,
|
|
1288
|
+
password: PasswordSchema,
|
|
1289
|
+
timeout: TimeoutSchema
|
|
1290
|
+
},
|
|
1291
|
+
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1292
|
+
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1293
|
+
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1294
|
+
const lines = [];
|
|
1295
|
+
for (const r of results) {
|
|
1296
|
+
lines.push(`--- ${r.host} ---`);
|
|
1297
|
+
if (r.error) {
|
|
1298
|
+
lines.push(`[ERROR] ${r.error}`);
|
|
1299
|
+
} else {
|
|
1300
|
+
if (r.stdout) lines.push(r.stdout);
|
|
1301
|
+
if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
|
|
1302
|
+
lines.push(`[exit code: ${r.code}]`);
|
|
1303
|
+
}
|
|
1304
|
+
lines.push("");
|
|
1305
|
+
}
|
|
1306
|
+
const hasErrors = results.some((r) => r.error || r.code !== 0);
|
|
1307
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
|
|
1308
|
+
}
|
|
1309
|
+
);
|
|
1310
|
+
server.tool(
|
|
1311
|
+
"ssh_find",
|
|
1312
|
+
"Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
|
|
1313
|
+
{
|
|
1314
|
+
...connectionParams,
|
|
1315
|
+
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
1316
|
+
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
1317
|
+
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
1318
|
+
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1319
|
+
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1320
|
+
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1321
|
+
timeout: TimeoutSchema
|
|
1322
|
+
},
|
|
1323
|
+
async ({
|
|
1324
|
+
host,
|
|
1325
|
+
port,
|
|
1326
|
+
username,
|
|
1327
|
+
privateKeyPath,
|
|
1328
|
+
password,
|
|
1329
|
+
path,
|
|
1330
|
+
name,
|
|
1331
|
+
type,
|
|
1332
|
+
maxdepth,
|
|
1333
|
+
minsize,
|
|
1334
|
+
maxsize,
|
|
1335
|
+
timeout
|
|
1336
|
+
}) => {
|
|
1337
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1338
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1339
|
+
if (files.length === 0) {
|
|
1340
|
+
return { content: [{ type: "text", text: "No files found." }] };
|
|
1341
|
+
}
|
|
1342
|
+
return { content: [{ type: "text", text: `Found ${files.length} result(s):
|
|
1343
|
+
${files.join("\n")}` }] };
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
);
|
|
1347
|
+
server.tool(
|
|
1348
|
+
"ssh_tail",
|
|
1349
|
+
"Read the last N lines of a file on a remote host, optionally filtering by a grep pattern. Use this for reading log files instead of ssh_exec with manual tail/grep commands.",
|
|
1350
|
+
{
|
|
1351
|
+
...connectionParams,
|
|
1352
|
+
path: z.string().describe("Absolute path to the file to tail"),
|
|
1353
|
+
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1354
|
+
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1355
|
+
timeout: TimeoutSchema
|
|
1356
|
+
},
|
|
1357
|
+
async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
|
|
1358
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1359
|
+
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1360
|
+
if (!output.trim()) {
|
|
1361
|
+
return {
|
|
1362
|
+
content: [
|
|
1363
|
+
{
|
|
1364
|
+
type: "text",
|
|
1365
|
+
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
|
|
1366
|
+
}
|
|
1367
|
+
]
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
return { content: [{ type: "text", text: output }] };
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
);
|
|
1374
|
+
server.tool(
|
|
1375
|
+
"ssh_service_status",
|
|
1376
|
+
"Check the status of a systemd service on a remote host. Returns whether it's active, its PID, uptime, and description. Use this instead of ssh_exec with systemctl.",
|
|
1377
|
+
{
|
|
1378
|
+
...connectionParams,
|
|
1379
|
+
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1380
|
+
timeout: TimeoutSchema
|
|
1381
|
+
},
|
|
1382
|
+
async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
|
|
1383
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1384
|
+
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1385
|
+
const lines = [];
|
|
1386
|
+
lines.push(`Service: ${status.name}`);
|
|
1387
|
+
lines.push(`Status: ${status.status}`);
|
|
1388
|
+
if (status.description) lines.push(`Description: ${status.description}`);
|
|
1389
|
+
if (status.pid) lines.push(`PID: ${status.pid}`);
|
|
1390
|
+
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1391
|
+
lines.push("");
|
|
1392
|
+
lines.push(status.raw);
|
|
1393
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
);
|
|
1103
1397
|
}
|
|
1104
1398
|
|
|
1105
1399
|
// src/server.ts
|
|
1106
1400
|
function createServer(pool) {
|
|
1107
1401
|
const server = new McpServer({
|
|
1108
1402
|
name: "ssh-mcp",
|
|
1109
|
-
version: "0.
|
|
1403
|
+
version: "0.6.0"
|
|
1110
1404
|
});
|
|
1111
1405
|
registerTools(server, pool);
|
|
1112
1406
|
return server;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import {
|
|
2
|
+
import { ConnectConfig, Client } from 'ssh2';
|
|
3
3
|
|
|
4
4
|
interface SSHConfig {
|
|
5
5
|
host: string;
|
|
@@ -14,11 +14,17 @@ interface ExecResult {
|
|
|
14
14
|
stderr: string;
|
|
15
15
|
code: number;
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
interface ResolvedConfig {
|
|
18
|
+
connectConfig: ConnectConfig;
|
|
19
|
+
proxyJump?: string;
|
|
20
|
+
}
|
|
21
|
+
declare function resolveConfig(config: SSHConfig): ResolvedConfig;
|
|
22
|
+
declare function formatDiagnostics(host: string): string;
|
|
18
23
|
declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
24
|
+
declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
|
|
19
25
|
declare function connect(config: SSHConfig): Promise<Client>;
|
|
20
26
|
declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
21
|
-
declare function readFile(client: Client, remotePath: string): Promise<string>;
|
|
27
|
+
declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
22
28
|
declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
23
29
|
declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
24
30
|
declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
|
@@ -27,10 +33,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
|
27
33
|
interface PoolOptions {
|
|
28
34
|
/** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
|
|
29
35
|
idleTtlMs?: number;
|
|
36
|
+
/** Maximum number of connections in the pool. Default: 100 */
|
|
37
|
+
maxPoolSize?: number;
|
|
30
38
|
}
|
|
31
39
|
declare class ConnectionPool {
|
|
32
40
|
private entries;
|
|
33
41
|
private idleTtlMs;
|
|
42
|
+
private maxPoolSize;
|
|
34
43
|
constructor(options?: PoolOptions);
|
|
35
44
|
acquire(config: SSHConfig): Promise<Client>;
|
|
36
45
|
release(client: Client): void;
|
|
@@ -63,6 +72,43 @@ declare function checkConnectivity(host: string, port?: number): DiagnosticResul
|
|
|
63
72
|
declare function checkSshConfig(host: string): DiagnosticResult;
|
|
64
73
|
declare function diagnose(host: string, port?: number): DiagnosticReport;
|
|
65
74
|
|
|
75
|
+
interface MultiExecResult {
|
|
76
|
+
host: string;
|
|
77
|
+
stdout: string;
|
|
78
|
+
stderr: string;
|
|
79
|
+
code: number;
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
interface MultiExecHost {
|
|
83
|
+
host: string;
|
|
84
|
+
port?: number;
|
|
85
|
+
username?: string;
|
|
86
|
+
privateKeyPath?: string;
|
|
87
|
+
password?: string;
|
|
88
|
+
}
|
|
89
|
+
declare function multiExec(pool: ConnectionPool, hosts: MultiExecHost[], command: string, timeoutMs?: number): Promise<MultiExecResult[]>;
|
|
90
|
+
interface FindOptions {
|
|
91
|
+
path: string;
|
|
92
|
+
name?: string;
|
|
93
|
+
type?: "f" | "d" | "l";
|
|
94
|
+
maxdepth?: number;
|
|
95
|
+
minsize?: string;
|
|
96
|
+
maxsize?: string;
|
|
97
|
+
newer?: string;
|
|
98
|
+
}
|
|
99
|
+
declare function find(client: Client, options: FindOptions, timeoutMs?: number): Promise<string[]>;
|
|
100
|
+
declare function tail(client: Client, path: string, lines?: number, grep?: string, timeoutMs?: number): Promise<string>;
|
|
101
|
+
interface ServiceStatus {
|
|
102
|
+
name: string;
|
|
103
|
+
active: boolean;
|
|
104
|
+
status: string;
|
|
105
|
+
description?: string;
|
|
106
|
+
since?: string;
|
|
107
|
+
pid?: number;
|
|
108
|
+
raw: string;
|
|
109
|
+
}
|
|
110
|
+
declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
|
|
111
|
+
|
|
66
112
|
interface KeyInfo {
|
|
67
113
|
name: string;
|
|
68
114
|
path: string;
|
|
@@ -118,4 +164,4 @@ declare function testConnection(host: string, port?: number): {
|
|
|
118
164
|
|
|
119
165
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
120
166
|
|
|
121
|
-
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type KeyInfo, type PoolOptions, type SSHConfig, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, createServer, diagnose, downloadFile, ensureAgent, exec, fixKnownHosts, listDir, listSshKeys, loadKey, readFile, registerTools, resolveConfig, testConnection, uploadFile, writeFile };
|
|
167
|
+
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
|
package/dist/server.js
CHANGED
|
@@ -10,21 +10,43 @@ import { existsSync, readFileSync, readdirSync } from "fs";
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
function isValidHostname(host) {
|
|
13
|
-
|
|
13
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
14
|
+
if (host.startsWith("[")) {
|
|
15
|
+
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
16
|
+
}
|
|
17
|
+
return /^[a-zA-Z0-9._\-]+$/.test(host);
|
|
14
18
|
}
|
|
15
19
|
function runArgs(cmd, args) {
|
|
16
20
|
try {
|
|
17
21
|
const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
18
22
|
return { stdout: stdout.trim(), ok: true };
|
|
19
23
|
} catch (e) {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
24
|
+
const err = e;
|
|
25
|
+
const stdout = err.stdout?.toString().trim() || "";
|
|
26
|
+
const stderr = err.stderr?.toString().trim() || "";
|
|
27
|
+
const output = [stdout, stderr].filter(Boolean).join("\n") || err.message || "";
|
|
23
28
|
return { stdout: output, ok: false };
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
function checkSshAgent() {
|
|
27
32
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
33
|
+
if (!sock && process.platform === "win32") {
|
|
34
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
35
|
+
if (ok2) {
|
|
36
|
+
return { status: "ok", message: `Windows OpenSSH agent running with keys:
|
|
37
|
+
${stdout2}` };
|
|
38
|
+
}
|
|
39
|
+
if (stdout2.includes("no identities") || stdout2.includes("The agent has no identities")) {
|
|
40
|
+
return {
|
|
41
|
+
status: "warning",
|
|
42
|
+
message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
status: "error",
|
|
47
|
+
message: "Windows OpenSSH Authentication Agent is not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
28
50
|
if (!sock) {
|
|
29
51
|
return {
|
|
30
52
|
status: "error",
|
|
@@ -87,6 +109,9 @@ function checkSshKeys() {
|
|
|
87
109
|
return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
|
|
88
110
|
}
|
|
89
111
|
function checkKnownHosts(host) {
|
|
112
|
+
if (!isValidHostname(host)) {
|
|
113
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
114
|
+
}
|
|
90
115
|
const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
|
|
91
116
|
if (!existsSync(knownHostsPath)) {
|
|
92
117
|
return {
|
|
@@ -94,9 +119,6 @@ function checkKnownHosts(host) {
|
|
|
94
119
|
message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
|
|
95
120
|
};
|
|
96
121
|
}
|
|
97
|
-
if (!isValidHostname(host)) {
|
|
98
|
-
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
99
|
-
}
|
|
100
122
|
const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
|
|
101
123
|
if (!ok || !stdout.trim()) {
|
|
102
124
|
return {
|
|
@@ -254,6 +276,21 @@ function ensureAgent() {
|
|
|
254
276
|
};
|
|
255
277
|
}
|
|
256
278
|
}
|
|
279
|
+
if (!sock && process.platform === "win32") {
|
|
280
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
281
|
+
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
282
|
+
if (ok2 || noIdentities) {
|
|
283
|
+
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
284
|
+
return {
|
|
285
|
+
running: true,
|
|
286
|
+
reachable: true,
|
|
287
|
+
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
288
|
+
keys,
|
|
289
|
+
started: false,
|
|
290
|
+
message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
257
294
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
258
295
|
if (ok) {
|
|
259
296
|
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
@@ -277,7 +314,7 @@ function ensureAgent() {
|
|
|
277
314
|
reachable: false,
|
|
278
315
|
keys: [],
|
|
279
316
|
started: false,
|
|
280
|
-
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
317
|
+
message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
281
318
|
};
|
|
282
319
|
}
|
|
283
320
|
function detectKeyType(filePath, fileName) {
|
|
@@ -537,7 +574,8 @@ function resolveFromSshConfig(host) {
|
|
|
537
574
|
hostname: config.hostname || host,
|
|
538
575
|
user: config.user || "",
|
|
539
576
|
port: config.port || "22",
|
|
540
|
-
identityFiles
|
|
577
|
+
identityFiles,
|
|
578
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
541
579
|
};
|
|
542
580
|
} catch {
|
|
543
581
|
return null;
|
|
@@ -552,7 +590,7 @@ function resolveConfig(config) {
|
|
|
552
590
|
keepaliveInterval: 15e3,
|
|
553
591
|
keepaliveCountMax: 3
|
|
554
592
|
};
|
|
555
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
|
|
593
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
556
594
|
if (agentSock) {
|
|
557
595
|
connectConfig.agent = agentSock;
|
|
558
596
|
}
|
|
@@ -572,7 +610,7 @@ function resolveConfig(config) {
|
|
|
572
610
|
}
|
|
573
611
|
}
|
|
574
612
|
}
|
|
575
|
-
return connectConfig;
|
|
613
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
576
614
|
}
|
|
577
615
|
function formatDiagnostics(host) {
|
|
578
616
|
try {
|
|
@@ -610,10 +648,37 @@ function connectRaw(connectConfig) {
|
|
|
610
648
|
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
611
649
|
});
|
|
612
650
|
}
|
|
651
|
+
async function connectWithProxy(resolved) {
|
|
652
|
+
if (!resolved.proxyJump) {
|
|
653
|
+
return connectRaw(resolved.connectConfig);
|
|
654
|
+
}
|
|
655
|
+
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
656
|
+
const jumpClient = await connectWithProxy(jumpResolved);
|
|
657
|
+
const targetHost = resolved.connectConfig.host;
|
|
658
|
+
const targetPort = resolved.connectConfig.port;
|
|
659
|
+
const stream = await new Promise((resolve, reject) => {
|
|
660
|
+
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
661
|
+
if (err) {
|
|
662
|
+
jumpClient.end();
|
|
663
|
+
return reject(err);
|
|
664
|
+
}
|
|
665
|
+
resolve(stream2);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
return new Promise((resolve, reject) => {
|
|
669
|
+
const client = new Client();
|
|
670
|
+
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
671
|
+
jumpClient.end();
|
|
672
|
+
reject(err);
|
|
673
|
+
}).on("close", () => {
|
|
674
|
+
jumpClient.end();
|
|
675
|
+
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
676
|
+
});
|
|
677
|
+
}
|
|
613
678
|
async function connect(config) {
|
|
614
|
-
const
|
|
679
|
+
const resolved = resolveConfig(config);
|
|
615
680
|
try {
|
|
616
|
-
return await
|
|
681
|
+
return await connectWithProxy(resolved);
|
|
617
682
|
} catch (err) {
|
|
618
683
|
const diag = formatDiagnostics(config.host);
|
|
619
684
|
if (diag) {
|
|
@@ -670,9 +735,21 @@ function getSftp(client) {
|
|
|
670
735
|
});
|
|
671
736
|
});
|
|
672
737
|
}
|
|
673
|
-
|
|
738
|
+
var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
739
|
+
async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
674
740
|
const sftp = await getSftp(client);
|
|
675
741
|
try {
|
|
742
|
+
const stats = await new Promise((resolve, reject) => {
|
|
743
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
744
|
+
if (err) return reject(err);
|
|
745
|
+
resolve(stats2);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
if (stats.size > maxBytes) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
676
753
|
return await new Promise((resolve, reject) => {
|
|
677
754
|
sftp.readFile(remotePath, (err, data) => {
|
|
678
755
|
if (err) return reject(err);
|
|
@@ -736,16 +813,97 @@ async function listDir(client, remotePath) {
|
|
|
736
813
|
}
|
|
737
814
|
}
|
|
738
815
|
|
|
816
|
+
// src/ops.ts
|
|
817
|
+
function shellQuote(s) {
|
|
818
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
819
|
+
}
|
|
820
|
+
async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
821
|
+
const results = await Promise.allSettled(
|
|
822
|
+
hosts.map(async (hostConfig) => {
|
|
823
|
+
return pool.withConnection(hostConfig, async (client) => {
|
|
824
|
+
const result = await exec(client, command, timeoutMs);
|
|
825
|
+
return { host: hostConfig.host, ...result };
|
|
826
|
+
});
|
|
827
|
+
})
|
|
828
|
+
);
|
|
829
|
+
return results.map((result, i) => {
|
|
830
|
+
if (result.status === "fulfilled") {
|
|
831
|
+
return result.value;
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
host: hosts[i].host,
|
|
835
|
+
stdout: "",
|
|
836
|
+
stderr: "",
|
|
837
|
+
code: -1,
|
|
838
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
839
|
+
};
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
|
|
843
|
+
async function find(client, options, timeoutMs = 3e4) {
|
|
844
|
+
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
const args = [shellQuote(options.path)];
|
|
855
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
856
|
+
if (options.type) args.push("-type", options.type);
|
|
857
|
+
if (options.name) args.push("-name", shellQuote(options.name));
|
|
858
|
+
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
859
|
+
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
860
|
+
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
861
|
+
const command = `find ${args.join(" ")} 2>/dev/null`;
|
|
862
|
+
const result = await exec(client, command, timeoutMs);
|
|
863
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
864
|
+
}
|
|
865
|
+
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
866
|
+
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
867
|
+
if (grep) {
|
|
868
|
+
command += ` | grep -i ${shellQuote(grep)}`;
|
|
869
|
+
}
|
|
870
|
+
const result = await exec(client, command, timeoutMs);
|
|
871
|
+
if (result.code !== 0 && result.stderr && !grep) {
|
|
872
|
+
throw new Error(result.stderr.trim());
|
|
873
|
+
}
|
|
874
|
+
return result.stdout;
|
|
875
|
+
}
|
|
876
|
+
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
877
|
+
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
878
|
+
const raw = result.stdout;
|
|
879
|
+
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
880
|
+
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
881
|
+
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
882
|
+
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
883
|
+
return {
|
|
884
|
+
name: serviceName,
|
|
885
|
+
active: activeMatch?.[1] === "active",
|
|
886
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
|
|
887
|
+
description: descMatch?.[1]?.trim(),
|
|
888
|
+
since: sinceMatch?.[1]?.trim(),
|
|
889
|
+
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
890
|
+
raw
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
739
894
|
// src/pool.ts
|
|
740
895
|
var ConnectionPool = class {
|
|
741
896
|
entries = /* @__PURE__ */ new Map();
|
|
742
897
|
idleTtlMs;
|
|
898
|
+
maxPoolSize;
|
|
743
899
|
constructor(options) {
|
|
744
900
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
901
|
+
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
745
902
|
}
|
|
746
903
|
async acquire(config) {
|
|
747
|
-
const
|
|
748
|
-
const
|
|
904
|
+
const resolved = resolveConfig(config);
|
|
905
|
+
const cc = resolved.connectConfig;
|
|
906
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
749
907
|
const existing = this.entries.get(key);
|
|
750
908
|
if (existing && !existing.dead) {
|
|
751
909
|
existing.refCount++;
|
|
@@ -758,8 +916,26 @@ var ConnectionPool = class {
|
|
|
758
916
|
if (existing?.dead) {
|
|
759
917
|
this.entries.delete(key);
|
|
760
918
|
}
|
|
919
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
920
|
+
let evicted = false;
|
|
921
|
+
for (const [k, e] of this.entries) {
|
|
922
|
+
if (e.refCount === 0) {
|
|
923
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
924
|
+
try {
|
|
925
|
+
e.client.end();
|
|
926
|
+
} catch {
|
|
927
|
+
}
|
|
928
|
+
this.entries.delete(k);
|
|
929
|
+
evicted = true;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (!evicted) {
|
|
934
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
761
937
|
try {
|
|
762
|
-
const client = await
|
|
938
|
+
const client = await connectWithProxy(resolved);
|
|
763
939
|
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
764
940
|
const markDead = () => {
|
|
765
941
|
entry.dead = true;
|
|
@@ -848,11 +1024,11 @@ ${diag}`);
|
|
|
848
1024
|
|
|
849
1025
|
// src/tools.ts
|
|
850
1026
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
851
|
-
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
1027
|
+
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
852
1028
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
853
1029
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
854
1030
|
var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
|
|
855
|
-
var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1031
|
+
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
856
1032
|
var connectionParams = {
|
|
857
1033
|
host: HostSchema,
|
|
858
1034
|
port: PortSchema,
|
|
@@ -980,7 +1156,7 @@ ${result.stderr}`);
|
|
|
980
1156
|
lines.push(` - ${s}`);
|
|
981
1157
|
}
|
|
982
1158
|
}
|
|
983
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1159
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
|
|
984
1160
|
}
|
|
985
1161
|
);
|
|
986
1162
|
server.tool(
|
|
@@ -1111,13 +1287,131 @@ ${result.stderr}`);
|
|
|
1111
1287
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
1112
1288
|
}
|
|
1113
1289
|
);
|
|
1290
|
+
server.tool(
|
|
1291
|
+
"ssh_multi_exec",
|
|
1292
|
+
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
|
|
1293
|
+
{
|
|
1294
|
+
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1295
|
+
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1296
|
+
port: PortSchema,
|
|
1297
|
+
username: UsernameSchema,
|
|
1298
|
+
privateKeyPath: KeyPathSchema,
|
|
1299
|
+
password: PasswordSchema,
|
|
1300
|
+
timeout: TimeoutSchema
|
|
1301
|
+
},
|
|
1302
|
+
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1303
|
+
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1304
|
+
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1305
|
+
const lines = [];
|
|
1306
|
+
for (const r of results) {
|
|
1307
|
+
lines.push(`--- ${r.host} ---`);
|
|
1308
|
+
if (r.error) {
|
|
1309
|
+
lines.push(`[ERROR] ${r.error}`);
|
|
1310
|
+
} else {
|
|
1311
|
+
if (r.stdout) lines.push(r.stdout);
|
|
1312
|
+
if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
|
|
1313
|
+
lines.push(`[exit code: ${r.code}]`);
|
|
1314
|
+
}
|
|
1315
|
+
lines.push("");
|
|
1316
|
+
}
|
|
1317
|
+
const hasErrors = results.some((r) => r.error || r.code !== 0);
|
|
1318
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
|
|
1319
|
+
}
|
|
1320
|
+
);
|
|
1321
|
+
server.tool(
|
|
1322
|
+
"ssh_find",
|
|
1323
|
+
"Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
|
|
1324
|
+
{
|
|
1325
|
+
...connectionParams,
|
|
1326
|
+
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
1327
|
+
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
1328
|
+
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
1329
|
+
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1330
|
+
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1331
|
+
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1332
|
+
timeout: TimeoutSchema
|
|
1333
|
+
},
|
|
1334
|
+
async ({
|
|
1335
|
+
host,
|
|
1336
|
+
port,
|
|
1337
|
+
username,
|
|
1338
|
+
privateKeyPath,
|
|
1339
|
+
password,
|
|
1340
|
+
path,
|
|
1341
|
+
name,
|
|
1342
|
+
type,
|
|
1343
|
+
maxdepth,
|
|
1344
|
+
minsize,
|
|
1345
|
+
maxsize,
|
|
1346
|
+
timeout
|
|
1347
|
+
}) => {
|
|
1348
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1349
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1350
|
+
if (files.length === 0) {
|
|
1351
|
+
return { content: [{ type: "text", text: "No files found." }] };
|
|
1352
|
+
}
|
|
1353
|
+
return { content: [{ type: "text", text: `Found ${files.length} result(s):
|
|
1354
|
+
${files.join("\n")}` }] };
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
);
|
|
1358
|
+
server.tool(
|
|
1359
|
+
"ssh_tail",
|
|
1360
|
+
"Read the last N lines of a file on a remote host, optionally filtering by a grep pattern. Use this for reading log files instead of ssh_exec with manual tail/grep commands.",
|
|
1361
|
+
{
|
|
1362
|
+
...connectionParams,
|
|
1363
|
+
path: z.string().describe("Absolute path to the file to tail"),
|
|
1364
|
+
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1365
|
+
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1366
|
+
timeout: TimeoutSchema
|
|
1367
|
+
},
|
|
1368
|
+
async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
|
|
1369
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1370
|
+
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1371
|
+
if (!output.trim()) {
|
|
1372
|
+
return {
|
|
1373
|
+
content: [
|
|
1374
|
+
{
|
|
1375
|
+
type: "text",
|
|
1376
|
+
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
|
|
1377
|
+
}
|
|
1378
|
+
]
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
return { content: [{ type: "text", text: output }] };
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
);
|
|
1385
|
+
server.tool(
|
|
1386
|
+
"ssh_service_status",
|
|
1387
|
+
"Check the status of a systemd service on a remote host. Returns whether it's active, its PID, uptime, and description. Use this instead of ssh_exec with systemctl.",
|
|
1388
|
+
{
|
|
1389
|
+
...connectionParams,
|
|
1390
|
+
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1391
|
+
timeout: TimeoutSchema
|
|
1392
|
+
},
|
|
1393
|
+
async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
|
|
1394
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1395
|
+
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1396
|
+
const lines = [];
|
|
1397
|
+
lines.push(`Service: ${status.name}`);
|
|
1398
|
+
lines.push(`Status: ${status.status}`);
|
|
1399
|
+
if (status.description) lines.push(`Description: ${status.description}`);
|
|
1400
|
+
if (status.pid) lines.push(`PID: ${status.pid}`);
|
|
1401
|
+
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1402
|
+
lines.push("");
|
|
1403
|
+
lines.push(status.raw);
|
|
1404
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
);
|
|
1114
1408
|
}
|
|
1115
1409
|
|
|
1116
1410
|
// src/server.ts
|
|
1117
1411
|
function createServer(pool) {
|
|
1118
1412
|
const server = new McpServer({
|
|
1119
1413
|
name: "ssh-mcp",
|
|
1120
|
-
version: "0.
|
|
1414
|
+
version: "0.6.0"
|
|
1121
1415
|
});
|
|
1122
1416
|
registerTools(server, pool);
|
|
1123
1417
|
return server;
|
|
@@ -1133,18 +1427,24 @@ export {
|
|
|
1133
1427
|
configLookup,
|
|
1134
1428
|
connect,
|
|
1135
1429
|
connectRaw,
|
|
1430
|
+
connectWithProxy,
|
|
1136
1431
|
createServer,
|
|
1137
1432
|
diagnose,
|
|
1138
1433
|
downloadFile,
|
|
1139
1434
|
ensureAgent,
|
|
1140
1435
|
exec,
|
|
1436
|
+
find,
|
|
1141
1437
|
fixKnownHosts,
|
|
1438
|
+
formatDiagnostics,
|
|
1142
1439
|
listDir,
|
|
1143
1440
|
listSshKeys,
|
|
1144
1441
|
loadKey,
|
|
1442
|
+
multiExec,
|
|
1145
1443
|
readFile,
|
|
1146
1444
|
registerTools,
|
|
1147
1445
|
resolveConfig,
|
|
1446
|
+
serviceStatus,
|
|
1447
|
+
tail,
|
|
1148
1448
|
testConnection,
|
|
1149
1449
|
uploadFile,
|
|
1150
1450
|
writeFile
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"lint:fix": "biome check --write src/",
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"test": "vitest run",
|
|
27
|
+
"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",
|
|
27
28
|
"test:ci": "npm run build && npm test",
|
|
28
29
|
"prepublishOnly": "npm run build"
|
|
29
30
|
},
|