@yawlabs/ssh-mcp 0.3.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 +43 -5
- package/dist/index.js +597 -228
- package/dist/server.d.ts +81 -17
- package/dist/server.js +446 -59
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
6
|
+
// src/ssh.ts
|
|
7
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
8
|
+
import { homedir as homedir2 } from "os";
|
|
9
|
+
import { join as join2 } from "path";
|
|
10
|
+
import { Client } from "ssh2";
|
|
11
11
|
|
|
12
12
|
// src/diagnose.ts
|
|
13
13
|
import { execFileSync } from "child_process";
|
|
@@ -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",
|
|
@@ -238,10 +257,359 @@ function diagnose(host, port = 22) {
|
|
|
238
257
|
return { overall, checks, suggestions };
|
|
239
258
|
}
|
|
240
259
|
|
|
260
|
+
// src/ssh.ts
|
|
261
|
+
function resolveFromSshConfig(host) {
|
|
262
|
+
try {
|
|
263
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
264
|
+
if (!ok) return null;
|
|
265
|
+
const config = {};
|
|
266
|
+
const identityFiles = [];
|
|
267
|
+
for (const line of stdout.split("\n")) {
|
|
268
|
+
const spaceIdx = line.indexOf(" ");
|
|
269
|
+
if (spaceIdx > 0) {
|
|
270
|
+
const key = line.substring(0, spaceIdx);
|
|
271
|
+
const value = line.substring(spaceIdx + 1);
|
|
272
|
+
if (key === "identityfile") {
|
|
273
|
+
identityFiles.push(value);
|
|
274
|
+
} else {
|
|
275
|
+
config[key] = value;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
hostname: config.hostname || host,
|
|
281
|
+
user: config.user || "",
|
|
282
|
+
port: config.port || "22",
|
|
283
|
+
identityFiles,
|
|
284
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
285
|
+
};
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function resolveConfig(config) {
|
|
291
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
292
|
+
const connectConfig = {
|
|
293
|
+
host: sshConfig?.hostname || config.host,
|
|
294
|
+
port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
|
|
295
|
+
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
296
|
+
keepaliveInterval: 15e3,
|
|
297
|
+
keepaliveCountMax: 3
|
|
298
|
+
};
|
|
299
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
300
|
+
if (agentSock) {
|
|
301
|
+
connectConfig.agent = agentSock;
|
|
302
|
+
}
|
|
303
|
+
if (config.password) {
|
|
304
|
+
connectConfig.password = config.password;
|
|
305
|
+
}
|
|
306
|
+
if (config.privateKeyPath) {
|
|
307
|
+
connectConfig.privateKey = readFileSync2(config.privateKeyPath);
|
|
308
|
+
} else if (!agentSock) {
|
|
309
|
+
const home = homedir2();
|
|
310
|
+
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join2(home, p.slice(1)) : p) : [join2(home, ".ssh", "id_ed25519"), join2(home, ".ssh", "id_rsa"), join2(home, ".ssh", "id_ecdsa")];
|
|
311
|
+
for (const keyPath of keyPaths) {
|
|
312
|
+
try {
|
|
313
|
+
connectConfig.privateKey = readFileSync2(keyPath);
|
|
314
|
+
break;
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
320
|
+
}
|
|
321
|
+
function formatDiagnostics(host) {
|
|
322
|
+
try {
|
|
323
|
+
const checks = [
|
|
324
|
+
{ name: "SSH Agent", ...checkSshAgent() },
|
|
325
|
+
{ name: "SSH Keys", ...checkSshKeys() },
|
|
326
|
+
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
327
|
+
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
328
|
+
];
|
|
329
|
+
const parts = [];
|
|
330
|
+
const suggestions = [];
|
|
331
|
+
for (const check of checks) {
|
|
332
|
+
if (check.status !== "ok") {
|
|
333
|
+
parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const agent = checks[0];
|
|
337
|
+
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
338
|
+
if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
|
|
339
|
+
const keys = checks[1];
|
|
340
|
+
if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
|
|
341
|
+
const known = checks[3];
|
|
342
|
+
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
343
|
+
if (suggestions.length > 0) {
|
|
344
|
+
parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
|
|
345
|
+
}
|
|
346
|
+
return parts.length > 0 ? parts.join("\n") : "";
|
|
347
|
+
} catch {
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function connectRaw(connectConfig) {
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
const client = new Client();
|
|
354
|
+
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
355
|
+
});
|
|
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
|
+
}
|
|
384
|
+
function exec(client, command, timeoutMs = 3e4) {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
let settled = false;
|
|
387
|
+
const settle = (fn) => {
|
|
388
|
+
if (settled) return;
|
|
389
|
+
settled = true;
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
fn();
|
|
392
|
+
};
|
|
393
|
+
const timer = setTimeout(() => {
|
|
394
|
+
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
395
|
+
}, timeoutMs);
|
|
396
|
+
client.exec(command, (err, stream) => {
|
|
397
|
+
if (err) {
|
|
398
|
+
settle(() => reject(err));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
let stdout = "";
|
|
402
|
+
let stderr = "";
|
|
403
|
+
stream.on("close", (code) => {
|
|
404
|
+
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
405
|
+
}).on("data", (data) => {
|
|
406
|
+
stdout += data.toString();
|
|
407
|
+
}).on("error", (err2) => {
|
|
408
|
+
settle(() => reject(err2));
|
|
409
|
+
});
|
|
410
|
+
stream.stderr.on("data", (data) => {
|
|
411
|
+
stderr += data.toString();
|
|
412
|
+
}).on("error", (err2) => {
|
|
413
|
+
settle(() => reject(err2));
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function getSftp(client) {
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
client.sftp((err, sftp) => {
|
|
421
|
+
if (err) return reject(err);
|
|
422
|
+
resolve(sftp);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
async function readFile(client, remotePath) {
|
|
427
|
+
const sftp = await getSftp(client);
|
|
428
|
+
try {
|
|
429
|
+
return await new Promise((resolve, reject) => {
|
|
430
|
+
sftp.readFile(remotePath, (err, data) => {
|
|
431
|
+
if (err) return reject(err);
|
|
432
|
+
resolve(data.toString("utf8"));
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
} finally {
|
|
436
|
+
sftp.end();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function writeFile(client, remotePath, content) {
|
|
440
|
+
const sftp = await getSftp(client);
|
|
441
|
+
try {
|
|
442
|
+
await new Promise((resolve, reject) => {
|
|
443
|
+
sftp.writeFile(remotePath, content, (err) => {
|
|
444
|
+
if (err) return reject(err);
|
|
445
|
+
resolve();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
} finally {
|
|
449
|
+
sftp.end();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function uploadFile(client, localPath, remotePath) {
|
|
453
|
+
const sftp = await getSftp(client);
|
|
454
|
+
try {
|
|
455
|
+
await new Promise((resolve, reject) => {
|
|
456
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
457
|
+
if (err) return reject(err);
|
|
458
|
+
resolve();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
} finally {
|
|
462
|
+
sftp.end();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async function downloadFile(client, remotePath, localPath) {
|
|
466
|
+
const sftp = await getSftp(client);
|
|
467
|
+
try {
|
|
468
|
+
await new Promise((resolve, reject) => {
|
|
469
|
+
sftp.fastGet(remotePath, localPath, (err) => {
|
|
470
|
+
if (err) return reject(err);
|
|
471
|
+
resolve();
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
} finally {
|
|
475
|
+
sftp.end();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function listDir(client, remotePath) {
|
|
479
|
+
const sftp = await getSftp(client);
|
|
480
|
+
try {
|
|
481
|
+
return await new Promise((resolve, reject) => {
|
|
482
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
483
|
+
if (err) return reject(err);
|
|
484
|
+
resolve(list.map((item) => item.filename));
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
} finally {
|
|
488
|
+
sftp.end();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/pool.ts
|
|
493
|
+
var ConnectionPool = class {
|
|
494
|
+
entries = /* @__PURE__ */ new Map();
|
|
495
|
+
idleTtlMs;
|
|
496
|
+
constructor(options) {
|
|
497
|
+
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
498
|
+
}
|
|
499
|
+
async acquire(config) {
|
|
500
|
+
const resolved = resolveConfig(config);
|
|
501
|
+
const cc = resolved.connectConfig;
|
|
502
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
503
|
+
const existing = this.entries.get(key);
|
|
504
|
+
if (existing && !existing.dead) {
|
|
505
|
+
existing.refCount++;
|
|
506
|
+
if (existing.idleTimer) {
|
|
507
|
+
clearTimeout(existing.idleTimer);
|
|
508
|
+
existing.idleTimer = null;
|
|
509
|
+
}
|
|
510
|
+
return existing.client;
|
|
511
|
+
}
|
|
512
|
+
if (existing?.dead) {
|
|
513
|
+
this.entries.delete(key);
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const client = await connectWithProxy(resolved);
|
|
517
|
+
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
518
|
+
const markDead = () => {
|
|
519
|
+
entry.dead = true;
|
|
520
|
+
if (entry.idleTimer) {
|
|
521
|
+
clearTimeout(entry.idleTimer);
|
|
522
|
+
entry.idleTimer = null;
|
|
523
|
+
}
|
|
524
|
+
if (this.entries.get(key) === entry) {
|
|
525
|
+
this.entries.delete(key);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
client.on("close", markDead);
|
|
529
|
+
client.on("end", markDead);
|
|
530
|
+
client.on("error", markDead);
|
|
531
|
+
this.entries.set(key, entry);
|
|
532
|
+
return client;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
const diag = formatDiagnostics(config.host);
|
|
535
|
+
if (diag) {
|
|
536
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
537
|
+
const enhanced = new Error(`${message}
|
|
538
|
+
|
|
539
|
+
SSH Diagnostics:
|
|
540
|
+
${diag}`);
|
|
541
|
+
enhanced.cause = err;
|
|
542
|
+
throw enhanced;
|
|
543
|
+
}
|
|
544
|
+
throw err;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
release(client) {
|
|
548
|
+
for (const entry of this.entries.values()) {
|
|
549
|
+
if (entry.client === client) {
|
|
550
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
551
|
+
if (entry.refCount === 0 && !entry.dead) {
|
|
552
|
+
entry.idleTimer = setTimeout(() => {
|
|
553
|
+
try {
|
|
554
|
+
entry.client.end();
|
|
555
|
+
} catch {
|
|
556
|
+
}
|
|
557
|
+
this.entries.delete(entry.key);
|
|
558
|
+
}, this.idleTtlMs);
|
|
559
|
+
entry.idleTimer.unref();
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
client.end();
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async withConnection(config, fn) {
|
|
570
|
+
const client = await this.acquire(config);
|
|
571
|
+
try {
|
|
572
|
+
return await fn(client);
|
|
573
|
+
} finally {
|
|
574
|
+
this.release(client);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
drain() {
|
|
578
|
+
for (const entry of this.entries.values()) {
|
|
579
|
+
if (entry.idleTimer) {
|
|
580
|
+
clearTimeout(entry.idleTimer);
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
entry.client.end();
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.entries.clear();
|
|
588
|
+
}
|
|
589
|
+
get size() {
|
|
590
|
+
return this.entries.size;
|
|
591
|
+
}
|
|
592
|
+
get stats() {
|
|
593
|
+
let active = 0;
|
|
594
|
+
let idle = 0;
|
|
595
|
+
for (const entry of this.entries.values()) {
|
|
596
|
+
if (entry.refCount > 0) active++;
|
|
597
|
+
else idle++;
|
|
598
|
+
}
|
|
599
|
+
return { active, idle };
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// src/server.ts
|
|
604
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
605
|
+
|
|
606
|
+
// src/tools.ts
|
|
607
|
+
import { z } from "zod";
|
|
608
|
+
|
|
241
609
|
// src/env.ts
|
|
242
|
-
import { appendFileSync, existsSync as existsSync2, readFileSync as
|
|
243
|
-
import { homedir as
|
|
244
|
-
import { join as
|
|
610
|
+
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
|
|
611
|
+
import { homedir as homedir3 } from "os";
|
|
612
|
+
import { join as join3 } from "path";
|
|
245
613
|
function ensureAgent() {
|
|
246
614
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
247
615
|
if (sock) {
|
|
@@ -259,6 +627,21 @@ function ensureAgent() {
|
|
|
259
627
|
};
|
|
260
628
|
}
|
|
261
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
|
+
}
|
|
262
645
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
263
646
|
if (ok) {
|
|
264
647
|
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
@@ -282,14 +665,14 @@ function ensureAgent() {
|
|
|
282
665
|
reachable: false,
|
|
283
666
|
keys: [],
|
|
284
667
|
started: false,
|
|
285
|
-
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)"'
|
|
286
669
|
};
|
|
287
670
|
}
|
|
288
671
|
function detectKeyType(filePath, fileName) {
|
|
289
672
|
const pubPath = `${filePath}.pub`;
|
|
290
673
|
if (existsSync2(pubPath)) {
|
|
291
674
|
try {
|
|
292
|
-
const pub =
|
|
675
|
+
const pub = readFileSync3(pubPath, "utf8");
|
|
293
676
|
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
294
677
|
if (pub.includes("ssh-rsa")) return "rsa";
|
|
295
678
|
if (pub.includes("ecdsa")) return "ecdsa";
|
|
@@ -302,7 +685,7 @@ function detectKeyType(filePath, fileName) {
|
|
|
302
685
|
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
303
686
|
if (fileName.includes("dsa")) return "dsa";
|
|
304
687
|
try {
|
|
305
|
-
const content =
|
|
688
|
+
const content = readFileSync3(filePath, "utf8");
|
|
306
689
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
307
690
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
308
691
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
@@ -311,7 +694,7 @@ function detectKeyType(filePath, fileName) {
|
|
|
311
694
|
return "unknown";
|
|
312
695
|
}
|
|
313
696
|
function listSshKeys() {
|
|
314
|
-
const sshDir =
|
|
697
|
+
const sshDir = join3(homedir3(), ".ssh");
|
|
315
698
|
if (!existsSync2(sshDir)) return [];
|
|
316
699
|
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
317
700
|
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
@@ -331,11 +714,11 @@ function listSshKeys() {
|
|
|
331
714
|
}
|
|
332
715
|
for (const file of files) {
|
|
333
716
|
if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
|
|
334
|
-
const filePath =
|
|
717
|
+
const filePath = join3(sshDir, file);
|
|
335
718
|
try {
|
|
336
719
|
const stat = statSync(filePath);
|
|
337
720
|
if (!stat.isFile()) continue;
|
|
338
|
-
const content =
|
|
721
|
+
const content = readFileSync3(filePath, "utf8");
|
|
339
722
|
if (!content.includes("PRIVATE KEY")) continue;
|
|
340
723
|
const type = detectKeyType(filePath, file);
|
|
341
724
|
let fingerprint;
|
|
@@ -356,7 +739,7 @@ function loadKey(keyPath) {
|
|
|
356
739
|
if (!agent.reachable) {
|
|
357
740
|
return { status: "error", message: agent.message };
|
|
358
741
|
}
|
|
359
|
-
const resolved = keyPath.startsWith("~") ?
|
|
742
|
+
const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
|
|
360
743
|
if (!existsSync2(resolved)) {
|
|
361
744
|
return { status: "error", message: `Key not found: ${resolved}` };
|
|
362
745
|
}
|
|
@@ -422,7 +805,7 @@ function fixKnownHosts(host, port = 22) {
|
|
|
422
805
|
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
423
806
|
if (scanOk && scanOut.trim()) {
|
|
424
807
|
try {
|
|
425
|
-
const knownHostsPath =
|
|
808
|
+
const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
|
|
426
809
|
appendFileSync(knownHostsPath, `
|
|
427
810
|
${scanOut.trim()}
|
|
428
811
|
`);
|
|
@@ -515,192 +898,71 @@ function testConnection(host, port = 22) {
|
|
|
515
898
|
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
516
899
|
}
|
|
517
900
|
|
|
518
|
-
// src/
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
import { join as join3 } from "path";
|
|
522
|
-
import { Client } from "ssh2";
|
|
523
|
-
function resolveConfig(config) {
|
|
524
|
-
const connectConfig = {
|
|
525
|
-
host: config.host,
|
|
526
|
-
port: config.port || 22,
|
|
527
|
-
username: config.username || process.env.USER || process.env.USERNAME || "root"
|
|
528
|
-
};
|
|
529
|
-
if (config.privateKeyPath) {
|
|
530
|
-
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
531
|
-
} else if (config.password) {
|
|
532
|
-
connectConfig.password = config.password;
|
|
533
|
-
} else if (config.agent || process.env.SSH_AUTH_SOCK) {
|
|
534
|
-
connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
|
|
535
|
-
} else {
|
|
536
|
-
const home = homedir3();
|
|
537
|
-
const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
|
|
538
|
-
for (const keyName of defaultKeys) {
|
|
539
|
-
const keyPath = join3(home, ".ssh", keyName);
|
|
540
|
-
try {
|
|
541
|
-
connectConfig.privateKey = readFileSync3(keyPath);
|
|
542
|
-
break;
|
|
543
|
-
} catch {
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
return connectConfig;
|
|
901
|
+
// src/ops.ts
|
|
902
|
+
function shellQuote(s) {
|
|
903
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
548
904
|
}
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
const agent = checks[0];
|
|
565
|
-
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
566
|
-
if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
|
|
567
|
-
const keys = checks[1];
|
|
568
|
-
if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
|
|
569
|
-
const known = checks[3];
|
|
570
|
-
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
571
|
-
if (suggestions.length > 0) {
|
|
572
|
-
parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
|
|
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;
|
|
573
917
|
}
|
|
574
|
-
return
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
return new Promise((resolve, reject) => {
|
|
581
|
-
const client = new Client();
|
|
582
|
-
const connectConfig = resolveConfig(config);
|
|
583
|
-
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
584
|
-
const diag = formatDiagnostics(config.host);
|
|
585
|
-
if (diag) {
|
|
586
|
-
const enhanced = new Error(`${err.message}
|
|
587
|
-
|
|
588
|
-
SSH Diagnostics:
|
|
589
|
-
${diag}`);
|
|
590
|
-
enhanced.cause = err;
|
|
591
|
-
reject(enhanced);
|
|
592
|
-
} else {
|
|
593
|
-
reject(err);
|
|
594
|
-
}
|
|
595
|
-
}).connect(connectConfig);
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
function exec(client, command, timeoutMs = 3e4) {
|
|
599
|
-
return new Promise((resolve, reject) => {
|
|
600
|
-
let settled = false;
|
|
601
|
-
const settle = (fn) => {
|
|
602
|
-
if (settled) return;
|
|
603
|
-
settled = true;
|
|
604
|
-
clearTimeout(timer);
|
|
605
|
-
fn();
|
|
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)
|
|
606
924
|
};
|
|
607
|
-
const timer = setTimeout(() => {
|
|
608
|
-
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
609
|
-
}, timeoutMs);
|
|
610
|
-
client.exec(command, (err, stream) => {
|
|
611
|
-
if (err) {
|
|
612
|
-
settle(() => reject(err));
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
let stdout = "";
|
|
616
|
-
let stderr = "";
|
|
617
|
-
stream.on("close", (code) => {
|
|
618
|
-
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
619
|
-
}).on("data", (data) => {
|
|
620
|
-
stdout += data.toString();
|
|
621
|
-
}).on("error", (err2) => {
|
|
622
|
-
settle(() => reject(err2));
|
|
623
|
-
});
|
|
624
|
-
stream.stderr.on("data", (data) => {
|
|
625
|
-
stderr += data.toString();
|
|
626
|
-
}).on("error", (err2) => {
|
|
627
|
-
settle(() => reject(err2));
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
function getSftp(client) {
|
|
633
|
-
return new Promise((resolve, reject) => {
|
|
634
|
-
client.sftp((err, sftp) => {
|
|
635
|
-
if (err) return reject(err);
|
|
636
|
-
resolve(sftp);
|
|
637
|
-
});
|
|
638
925
|
});
|
|
639
926
|
}
|
|
640
|
-
async function
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
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);
|
|
652
938
|
}
|
|
653
|
-
async function
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
sftp.writeFile(remotePath, content, (err) => {
|
|
658
|
-
if (err) return reject(err);
|
|
659
|
-
resolve();
|
|
660
|
-
});
|
|
661
|
-
});
|
|
662
|
-
} finally {
|
|
663
|
-
sftp.end();
|
|
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)}`;
|
|
664
943
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
try {
|
|
669
|
-
await new Promise((resolve, reject) => {
|
|
670
|
-
sftp.fastPut(localPath, remotePath, (err) => {
|
|
671
|
-
if (err) return reject(err);
|
|
672
|
-
resolve();
|
|
673
|
-
});
|
|
674
|
-
});
|
|
675
|
-
} finally {
|
|
676
|
-
sftp.end();
|
|
944
|
+
const result = await exec(client, command, timeoutMs);
|
|
945
|
+
if (result.code !== 0 && result.stderr && !grep) {
|
|
946
|
+
throw new Error(result.stderr.trim());
|
|
677
947
|
}
|
|
948
|
+
return result.stdout;
|
|
678
949
|
}
|
|
679
|
-
async function
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
return await new Promise((resolve, reject) => {
|
|
696
|
-
sftp.readdir(remotePath, (err, list) => {
|
|
697
|
-
if (err) return reject(err);
|
|
698
|
-
resolve(list.map((item) => item.filename));
|
|
699
|
-
});
|
|
700
|
-
});
|
|
701
|
-
} finally {
|
|
702
|
-
sftp.end();
|
|
703
|
-
}
|
|
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
|
+
};
|
|
704
966
|
}
|
|
705
967
|
|
|
706
968
|
// src/tools.ts
|
|
@@ -717,7 +979,8 @@ var connectionParams = {
|
|
|
717
979
|
privateKeyPath: KeyPathSchema,
|
|
718
980
|
password: PasswordSchema
|
|
719
981
|
};
|
|
720
|
-
function registerTools(server) {
|
|
982
|
+
function registerTools(server, pool) {
|
|
983
|
+
const connectionPool = pool ?? new ConnectionPool();
|
|
721
984
|
server.tool(
|
|
722
985
|
"ssh_exec",
|
|
723
986
|
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
@@ -727,8 +990,7 @@ function registerTools(server) {
|
|
|
727
990
|
timeout: TimeoutSchema
|
|
728
991
|
},
|
|
729
992
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
730
|
-
|
|
731
|
-
try {
|
|
993
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
732
994
|
const result = await exec(client, command, timeout || 3e4);
|
|
733
995
|
const parts = [];
|
|
734
996
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -736,9 +998,7 @@ function registerTools(server) {
|
|
|
736
998
|
${result.stderr}`);
|
|
737
999
|
parts.push(`[exit code: ${result.code}]`);
|
|
738
1000
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
739
|
-
}
|
|
740
|
-
client.end();
|
|
741
|
-
}
|
|
1001
|
+
});
|
|
742
1002
|
}
|
|
743
1003
|
);
|
|
744
1004
|
server.tool(
|
|
@@ -749,13 +1009,10 @@ ${result.stderr}`);
|
|
|
749
1009
|
path: z.string().describe("Absolute path to the remote file")
|
|
750
1010
|
},
|
|
751
1011
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
752
|
-
|
|
753
|
-
try {
|
|
1012
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
754
1013
|
const content = await readFile(client, path);
|
|
755
1014
|
return { content: [{ type: "text", text: content }] };
|
|
756
|
-
}
|
|
757
|
-
client.end();
|
|
758
|
-
}
|
|
1015
|
+
});
|
|
759
1016
|
}
|
|
760
1017
|
);
|
|
761
1018
|
server.tool(
|
|
@@ -767,13 +1024,10 @@ ${result.stderr}`);
|
|
|
767
1024
|
content: z.string().describe("File content to write")
|
|
768
1025
|
},
|
|
769
1026
|
async ({ host, port, username, privateKeyPath, password, path, content }) => {
|
|
770
|
-
|
|
771
|
-
try {
|
|
1027
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
772
1028
|
await writeFile(client, path, content);
|
|
773
1029
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
774
|
-
}
|
|
775
|
-
client.end();
|
|
776
|
-
}
|
|
1030
|
+
});
|
|
777
1031
|
}
|
|
778
1032
|
);
|
|
779
1033
|
server.tool(
|
|
@@ -785,13 +1039,10 @@ ${result.stderr}`);
|
|
|
785
1039
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
786
1040
|
},
|
|
787
1041
|
async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
|
|
788
|
-
|
|
789
|
-
try {
|
|
1042
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
790
1043
|
await uploadFile(client, localPath, remotePath);
|
|
791
1044
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
792
|
-
}
|
|
793
|
-
client.end();
|
|
794
|
-
}
|
|
1045
|
+
});
|
|
795
1046
|
}
|
|
796
1047
|
);
|
|
797
1048
|
server.tool(
|
|
@@ -803,13 +1054,10 @@ ${result.stderr}`);
|
|
|
803
1054
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
804
1055
|
},
|
|
805
1056
|
async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
|
|
806
|
-
|
|
807
|
-
try {
|
|
1057
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
808
1058
|
await downloadFile(client, remotePath, localPath);
|
|
809
1059
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
810
|
-
}
|
|
811
|
-
client.end();
|
|
812
|
-
}
|
|
1060
|
+
});
|
|
813
1061
|
}
|
|
814
1062
|
);
|
|
815
1063
|
server.tool(
|
|
@@ -820,13 +1068,10 @@ ${result.stderr}`);
|
|
|
820
1068
|
path: z.string().describe("Absolute path to the remote directory")
|
|
821
1069
|
},
|
|
822
1070
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
823
|
-
|
|
824
|
-
try {
|
|
1071
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
825
1072
|
const files = await listDir(client, path);
|
|
826
1073
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
827
|
-
}
|
|
828
|
-
client.end();
|
|
829
|
-
}
|
|
1074
|
+
});
|
|
830
1075
|
}
|
|
831
1076
|
);
|
|
832
1077
|
server.tool(
|
|
@@ -985,22 +1230,146 @@ ${result.stderr}`);
|
|
|
985
1230
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
986
1231
|
}
|
|
987
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
|
+
);
|
|
988
1350
|
}
|
|
989
1351
|
|
|
990
1352
|
// src/server.ts
|
|
991
|
-
function createServer() {
|
|
1353
|
+
function createServer(pool) {
|
|
992
1354
|
const server = new McpServer({
|
|
993
1355
|
name: "ssh-mcp",
|
|
994
|
-
version: "0.
|
|
1356
|
+
version: "0.5.0"
|
|
995
1357
|
});
|
|
996
|
-
registerTools(server);
|
|
1358
|
+
registerTools(server, pool);
|
|
997
1359
|
return server;
|
|
998
1360
|
}
|
|
999
1361
|
|
|
1000
1362
|
// src/index.ts
|
|
1001
1363
|
async function main() {
|
|
1002
|
-
const
|
|
1364
|
+
const pool = new ConnectionPool();
|
|
1365
|
+
const server = createServer(pool);
|
|
1003
1366
|
const transport = new StdioServerTransport();
|
|
1367
|
+
const shutdown = () => {
|
|
1368
|
+
pool.drain();
|
|
1369
|
+
process.exit(0);
|
|
1370
|
+
};
|
|
1371
|
+
process.on("SIGINT", shutdown);
|
|
1372
|
+
process.on("SIGTERM", shutdown);
|
|
1004
1373
|
await server.connect(transport);
|
|
1005
1374
|
}
|
|
1006
1375
|
main().catch((err) => {
|