@yawlabs/ssh-mcp 0.4.0 → 0.5.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 +255 -8
- package/dist/server.d.ts +46 -3
- package/dist/server.js +263 -10
- 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
|
@@ -30,6 +30,25 @@ function runArgs(cmd, args) {
|
|
|
30
30
|
}
|
|
31
31
|
function checkSshAgent() {
|
|
32
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
|
+
if (!stdout2.includes("Error connecting") && !stdout2.includes("unable to")) {
|
|
46
|
+
return {
|
|
47
|
+
status: "warning",
|
|
48
|
+
message: "Windows OpenSSH Authentication Agent may not be running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
33
52
|
if (!sock) {
|
|
34
53
|
return {
|
|
35
54
|
status: "error",
|
|
@@ -261,7 +280,8 @@ function resolveFromSshConfig(host) {
|
|
|
261
280
|
hostname: config.hostname || host,
|
|
262
281
|
user: config.user || "",
|
|
263
282
|
port: config.port || "22",
|
|
264
|
-
identityFiles
|
|
283
|
+
identityFiles,
|
|
284
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
265
285
|
};
|
|
266
286
|
} catch {
|
|
267
287
|
return null;
|
|
@@ -276,7 +296,7 @@ function resolveConfig(config) {
|
|
|
276
296
|
keepaliveInterval: 15e3,
|
|
277
297
|
keepaliveCountMax: 3
|
|
278
298
|
};
|
|
279
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
|
|
299
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
280
300
|
if (agentSock) {
|
|
281
301
|
connectConfig.agent = agentSock;
|
|
282
302
|
}
|
|
@@ -296,7 +316,7 @@ function resolveConfig(config) {
|
|
|
296
316
|
}
|
|
297
317
|
}
|
|
298
318
|
}
|
|
299
|
-
return connectConfig;
|
|
319
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
300
320
|
}
|
|
301
321
|
function formatDiagnostics(host) {
|
|
302
322
|
try {
|
|
@@ -334,6 +354,33 @@ function connectRaw(connectConfig) {
|
|
|
334
354
|
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
335
355
|
});
|
|
336
356
|
}
|
|
357
|
+
async function connectWithProxy(resolved) {
|
|
358
|
+
if (!resolved.proxyJump) {
|
|
359
|
+
return connectRaw(resolved.connectConfig);
|
|
360
|
+
}
|
|
361
|
+
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
362
|
+
const jumpClient = await connectWithProxy(jumpResolved);
|
|
363
|
+
const targetHost = resolved.connectConfig.host;
|
|
364
|
+
const targetPort = resolved.connectConfig.port;
|
|
365
|
+
const stream = await new Promise((resolve, reject) => {
|
|
366
|
+
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
367
|
+
if (err) {
|
|
368
|
+
jumpClient.end();
|
|
369
|
+
return reject(err);
|
|
370
|
+
}
|
|
371
|
+
resolve(stream2);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const client = new Client();
|
|
376
|
+
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
377
|
+
jumpClient.end();
|
|
378
|
+
reject(err);
|
|
379
|
+
}).on("close", () => {
|
|
380
|
+
jumpClient.end();
|
|
381
|
+
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
382
|
+
});
|
|
383
|
+
}
|
|
337
384
|
function exec(client, command, timeoutMs = 3e4) {
|
|
338
385
|
return new Promise((resolve, reject) => {
|
|
339
386
|
let settled = false;
|
|
@@ -450,8 +497,9 @@ var ConnectionPool = class {
|
|
|
450
497
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
451
498
|
}
|
|
452
499
|
async acquire(config) {
|
|
453
|
-
const
|
|
454
|
-
const
|
|
500
|
+
const resolved = resolveConfig(config);
|
|
501
|
+
const cc = resolved.connectConfig;
|
|
502
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
455
503
|
const existing = this.entries.get(key);
|
|
456
504
|
if (existing && !existing.dead) {
|
|
457
505
|
existing.refCount++;
|
|
@@ -465,7 +513,7 @@ var ConnectionPool = class {
|
|
|
465
513
|
this.entries.delete(key);
|
|
466
514
|
}
|
|
467
515
|
try {
|
|
468
|
-
const client = await
|
|
516
|
+
const client = await connectWithProxy(resolved);
|
|
469
517
|
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
470
518
|
const markDead = () => {
|
|
471
519
|
entry.dead = true;
|
|
@@ -579,6 +627,21 @@ function ensureAgent() {
|
|
|
579
627
|
};
|
|
580
628
|
}
|
|
581
629
|
}
|
|
630
|
+
if (!sock && process.platform === "win32") {
|
|
631
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
632
|
+
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
633
|
+
if (ok2 || noIdentities) {
|
|
634
|
+
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
635
|
+
return {
|
|
636
|
+
running: true,
|
|
637
|
+
reachable: true,
|
|
638
|
+
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
639
|
+
keys,
|
|
640
|
+
started: false,
|
|
641
|
+
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."
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
582
645
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
583
646
|
if (ok) {
|
|
584
647
|
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
@@ -602,7 +665,7 @@ function ensureAgent() {
|
|
|
602
665
|
reachable: false,
|
|
603
666
|
keys: [],
|
|
604
667
|
started: false,
|
|
605
|
-
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
668
|
+
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
669
|
};
|
|
607
670
|
}
|
|
608
671
|
function detectKeyType(filePath, fileName) {
|
|
@@ -835,6 +898,73 @@ function testConnection(host, port = 22) {
|
|
|
835
898
|
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
836
899
|
}
|
|
837
900
|
|
|
901
|
+
// src/ops.ts
|
|
902
|
+
function shellQuote(s) {
|
|
903
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
904
|
+
}
|
|
905
|
+
async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
906
|
+
const results = await Promise.allSettled(
|
|
907
|
+
hosts.map(async (hostConfig) => {
|
|
908
|
+
return pool.withConnection(hostConfig, async (client) => {
|
|
909
|
+
const result = await exec(client, command, timeoutMs);
|
|
910
|
+
return { host: hostConfig.host, ...result };
|
|
911
|
+
});
|
|
912
|
+
})
|
|
913
|
+
);
|
|
914
|
+
return results.map((result, i) => {
|
|
915
|
+
if (result.status === "fulfilled") {
|
|
916
|
+
return result.value;
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
host: hosts[i].host,
|
|
920
|
+
stdout: "",
|
|
921
|
+
stderr: "",
|
|
922
|
+
code: -1,
|
|
923
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
async function find(client, options, timeoutMs = 3e4) {
|
|
928
|
+
const args = [shellQuote(options.path)];
|
|
929
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
930
|
+
if (options.type) args.push("-type", options.type);
|
|
931
|
+
if (options.name) args.push("-name", shellQuote(options.name));
|
|
932
|
+
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
933
|
+
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
934
|
+
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
935
|
+
const command = `find ${args.join(" ")} 2>/dev/null`;
|
|
936
|
+
const result = await exec(client, command, timeoutMs);
|
|
937
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
938
|
+
}
|
|
939
|
+
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
940
|
+
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
941
|
+
if (grep) {
|
|
942
|
+
command += ` | grep -i ${shellQuote(grep)}`;
|
|
943
|
+
}
|
|
944
|
+
const result = await exec(client, command, timeoutMs);
|
|
945
|
+
if (result.code !== 0 && result.stderr && !grep) {
|
|
946
|
+
throw new Error(result.stderr.trim());
|
|
947
|
+
}
|
|
948
|
+
return result.stdout;
|
|
949
|
+
}
|
|
950
|
+
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
951
|
+
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
952
|
+
const raw = result.stdout;
|
|
953
|
+
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
954
|
+
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
955
|
+
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
956
|
+
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
957
|
+
return {
|
|
958
|
+
name: serviceName,
|
|
959
|
+
active: activeMatch?.[1] === "active",
|
|
960
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
|
|
961
|
+
description: descMatch?.[1]?.trim(),
|
|
962
|
+
since: sinceMatch?.[1]?.trim(),
|
|
963
|
+
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
964
|
+
raw
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
838
968
|
// src/tools.ts
|
|
839
969
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
840
970
|
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
@@ -1100,13 +1230,130 @@ ${result.stderr}`);
|
|
|
1100
1230
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
1101
1231
|
}
|
|
1102
1232
|
);
|
|
1233
|
+
server.tool(
|
|
1234
|
+
"ssh_multi_exec",
|
|
1235
|
+
"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.",
|
|
1236
|
+
{
|
|
1237
|
+
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1238
|
+
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1239
|
+
username: UsernameSchema,
|
|
1240
|
+
privateKeyPath: KeyPathSchema,
|
|
1241
|
+
password: PasswordSchema,
|
|
1242
|
+
timeout: TimeoutSchema
|
|
1243
|
+
},
|
|
1244
|
+
async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
|
|
1245
|
+
const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
|
|
1246
|
+
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1247
|
+
const lines = [];
|
|
1248
|
+
for (const r of results) {
|
|
1249
|
+
lines.push(`--- ${r.host} ---`);
|
|
1250
|
+
if (r.error) {
|
|
1251
|
+
lines.push(`[ERROR] ${r.error}`);
|
|
1252
|
+
} else {
|
|
1253
|
+
if (r.stdout) lines.push(r.stdout);
|
|
1254
|
+
if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
|
|
1255
|
+
lines.push(`[exit code: ${r.code}]`);
|
|
1256
|
+
}
|
|
1257
|
+
lines.push("");
|
|
1258
|
+
}
|
|
1259
|
+
const hasErrors = results.some((r) => r.error || r.code !== 0);
|
|
1260
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
|
|
1261
|
+
}
|
|
1262
|
+
);
|
|
1263
|
+
server.tool(
|
|
1264
|
+
"ssh_find",
|
|
1265
|
+
"Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
|
|
1266
|
+
{
|
|
1267
|
+
...connectionParams,
|
|
1268
|
+
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
1269
|
+
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
1270
|
+
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
1271
|
+
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1272
|
+
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1273
|
+
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1274
|
+
timeout: TimeoutSchema
|
|
1275
|
+
},
|
|
1276
|
+
async ({
|
|
1277
|
+
host,
|
|
1278
|
+
port,
|
|
1279
|
+
username,
|
|
1280
|
+
privateKeyPath,
|
|
1281
|
+
password,
|
|
1282
|
+
path,
|
|
1283
|
+
name,
|
|
1284
|
+
type,
|
|
1285
|
+
maxdepth,
|
|
1286
|
+
minsize,
|
|
1287
|
+
maxsize,
|
|
1288
|
+
timeout
|
|
1289
|
+
}) => {
|
|
1290
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1291
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1292
|
+
if (files.length === 0) {
|
|
1293
|
+
return { content: [{ type: "text", text: "No files found." }] };
|
|
1294
|
+
}
|
|
1295
|
+
return { content: [{ type: "text", text: `Found ${files.length} result(s):
|
|
1296
|
+
${files.join("\n")}` }] };
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
);
|
|
1300
|
+
server.tool(
|
|
1301
|
+
"ssh_tail",
|
|
1302
|
+
"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.",
|
|
1303
|
+
{
|
|
1304
|
+
...connectionParams,
|
|
1305
|
+
path: z.string().describe("Absolute path to the file to tail"),
|
|
1306
|
+
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1307
|
+
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1308
|
+
timeout: TimeoutSchema
|
|
1309
|
+
},
|
|
1310
|
+
async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
|
|
1311
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1312
|
+
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1313
|
+
if (!output.trim()) {
|
|
1314
|
+
return {
|
|
1315
|
+
content: [
|
|
1316
|
+
{
|
|
1317
|
+
type: "text",
|
|
1318
|
+
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
|
|
1319
|
+
}
|
|
1320
|
+
]
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
return { content: [{ type: "text", text: output }] };
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
);
|
|
1327
|
+
server.tool(
|
|
1328
|
+
"ssh_service_status",
|
|
1329
|
+
"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.",
|
|
1330
|
+
{
|
|
1331
|
+
...connectionParams,
|
|
1332
|
+
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1333
|
+
timeout: TimeoutSchema
|
|
1334
|
+
},
|
|
1335
|
+
async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
|
|
1336
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1337
|
+
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1338
|
+
const lines = [];
|
|
1339
|
+
lines.push(`Service: ${status.name}`);
|
|
1340
|
+
lines.push(`Status: ${status.status}`);
|
|
1341
|
+
if (status.description) lines.push(`Description: ${status.description}`);
|
|
1342
|
+
if (status.pid) lines.push(`PID: ${status.pid}`);
|
|
1343
|
+
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1344
|
+
lines.push("");
|
|
1345
|
+
lines.push(status.raw);
|
|
1346
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
);
|
|
1103
1350
|
}
|
|
1104
1351
|
|
|
1105
1352
|
// src/server.ts
|
|
1106
1353
|
function createServer(pool) {
|
|
1107
1354
|
const server = new McpServer({
|
|
1108
1355
|
name: "ssh-mcp",
|
|
1109
|
-
version: "0.
|
|
1356
|
+
version: "0.5.0"
|
|
1110
1357
|
});
|
|
1111
1358
|
registerTools(server, pool);
|
|
1112
1359
|
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,8 +14,14 @@ 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
27
|
declare function readFile(client: Client, remotePath: string): Promise<string>;
|
|
@@ -63,6 +69,43 @@ declare function checkConnectivity(host: string, port?: number): DiagnosticResul
|
|
|
63
69
|
declare function checkSshConfig(host: string): DiagnosticResult;
|
|
64
70
|
declare function diagnose(host: string, port?: number): DiagnosticReport;
|
|
65
71
|
|
|
72
|
+
interface MultiExecResult {
|
|
73
|
+
host: string;
|
|
74
|
+
stdout: string;
|
|
75
|
+
stderr: string;
|
|
76
|
+
code: number;
|
|
77
|
+
error?: string;
|
|
78
|
+
}
|
|
79
|
+
interface MultiExecHost {
|
|
80
|
+
host: string;
|
|
81
|
+
port?: number;
|
|
82
|
+
username?: string;
|
|
83
|
+
privateKeyPath?: string;
|
|
84
|
+
password?: string;
|
|
85
|
+
}
|
|
86
|
+
declare function multiExec(pool: ConnectionPool, hosts: MultiExecHost[], command: string, timeoutMs?: number): Promise<MultiExecResult[]>;
|
|
87
|
+
interface FindOptions {
|
|
88
|
+
path: string;
|
|
89
|
+
name?: string;
|
|
90
|
+
type?: "f" | "d" | "l";
|
|
91
|
+
maxdepth?: number;
|
|
92
|
+
minsize?: string;
|
|
93
|
+
maxsize?: string;
|
|
94
|
+
newer?: string;
|
|
95
|
+
}
|
|
96
|
+
declare function find(client: Client, options: FindOptions, timeoutMs?: number): Promise<string[]>;
|
|
97
|
+
declare function tail(client: Client, path: string, lines?: number, grep?: string, timeoutMs?: number): Promise<string>;
|
|
98
|
+
interface ServiceStatus {
|
|
99
|
+
name: string;
|
|
100
|
+
active: boolean;
|
|
101
|
+
status: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
since?: string;
|
|
104
|
+
pid?: number;
|
|
105
|
+
raw: string;
|
|
106
|
+
}
|
|
107
|
+
declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
|
|
108
|
+
|
|
66
109
|
interface KeyInfo {
|
|
67
110
|
name: string;
|
|
68
111
|
path: string;
|
|
@@ -118,4 +161,4 @@ declare function testConnection(host: string, port?: number): {
|
|
|
118
161
|
|
|
119
162
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
120
163
|
|
|
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 };
|
|
164
|
+
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
|
@@ -25,6 +25,25 @@ function runArgs(cmd, args) {
|
|
|
25
25
|
}
|
|
26
26
|
function checkSshAgent() {
|
|
27
27
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
28
|
+
if (!sock && process.platform === "win32") {
|
|
29
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
30
|
+
if (ok2) {
|
|
31
|
+
return { status: "ok", message: `Windows OpenSSH agent running with keys:
|
|
32
|
+
${stdout2}` };
|
|
33
|
+
}
|
|
34
|
+
if (stdout2.includes("no identities") || stdout2.includes("The agent has no identities")) {
|
|
35
|
+
return {
|
|
36
|
+
status: "warning",
|
|
37
|
+
message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!stdout2.includes("Error connecting") && !stdout2.includes("unable to")) {
|
|
41
|
+
return {
|
|
42
|
+
status: "warning",
|
|
43
|
+
message: "Windows OpenSSH Authentication Agent may not be running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
if (!sock) {
|
|
29
48
|
return {
|
|
30
49
|
status: "error",
|
|
@@ -254,6 +273,21 @@ function ensureAgent() {
|
|
|
254
273
|
};
|
|
255
274
|
}
|
|
256
275
|
}
|
|
276
|
+
if (!sock && process.platform === "win32") {
|
|
277
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
278
|
+
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
279
|
+
if (ok2 || noIdentities) {
|
|
280
|
+
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
281
|
+
return {
|
|
282
|
+
running: true,
|
|
283
|
+
reachable: true,
|
|
284
|
+
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
285
|
+
keys,
|
|
286
|
+
started: false,
|
|
287
|
+
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."
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
257
291
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
258
292
|
if (ok) {
|
|
259
293
|
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
@@ -277,7 +311,7 @@ function ensureAgent() {
|
|
|
277
311
|
reachable: false,
|
|
278
312
|
keys: [],
|
|
279
313
|
started: false,
|
|
280
|
-
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
314
|
+
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
315
|
};
|
|
282
316
|
}
|
|
283
317
|
function detectKeyType(filePath, fileName) {
|
|
@@ -537,7 +571,8 @@ function resolveFromSshConfig(host) {
|
|
|
537
571
|
hostname: config.hostname || host,
|
|
538
572
|
user: config.user || "",
|
|
539
573
|
port: config.port || "22",
|
|
540
|
-
identityFiles
|
|
574
|
+
identityFiles,
|
|
575
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
541
576
|
};
|
|
542
577
|
} catch {
|
|
543
578
|
return null;
|
|
@@ -552,7 +587,7 @@ function resolveConfig(config) {
|
|
|
552
587
|
keepaliveInterval: 15e3,
|
|
553
588
|
keepaliveCountMax: 3
|
|
554
589
|
};
|
|
555
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
|
|
590
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
556
591
|
if (agentSock) {
|
|
557
592
|
connectConfig.agent = agentSock;
|
|
558
593
|
}
|
|
@@ -572,7 +607,7 @@ function resolveConfig(config) {
|
|
|
572
607
|
}
|
|
573
608
|
}
|
|
574
609
|
}
|
|
575
|
-
return connectConfig;
|
|
610
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
576
611
|
}
|
|
577
612
|
function formatDiagnostics(host) {
|
|
578
613
|
try {
|
|
@@ -610,10 +645,37 @@ function connectRaw(connectConfig) {
|
|
|
610
645
|
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
611
646
|
});
|
|
612
647
|
}
|
|
648
|
+
async function connectWithProxy(resolved) {
|
|
649
|
+
if (!resolved.proxyJump) {
|
|
650
|
+
return connectRaw(resolved.connectConfig);
|
|
651
|
+
}
|
|
652
|
+
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
653
|
+
const jumpClient = await connectWithProxy(jumpResolved);
|
|
654
|
+
const targetHost = resolved.connectConfig.host;
|
|
655
|
+
const targetPort = resolved.connectConfig.port;
|
|
656
|
+
const stream = await new Promise((resolve, reject) => {
|
|
657
|
+
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
658
|
+
if (err) {
|
|
659
|
+
jumpClient.end();
|
|
660
|
+
return reject(err);
|
|
661
|
+
}
|
|
662
|
+
resolve(stream2);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
return new Promise((resolve, reject) => {
|
|
666
|
+
const client = new Client();
|
|
667
|
+
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
668
|
+
jumpClient.end();
|
|
669
|
+
reject(err);
|
|
670
|
+
}).on("close", () => {
|
|
671
|
+
jumpClient.end();
|
|
672
|
+
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
673
|
+
});
|
|
674
|
+
}
|
|
613
675
|
async function connect(config) {
|
|
614
|
-
const
|
|
676
|
+
const resolved = resolveConfig(config);
|
|
615
677
|
try {
|
|
616
|
-
return await
|
|
678
|
+
return await connectWithProxy(resolved);
|
|
617
679
|
} catch (err) {
|
|
618
680
|
const diag = formatDiagnostics(config.host);
|
|
619
681
|
if (diag) {
|
|
@@ -736,6 +798,73 @@ async function listDir(client, remotePath) {
|
|
|
736
798
|
}
|
|
737
799
|
}
|
|
738
800
|
|
|
801
|
+
// src/ops.ts
|
|
802
|
+
function shellQuote(s) {
|
|
803
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
804
|
+
}
|
|
805
|
+
async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
806
|
+
const results = await Promise.allSettled(
|
|
807
|
+
hosts.map(async (hostConfig) => {
|
|
808
|
+
return pool.withConnection(hostConfig, async (client) => {
|
|
809
|
+
const result = await exec(client, command, timeoutMs);
|
|
810
|
+
return { host: hostConfig.host, ...result };
|
|
811
|
+
});
|
|
812
|
+
})
|
|
813
|
+
);
|
|
814
|
+
return results.map((result, i) => {
|
|
815
|
+
if (result.status === "fulfilled") {
|
|
816
|
+
return result.value;
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
host: hosts[i].host,
|
|
820
|
+
stdout: "",
|
|
821
|
+
stderr: "",
|
|
822
|
+
code: -1,
|
|
823
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
824
|
+
};
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
async function find(client, options, timeoutMs = 3e4) {
|
|
828
|
+
const args = [shellQuote(options.path)];
|
|
829
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
830
|
+
if (options.type) args.push("-type", options.type);
|
|
831
|
+
if (options.name) args.push("-name", shellQuote(options.name));
|
|
832
|
+
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
833
|
+
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
834
|
+
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
835
|
+
const command = `find ${args.join(" ")} 2>/dev/null`;
|
|
836
|
+
const result = await exec(client, command, timeoutMs);
|
|
837
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
838
|
+
}
|
|
839
|
+
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
840
|
+
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
841
|
+
if (grep) {
|
|
842
|
+
command += ` | grep -i ${shellQuote(grep)}`;
|
|
843
|
+
}
|
|
844
|
+
const result = await exec(client, command, timeoutMs);
|
|
845
|
+
if (result.code !== 0 && result.stderr && !grep) {
|
|
846
|
+
throw new Error(result.stderr.trim());
|
|
847
|
+
}
|
|
848
|
+
return result.stdout;
|
|
849
|
+
}
|
|
850
|
+
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
851
|
+
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
852
|
+
const raw = result.stdout;
|
|
853
|
+
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
854
|
+
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
855
|
+
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
856
|
+
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
857
|
+
return {
|
|
858
|
+
name: serviceName,
|
|
859
|
+
active: activeMatch?.[1] === "active",
|
|
860
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
|
|
861
|
+
description: descMatch?.[1]?.trim(),
|
|
862
|
+
since: sinceMatch?.[1]?.trim(),
|
|
863
|
+
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
864
|
+
raw
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
739
868
|
// src/pool.ts
|
|
740
869
|
var ConnectionPool = class {
|
|
741
870
|
entries = /* @__PURE__ */ new Map();
|
|
@@ -744,8 +873,9 @@ var ConnectionPool = class {
|
|
|
744
873
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
745
874
|
}
|
|
746
875
|
async acquire(config) {
|
|
747
|
-
const
|
|
748
|
-
const
|
|
876
|
+
const resolved = resolveConfig(config);
|
|
877
|
+
const cc = resolved.connectConfig;
|
|
878
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
749
879
|
const existing = this.entries.get(key);
|
|
750
880
|
if (existing && !existing.dead) {
|
|
751
881
|
existing.refCount++;
|
|
@@ -759,7 +889,7 @@ var ConnectionPool = class {
|
|
|
759
889
|
this.entries.delete(key);
|
|
760
890
|
}
|
|
761
891
|
try {
|
|
762
|
-
const client = await
|
|
892
|
+
const client = await connectWithProxy(resolved);
|
|
763
893
|
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
764
894
|
const markDead = () => {
|
|
765
895
|
entry.dead = true;
|
|
@@ -1111,13 +1241,130 @@ ${result.stderr}`);
|
|
|
1111
1241
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
1112
1242
|
}
|
|
1113
1243
|
);
|
|
1244
|
+
server.tool(
|
|
1245
|
+
"ssh_multi_exec",
|
|
1246
|
+
"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.",
|
|
1247
|
+
{
|
|
1248
|
+
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1249
|
+
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1250
|
+
username: UsernameSchema,
|
|
1251
|
+
privateKeyPath: KeyPathSchema,
|
|
1252
|
+
password: PasswordSchema,
|
|
1253
|
+
timeout: TimeoutSchema
|
|
1254
|
+
},
|
|
1255
|
+
async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
|
|
1256
|
+
const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
|
|
1257
|
+
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1258
|
+
const lines = [];
|
|
1259
|
+
for (const r of results) {
|
|
1260
|
+
lines.push(`--- ${r.host} ---`);
|
|
1261
|
+
if (r.error) {
|
|
1262
|
+
lines.push(`[ERROR] ${r.error}`);
|
|
1263
|
+
} else {
|
|
1264
|
+
if (r.stdout) lines.push(r.stdout);
|
|
1265
|
+
if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
|
|
1266
|
+
lines.push(`[exit code: ${r.code}]`);
|
|
1267
|
+
}
|
|
1268
|
+
lines.push("");
|
|
1269
|
+
}
|
|
1270
|
+
const hasErrors = results.some((r) => r.error || r.code !== 0);
|
|
1271
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
|
|
1272
|
+
}
|
|
1273
|
+
);
|
|
1274
|
+
server.tool(
|
|
1275
|
+
"ssh_find",
|
|
1276
|
+
"Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
|
|
1277
|
+
{
|
|
1278
|
+
...connectionParams,
|
|
1279
|
+
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
1280
|
+
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
1281
|
+
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
1282
|
+
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1283
|
+
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1284
|
+
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1285
|
+
timeout: TimeoutSchema
|
|
1286
|
+
},
|
|
1287
|
+
async ({
|
|
1288
|
+
host,
|
|
1289
|
+
port,
|
|
1290
|
+
username,
|
|
1291
|
+
privateKeyPath,
|
|
1292
|
+
password,
|
|
1293
|
+
path,
|
|
1294
|
+
name,
|
|
1295
|
+
type,
|
|
1296
|
+
maxdepth,
|
|
1297
|
+
minsize,
|
|
1298
|
+
maxsize,
|
|
1299
|
+
timeout
|
|
1300
|
+
}) => {
|
|
1301
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1302
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1303
|
+
if (files.length === 0) {
|
|
1304
|
+
return { content: [{ type: "text", text: "No files found." }] };
|
|
1305
|
+
}
|
|
1306
|
+
return { content: [{ type: "text", text: `Found ${files.length} result(s):
|
|
1307
|
+
${files.join("\n")}` }] };
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
);
|
|
1311
|
+
server.tool(
|
|
1312
|
+
"ssh_tail",
|
|
1313
|
+
"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.",
|
|
1314
|
+
{
|
|
1315
|
+
...connectionParams,
|
|
1316
|
+
path: z.string().describe("Absolute path to the file to tail"),
|
|
1317
|
+
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1318
|
+
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1319
|
+
timeout: TimeoutSchema
|
|
1320
|
+
},
|
|
1321
|
+
async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
|
|
1322
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1323
|
+
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1324
|
+
if (!output.trim()) {
|
|
1325
|
+
return {
|
|
1326
|
+
content: [
|
|
1327
|
+
{
|
|
1328
|
+
type: "text",
|
|
1329
|
+
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
|
|
1330
|
+
}
|
|
1331
|
+
]
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return { content: [{ type: "text", text: output }] };
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
);
|
|
1338
|
+
server.tool(
|
|
1339
|
+
"ssh_service_status",
|
|
1340
|
+
"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.",
|
|
1341
|
+
{
|
|
1342
|
+
...connectionParams,
|
|
1343
|
+
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1344
|
+
timeout: TimeoutSchema
|
|
1345
|
+
},
|
|
1346
|
+
async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
|
|
1347
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1348
|
+
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1349
|
+
const lines = [];
|
|
1350
|
+
lines.push(`Service: ${status.name}`);
|
|
1351
|
+
lines.push(`Status: ${status.status}`);
|
|
1352
|
+
if (status.description) lines.push(`Description: ${status.description}`);
|
|
1353
|
+
if (status.pid) lines.push(`PID: ${status.pid}`);
|
|
1354
|
+
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1355
|
+
lines.push("");
|
|
1356
|
+
lines.push(status.raw);
|
|
1357
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
);
|
|
1114
1361
|
}
|
|
1115
1362
|
|
|
1116
1363
|
// src/server.ts
|
|
1117
1364
|
function createServer(pool) {
|
|
1118
1365
|
const server = new McpServer({
|
|
1119
1366
|
name: "ssh-mcp",
|
|
1120
|
-
version: "0.
|
|
1367
|
+
version: "0.5.0"
|
|
1121
1368
|
});
|
|
1122
1369
|
registerTools(server, pool);
|
|
1123
1370
|
return server;
|
|
@@ -1133,18 +1380,24 @@ export {
|
|
|
1133
1380
|
configLookup,
|
|
1134
1381
|
connect,
|
|
1135
1382
|
connectRaw,
|
|
1383
|
+
connectWithProxy,
|
|
1136
1384
|
createServer,
|
|
1137
1385
|
diagnose,
|
|
1138
1386
|
downloadFile,
|
|
1139
1387
|
ensureAgent,
|
|
1140
1388
|
exec,
|
|
1389
|
+
find,
|
|
1141
1390
|
fixKnownHosts,
|
|
1391
|
+
formatDiagnostics,
|
|
1142
1392
|
listDir,
|
|
1143
1393
|
listSshKeys,
|
|
1144
1394
|
loadKey,
|
|
1395
|
+
multiExec,
|
|
1145
1396
|
readFile,
|
|
1146
1397
|
registerTools,
|
|
1147
1398
|
resolveConfig,
|
|
1399
|
+
serviceStatus,
|
|
1400
|
+
tail,
|
|
1148
1401
|
testConnection,
|
|
1149
1402
|
uploadFile,
|
|
1150
1403
|
writeFile
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
},
|