@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/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/server.ts
7
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
-
9
- // src/tools.ts
10
- import { z } from "zod";
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 readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
243
- import { homedir as homedir2 } from "os";
244
- import { join as join2 } from "path";
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 = readFileSync2(pubPath, "utf8");
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 = readFileSync2(filePath, "utf8");
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 = join2(homedir2(), ".ssh");
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 = join2(sshDir, file);
717
+ const filePath = join3(sshDir, file);
335
718
  try {
336
719
  const stat = statSync(filePath);
337
720
  if (!stat.isFile()) continue;
338
- const content = readFileSync2(filePath, "utf8");
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("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
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 = join2(homedir2(), ".ssh", "known_hosts");
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/ssh.ts
519
- import { readFileSync as readFileSync3 } from "fs";
520
- import { homedir as homedir3 } from "os";
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 formatDiagnostics(host) {
550
- try {
551
- const checks = [
552
- { name: "SSH Agent", ...checkSshAgent() },
553
- { name: "SSH Keys", ...checkSshKeys() },
554
- { name: "SSH Config", ...checkSshConfig(host) },
555
- { name: "Known Hosts", ...checkKnownHosts(host) }
556
- ];
557
- const parts = [];
558
- const suggestions = [];
559
- for (const check of checks) {
560
- if (check.status !== "ok") {
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 parts.length > 0 ? parts.join("\n") : "";
575
- } catch {
576
- return "";
577
- }
578
- }
579
- function connect(config) {
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 readFile(client, remotePath) {
641
- const sftp = await getSftp(client);
642
- try {
643
- return await new Promise((resolve, reject) => {
644
- sftp.readFile(remotePath, (err, data) => {
645
- if (err) return reject(err);
646
- resolve(data.toString("utf8"));
647
- });
648
- });
649
- } finally {
650
- sftp.end();
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 writeFile(client, remotePath, content) {
654
- const sftp = await getSftp(client);
655
- try {
656
- await new Promise((resolve, reject) => {
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
- async function uploadFile(client, localPath, remotePath) {
667
- const sftp = await getSftp(client);
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 downloadFile(client, remotePath, localPath) {
680
- const sftp = await getSftp(client);
681
- try {
682
- await new Promise((resolve, reject) => {
683
- sftp.fastGet(remotePath, localPath, (err) => {
684
- if (err) return reject(err);
685
- resolve();
686
- });
687
- });
688
- } finally {
689
- sftp.end();
690
- }
691
- }
692
- async function listDir(client, remotePath) {
693
- const sftp = await getSftp(client);
694
- try {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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.3.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 server = createServer();
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) => {