@yawlabs/ssh-mcp 0.8.0 → 0.9.1

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,15 +3,15 @@
3
3
  // src/index.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
- // src/ssh.ts
7
- import { readFileSync as readFileSync2 } from "fs";
6
+ // src/env.ts
7
+ import { execFileSync as execFileSync2 } from "child_process";
8
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
8
9
  import { homedir as homedir2 } from "os";
9
10
  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";
14
- import { existsSync, readFileSync, readdirSync } from "fs";
14
+ import { existsSync, readdirSync, readFileSync } from "fs";
15
15
  import { homedir } from "os";
16
16
  import { join } from "path";
17
17
  function isValidHostname(host) {
@@ -19,7 +19,7 @@ function isValidHostname(host) {
19
19
  if (host.startsWith("[")) {
20
20
  return /^\[[0-9a-fA-F:]+\]$/.test(host);
21
21
  }
22
- return /^[a-zA-Z0-9._\-]+$/.test(host);
22
+ return /^[a-zA-Z0-9._-]+$/.test(host);
23
23
  }
24
24
  function runArgs(cmd, args) {
25
25
  try {
@@ -260,31 +260,358 @@ function diagnose(host, port = 22) {
260
260
  return { overall, checks, suggestions };
261
261
  }
262
262
 
263
+ // src/ssh-config.ts
264
+ function parseSshConfigOutput(stdout) {
265
+ const all = {};
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
+ all[key] = value;
276
+ }
277
+ }
278
+ }
279
+ return { all, identityFiles };
280
+ }
281
+
282
+ // src/env.ts
283
+ function runArgsWithEnv(cmd, args, extraEnv) {
284
+ const env = {};
285
+ for (const [k, v] of Object.entries(process.env)) {
286
+ if (typeof v === "string") env[k] = v;
287
+ }
288
+ for (const [k, v] of Object.entries(extraEnv)) {
289
+ if (v === void 0) {
290
+ delete env[k];
291
+ } else {
292
+ env[k] = v;
293
+ }
294
+ }
295
+ try {
296
+ const stdout = execFileSync2(cmd, args, {
297
+ env,
298
+ encoding: "utf8",
299
+ timeout: 1e4,
300
+ stdio: ["pipe", "pipe", "pipe"]
301
+ });
302
+ return { stdout: stdout.trim(), ok: true };
303
+ } catch (e) {
304
+ const err = e;
305
+ const so = err.stdout?.toString().trim() || "";
306
+ const se = err.stderr?.toString().trim() || "";
307
+ const output = [so, se].filter(Boolean).join("\n") || err.message || "";
308
+ return { stdout: output, ok: false };
309
+ }
310
+ }
311
+ function probeAgent(socket, agentLabel) {
312
+ const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
313
+ const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
314
+ const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
315
+ const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
316
+ if (!ok && !noIdentities) return null;
317
+ const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
318
+ return {
319
+ running: true,
320
+ reachable: true,
321
+ socket,
322
+ keys,
323
+ started: false,
324
+ message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
325
+ };
326
+ }
327
+ var startedAgentPid = null;
328
+ function killStartedAgent() {
329
+ if (startedAgentPid === null) return;
330
+ try {
331
+ process.kill(startedAgentPid);
332
+ } catch {
333
+ }
334
+ startedAgentPid = null;
335
+ }
336
+ function ensureAgent() {
337
+ const sock = process.env.SSH_AUTH_SOCK;
338
+ if (sock) {
339
+ const result = probeAgent(sock, "ssh-agent");
340
+ if (result) return result;
341
+ }
342
+ if (!sock && process.platform === "win32") {
343
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
344
+ if (result) return result;
345
+ }
346
+ const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
347
+ if (ok) {
348
+ const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
349
+ const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
350
+ if (sockMatch) {
351
+ process.env.SSH_AUTH_SOCK = sockMatch[1];
352
+ if (pidMatch) {
353
+ process.env.SSH_AGENT_PID = pidMatch[1];
354
+ startedAgentPid = Number.parseInt(pidMatch[1], 10);
355
+ }
356
+ return {
357
+ running: true,
358
+ reachable: true,
359
+ socket: sockMatch[1],
360
+ keys: [],
361
+ started: true,
362
+ env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
363
+ message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
364
+ };
365
+ }
366
+ }
367
+ return {
368
+ running: false,
369
+ reachable: false,
370
+ keys: [],
371
+ started: false,
372
+ 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)"'
373
+ };
374
+ }
375
+ function detectKeyType(filePath, fileName) {
376
+ const pubPath = `${filePath}.pub`;
377
+ if (existsSync2(pubPath)) {
378
+ try {
379
+ const pub = readFileSync2(pubPath, "utf8");
380
+ if (pub.includes("ssh-ed25519")) return "ed25519";
381
+ if (pub.includes("ssh-rsa")) return "rsa";
382
+ if (pub.includes("ecdsa")) return "ecdsa";
383
+ if (pub.includes("ssh-dss")) return "dsa";
384
+ } catch {
385
+ }
386
+ }
387
+ if (fileName.includes("ed25519")) return "ed25519";
388
+ if (fileName.includes("rsa")) return "rsa";
389
+ if (fileName.includes("ecdsa")) return "ecdsa";
390
+ if (fileName.includes("dsa")) return "dsa";
391
+ try {
392
+ const content = readFileSync2(filePath, "utf8");
393
+ if (content.includes("RSA PRIVATE KEY")) return "rsa";
394
+ if (content.includes("EC PRIVATE KEY")) return "ecdsa";
395
+ if (content.includes("DSA PRIVATE KEY")) return "dsa";
396
+ if (content.includes("OPENSSH PRIVATE KEY")) {
397
+ const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
398
+ if (ok) {
399
+ const match = stdout.match(/\(([^)]+)\)\s*$/);
400
+ if (match) return match[1].toLowerCase();
401
+ }
402
+ }
403
+ } catch {
404
+ }
405
+ return "unknown";
406
+ }
407
+ function listSshKeys() {
408
+ const sshDir = join2(homedir2(), ".ssh");
409
+ if (!existsSync2(sshDir)) return [];
410
+ const loadedFingerprints = /* @__PURE__ */ new Set();
411
+ const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
412
+ if (agentOk && !agentOut.includes("no identities")) {
413
+ for (const line of agentOut.split("\n").filter(Boolean)) {
414
+ const match = line.match(/(\S+:\S+)/);
415
+ if (match) loadedFingerprints.add(match[1]);
416
+ }
417
+ }
418
+ const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
419
+ const keys = [];
420
+ let files;
421
+ try {
422
+ files = readdirSync2(sshDir);
423
+ } catch {
424
+ return [];
425
+ }
426
+ for (const file of files) {
427
+ if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
428
+ const filePath = join2(sshDir, file);
429
+ try {
430
+ const stat = statSync(filePath);
431
+ if (!stat.isFile()) continue;
432
+ const content = readFileSync2(filePath, "utf8");
433
+ if (!content.includes("PRIVATE KEY")) continue;
434
+ const type = detectKeyType(filePath, file);
435
+ let fingerprint;
436
+ const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
437
+ if (fpOk) {
438
+ const match = fpOut.match(/(\S+:\S+)/);
439
+ fingerprint = match?.[1];
440
+ }
441
+ const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
442
+ keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
443
+ } catch {
444
+ }
445
+ }
446
+ return keys;
447
+ }
448
+ function loadKey(keyPath) {
449
+ const agent = ensureAgent();
450
+ if (!agent.reachable) {
451
+ return { status: "error", message: agent.message };
452
+ }
453
+ const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
454
+ if (!existsSync2(resolved)) {
455
+ return { status: "error", message: `Key not found: ${resolved}` };
456
+ }
457
+ const { stdout, ok } = runArgs("ssh-add", [resolved]);
458
+ if (ok) {
459
+ return { status: "ok", message: `Key loaded: ${resolved}` };
460
+ }
461
+ if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
462
+ if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
463
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
464
+ }
465
+ return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
466
+ }
467
+ return { status: "error", message: `Failed to load key: ${stdout}` };
468
+ }
469
+ function configLookup(host) {
470
+ if (!isValidHostname(host)) {
471
+ return { error: `Invalid hostname: "${host}"` };
472
+ }
473
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
474
+ if (!ok) {
475
+ return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
476
+ }
477
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
478
+ return {
479
+ hostname: all.hostname || host,
480
+ user: all.user || "",
481
+ port: all.port || "22",
482
+ identityFile: identityFiles,
483
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
484
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
485
+ all,
486
+ raw: stdout
487
+ };
488
+ }
489
+ function fixKnownHosts(host, port = 22) {
490
+ if (!isValidHostname(host)) {
491
+ return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
492
+ }
493
+ const actions = [];
494
+ const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
495
+ if (removeOk) {
496
+ actions.push(`Removed old host key for ${host}`);
497
+ }
498
+ if (port !== 22) {
499
+ const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
500
+ if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
501
+ }
502
+ const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
503
+ const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
504
+ if (scanOk && scanOut.trim()) {
505
+ try {
506
+ const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
507
+ appendFileSync(knownHostsPath, `
508
+ ${scanOut.trim()}
509
+ `);
510
+ actions.push(`Added new host key for ${host}`);
511
+ return { status: "ok", message: `Host key refreshed for ${host}`, actions };
512
+ } catch (e) {
513
+ const msg = e instanceof Error ? e.message : String(e);
514
+ return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
515
+ }
516
+ }
517
+ return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
518
+ }
519
+ function checkGitSsh(host = "github.com", user = "git") {
520
+ if (!isValidHostname(host)) {
521
+ return { status: "error", message: `Invalid hostname: "${host}"` };
522
+ }
523
+ const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
524
+ const text = stdout;
525
+ if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
526
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
527
+ return {
528
+ status: "ok",
529
+ message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
530
+ authenticatedAs: userMatch?.[1]
531
+ };
532
+ }
533
+ if (text.includes("Permission denied")) {
534
+ return {
535
+ status: "error",
536
+ message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
537
+ };
538
+ }
539
+ if (text.includes("Connection refused")) {
540
+ return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
541
+ }
542
+ if (text.includes("timed out") || text.includes("Connection timed out")) {
543
+ return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
544
+ }
545
+ if (text.includes("Could not resolve")) {
546
+ return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
547
+ }
548
+ return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
549
+ }
550
+ function testConnection(host, port = 22) {
551
+ if (!isValidHostname(host)) {
552
+ return { status: "error", message: `Invalid hostname: "${host}"` };
553
+ }
554
+ const start = Date.now();
555
+ const { ok, stdout } = runArgs("ssh", [
556
+ "-o",
557
+ "ConnectTimeout=5",
558
+ "-o",
559
+ "BatchMode=yes",
560
+ "-o",
561
+ "StrictHostKeyChecking=no",
562
+ "-p",
563
+ String(port),
564
+ host,
565
+ "echo",
566
+ "SSH_OK"
567
+ ]);
568
+ const elapsed = Date.now() - start;
569
+ if (ok && stdout.includes("SSH_OK")) {
570
+ return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
571
+ }
572
+ if (stdout.includes("Permission denied")) {
573
+ return {
574
+ status: "error",
575
+ message: `Authentication failed to ${host}:${port} (${elapsed}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
576
+ };
577
+ }
578
+ if (stdout.includes("Connection refused")) {
579
+ return {
580
+ status: "error",
581
+ message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
582
+ };
583
+ }
584
+ if (stdout.includes("timed out")) {
585
+ return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
586
+ }
587
+ if (stdout.includes("Host key verification failed")) {
588
+ return {
589
+ status: "error",
590
+ message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
591
+ };
592
+ }
593
+ if (stdout.includes("Could not resolve")) {
594
+ return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
595
+ }
596
+ return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
597
+ }
598
+
263
599
  // src/ssh.ts
600
+ import { readFileSync as readFileSync3 } from "fs";
601
+ import { homedir as homedir3 } from "os";
602
+ import { join as join3 } from "path";
603
+ import { Client } from "ssh2";
264
604
  function resolveFromSshConfig(host) {
265
605
  try {
266
606
  const { stdout, ok } = runArgs("ssh", ["-G", host]);
267
607
  if (!ok) return null;
268
- const config = {};
269
- const identityFiles = [];
270
- for (const line of stdout.split("\n")) {
271
- const spaceIdx = line.indexOf(" ");
272
- if (spaceIdx > 0) {
273
- const key = line.substring(0, spaceIdx);
274
- const value = line.substring(spaceIdx + 1);
275
- if (key === "identityfile") {
276
- identityFiles.push(value);
277
- } else {
278
- config[key] = value;
279
- }
280
- }
281
- }
608
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
282
609
  return {
283
- hostname: config.hostname || host,
284
- user: config.user || "",
285
- port: config.port || "22",
610
+ hostname: all.hostname || host,
611
+ user: all.user || "",
612
+ port: all.port || "22",
286
613
  identityFiles,
287
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
614
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
288
615
  };
289
616
  } catch {
290
617
  return null;
@@ -336,7 +663,7 @@ function resolveConfig(config) {
336
663
  hostVerifier: buildHostVerifier(verifierHosts, port)
337
664
  };
338
665
  if (config.privateKeyPath) {
339
- connectConfig.privateKey = readFileSync2(config.privateKeyPath);
666
+ connectConfig.privateKey = readFileSync3(config.privateKeyPath);
340
667
  } else if (config.password) {
341
668
  connectConfig.password = config.password;
342
669
  } else {
@@ -344,11 +671,11 @@ function resolveConfig(config) {
344
671
  if (agentSock) {
345
672
  connectConfig.agent = agentSock;
346
673
  } else {
347
- const home = homedir2();
348
- 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")];
674
+ const home = homedir3();
675
+ const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
349
676
  for (const keyPath of keyPaths) {
350
677
  try {
351
- connectConfig.privateKey = readFileSync2(keyPath);
678
+ connectConfig.privateKey = readFileSync3(keyPath);
352
679
  break;
353
680
  } catch {
354
681
  }
@@ -445,6 +772,7 @@ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
445
772
  function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
446
773
  return new Promise((resolve, reject) => {
447
774
  let settled = false;
775
+ let activeStream = null;
448
776
  const settle = (fn) => {
449
777
  if (settled) return;
450
778
  settled = true;
@@ -452,6 +780,16 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
452
780
  fn();
453
781
  };
454
782
  const timer = setTimeout(() => {
783
+ if (activeStream) {
784
+ try {
785
+ activeStream.signal("TERM");
786
+ } catch {
787
+ }
788
+ try {
789
+ activeStream.close();
790
+ } catch {
791
+ }
792
+ }
455
793
  settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
456
794
  }, timeoutMs);
457
795
  client.exec(command, (err, stream) => {
@@ -459,6 +797,7 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
459
797
  settle(() => reject(err));
460
798
  return;
461
799
  }
800
+ activeStream = stream;
462
801
  const stdoutChunks = [];
463
802
  const stderrChunks = [];
464
803
  let stdoutBytes = 0;
@@ -607,6 +946,10 @@ var ConnectionPool = class {
607
946
  // Total number of successful connects ever made by this pool. Useful for
608
947
  // introspection and for tests that want to prove connection reuse.
609
948
  _connectCount = 0;
949
+ // Once drained, the pool stays drained — new acquires reject and any in-flight
950
+ // factory closes the freshly-connected client instead of registering it.
951
+ // Consumers must construct a new pool to use again.
952
+ drained = false;
610
953
  constructor(options) {
611
954
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
612
955
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -618,6 +961,9 @@ var ConnectionPool = class {
618
961
  const MAX_ACQUIRE_ATTEMPTS = 3;
619
962
  let lastErr;
620
963
  for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
964
+ if (this.drained) {
965
+ throw new Error("ConnectionPool was drained");
966
+ }
621
967
  const existing = this.entries.get(key);
622
968
  if (existing && !existing.dead) {
623
969
  existing.refCount++;
@@ -653,6 +999,13 @@ var ConnectionPool = class {
653
999
  pending = (async () => {
654
1000
  try {
655
1001
  const client2 = await connectWithProxy(resolved);
1002
+ if (this.drained) {
1003
+ try {
1004
+ client2.end();
1005
+ } catch {
1006
+ }
1007
+ throw new Error("ConnectionPool was drained while connecting");
1008
+ }
656
1009
  this._connectCount++;
657
1010
  const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
658
1011
  const markDead = () => {
@@ -704,359 +1057,80 @@ ${diag}`);
704
1057
  }
705
1058
  return client;
706
1059
  }
707
- throw new Error(
708
- `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
709
- );
710
- }
711
- release(client) {
712
- for (const entry of this.entries.values()) {
713
- if (entry.client === client) {
714
- entry.refCount = Math.max(0, entry.refCount - 1);
715
- if (entry.refCount === 0 && !entry.dead) {
716
- entry.idleTimer = setTimeout(() => {
717
- try {
718
- entry.client.end();
719
- } catch {
720
- }
721
- this.entries.delete(entry.key);
722
- }, this.idleTtlMs);
723
- entry.idleTimer.unref();
724
- }
725
- return;
726
- }
727
- }
728
- try {
729
- client.end();
730
- } catch {
731
- }
732
- }
733
- async withConnection(config, fn) {
734
- const client = await this.acquire(config);
735
- try {
736
- return await fn(client);
737
- } finally {
738
- this.release(client);
739
- }
740
- }
741
- drain() {
742
- for (const entry of this.entries.values()) {
743
- if (entry.idleTimer) {
744
- clearTimeout(entry.idleTimer);
745
- }
746
- try {
747
- entry.client.end();
748
- } catch {
749
- }
750
- }
751
- this.entries.clear();
752
- }
753
- get size() {
754
- return this.entries.size;
755
- }
756
- get stats() {
757
- let active = 0;
758
- let idle = 0;
759
- for (const entry of this.entries.values()) {
760
- if (entry.refCount > 0) active++;
761
- else idle++;
762
- }
763
- return { active, idle };
764
- }
765
- /** Total number of successful SSH connects made by this pool since construction. */
766
- get connectCount() {
767
- return this._connectCount;
768
- }
769
- };
770
-
771
- // src/server.ts
772
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
773
-
774
- // src/tools.ts
775
- import { z } from "zod";
776
-
777
- // src/env.ts
778
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
779
- import { homedir as homedir3 } from "os";
780
- import { join as join3 } from "path";
781
- function probeAgent(socket, agentLabel) {
782
- const { stdout, ok } = runArgs("ssh-add", ["-l"]);
783
- const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
784
- if (!ok && !noIdentities) return null;
785
- const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
786
- return {
787
- running: true,
788
- reachable: true,
789
- socket,
790
- keys,
791
- started: false,
792
- message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
793
- };
794
- }
795
- function ensureAgent() {
796
- const sock = process.env.SSH_AUTH_SOCK;
797
- if (sock) {
798
- const result = probeAgent(sock, "ssh-agent");
799
- if (result) return result;
800
- }
801
- if (!sock && process.platform === "win32") {
802
- const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
803
- if (result) return result;
804
- }
805
- const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
806
- if (ok) {
807
- const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
808
- const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
809
- if (sockMatch) {
810
- process.env.SSH_AUTH_SOCK = sockMatch[1];
811
- if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
812
- return {
813
- running: true,
814
- reachable: true,
815
- socket: sockMatch[1],
816
- keys: [],
817
- started: true,
818
- env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
819
- message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
820
- };
821
- }
822
- }
823
- return {
824
- running: false,
825
- reachable: false,
826
- keys: [],
827
- started: false,
828
- 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)"'
829
- };
830
- }
831
- function detectKeyType(filePath, fileName) {
832
- const pubPath = `${filePath}.pub`;
833
- if (existsSync2(pubPath)) {
834
- try {
835
- const pub = readFileSync3(pubPath, "utf8");
836
- if (pub.includes("ssh-ed25519")) return "ed25519";
837
- if (pub.includes("ssh-rsa")) return "rsa";
838
- if (pub.includes("ecdsa")) return "ecdsa";
839
- if (pub.includes("ssh-dss")) return "dsa";
840
- } catch {
841
- }
842
- }
843
- if (fileName.includes("ed25519")) return "ed25519";
844
- if (fileName.includes("rsa")) return "rsa";
845
- if (fileName.includes("ecdsa")) return "ecdsa";
846
- if (fileName.includes("dsa")) return "dsa";
847
- try {
848
- const content = readFileSync3(filePath, "utf8");
849
- if (content.includes("RSA PRIVATE KEY")) return "rsa";
850
- if (content.includes("EC PRIVATE KEY")) return "ecdsa";
851
- if (content.includes("DSA PRIVATE KEY")) return "dsa";
852
- } catch {
1060
+ throw new Error(
1061
+ `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1062
+ );
853
1063
  }
854
- return "unknown";
855
- }
856
- function listSshKeys() {
857
- const sshDir = join3(homedir3(), ".ssh");
858
- if (!existsSync2(sshDir)) return [];
859
- const loadedFingerprints = /* @__PURE__ */ new Set();
860
- const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
861
- if (agentOk && !agentOut.includes("no identities")) {
862
- for (const line of agentOut.split("\n").filter(Boolean)) {
863
- const match = line.match(/(\S+:\S+)/);
864
- if (match) loadedFingerprints.add(match[1]);
1064
+ release(client) {
1065
+ for (const entry of this.entries.values()) {
1066
+ if (entry.client === client) {
1067
+ entry.refCount = Math.max(0, entry.refCount - 1);
1068
+ if (entry.refCount === 0 && !entry.dead) {
1069
+ entry.idleTimer = setTimeout(() => {
1070
+ try {
1071
+ entry.client.end();
1072
+ } catch {
1073
+ }
1074
+ this.entries.delete(entry.key);
1075
+ }, this.idleTtlMs);
1076
+ entry.idleTimer.unref();
1077
+ }
1078
+ return;
1079
+ }
865
1080
  }
866
- }
867
- const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
868
- const keys = [];
869
- let files;
870
- try {
871
- files = readdirSync2(sshDir);
872
- } catch {
873
- return [];
874
- }
875
- for (const file of files) {
876
- if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
877
- const filePath = join3(sshDir, file);
878
1081
  try {
879
- const stat = statSync(filePath);
880
- if (!stat.isFile()) continue;
881
- const content = readFileSync3(filePath, "utf8");
882
- if (!content.includes("PRIVATE KEY")) continue;
883
- const type = detectKeyType(filePath, file);
884
- let fingerprint;
885
- const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
886
- if (fpOk) {
887
- const match = fpOut.match(/(\S+:\S+)/);
888
- fingerprint = match?.[1];
889
- }
890
- const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
891
- keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1082
+ client.end();
892
1083
  } catch {
893
1084
  }
894
1085
  }
895
- return keys;
896
- }
897
- function loadKey(keyPath) {
898
- const agent = ensureAgent();
899
- if (!agent.reachable) {
900
- return { status: "error", message: agent.message };
901
- }
902
- const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
903
- if (!existsSync2(resolved)) {
904
- return { status: "error", message: `Key not found: ${resolved}` };
905
- }
906
- const { stdout, ok } = runArgs("ssh-add", [resolved]);
907
- if (ok) {
908
- return { status: "ok", message: `Key loaded: ${resolved}` };
909
- }
910
- if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
911
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
912
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
1086
+ async withConnection(config, fn) {
1087
+ const client = await this.acquire(config);
1088
+ try {
1089
+ return await fn(client);
1090
+ } finally {
1091
+ this.release(client);
913
1092
  }
914
- return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
915
- }
916
- return { status: "error", message: `Failed to load key: ${stdout}` };
917
- }
918
- function configLookup(host) {
919
- if (!isValidHostname(host)) {
920
- return { error: `Invalid hostname: "${host}"` };
921
- }
922
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
923
- if (!ok) {
924
- return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
925
1093
  }
926
- const all = {};
927
- const identityFiles = [];
928
- for (const line of stdout.split("\n")) {
929
- const spaceIdx = line.indexOf(" ");
930
- if (spaceIdx > 0) {
931
- const key = line.substring(0, spaceIdx);
932
- const value = line.substring(spaceIdx + 1);
933
- if (key === "identityfile") {
934
- identityFiles.push(value);
935
- } else {
936
- all[key] = value;
1094
+ drain() {
1095
+ this.drained = true;
1096
+ for (const entry of this.entries.values()) {
1097
+ if (entry.idleTimer) {
1098
+ clearTimeout(entry.idleTimer);
1099
+ }
1100
+ try {
1101
+ entry.client.end();
1102
+ } catch {
937
1103
  }
938
1104
  }
1105
+ this.entries.clear();
1106
+ this.pending.clear();
939
1107
  }
940
- return {
941
- hostname: all.hostname || host,
942
- user: all.user || "",
943
- port: all.port || "22",
944
- identityFile: identityFiles,
945
- proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
946
- proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
947
- all,
948
- raw: stdout
949
- };
950
- }
951
- function fixKnownHosts(host, port = 22) {
952
- if (!isValidHostname(host)) {
953
- return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
954
- }
955
- const actions = [];
956
- const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
957
- if (removeOk) {
958
- actions.push(`Removed old host key for ${host}`);
959
- }
960
- if (port !== 22) {
961
- const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
962
- if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
1108
+ get size() {
1109
+ return this.entries.size;
963
1110
  }
964
- const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
965
- const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
966
- if (scanOk && scanOut.trim()) {
967
- try {
968
- const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
969
- appendFileSync(knownHostsPath, `
970
- ${scanOut.trim()}
971
- `);
972
- actions.push(`Added new host key for ${host}`);
973
- return { status: "ok", message: `Host key refreshed for ${host}`, actions };
974
- } catch (e) {
975
- const msg = e instanceof Error ? e.message : String(e);
976
- return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
1111
+ get stats() {
1112
+ let active = 0;
1113
+ let idle = 0;
1114
+ for (const entry of this.entries.values()) {
1115
+ if (entry.refCount > 0) active++;
1116
+ else idle++;
977
1117
  }
1118
+ return { active, idle };
978
1119
  }
979
- return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
980
- }
981
- function checkGitSsh(host = "github.com", user = "git") {
982
- if (!isValidHostname(host)) {
983
- return { status: "error", message: `Invalid hostname: "${host}"` };
984
- }
985
- const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
986
- const text = stdout;
987
- if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
988
- const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
989
- return {
990
- status: "ok",
991
- message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
992
- authenticatedAs: userMatch?.[1]
993
- };
994
- }
995
- if (text.includes("Permission denied")) {
996
- return {
997
- status: "error",
998
- message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
999
- };
1000
- }
1001
- if (text.includes("Connection refused")) {
1002
- return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
1003
- }
1004
- if (text.includes("timed out") || text.includes("Connection timed out")) {
1005
- return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
1006
- }
1007
- if (text.includes("Could not resolve")) {
1008
- return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
1009
- }
1010
- return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
1011
- }
1012
- function testConnection(host, port = 22) {
1013
- if (!isValidHostname(host)) {
1014
- return { status: "error", message: `Invalid hostname: "${host}"` };
1015
- }
1016
- const start = Date.now();
1017
- const { ok, stdout } = runArgs("ssh", [
1018
- "-o",
1019
- "ConnectTimeout=5",
1020
- "-o",
1021
- "BatchMode=yes",
1022
- "-o",
1023
- "StrictHostKeyChecking=no",
1024
- "-p",
1025
- String(port),
1026
- host,
1027
- "echo",
1028
- "SSH_OK"
1029
- ]);
1030
- const elapsed = Date.now() - start;
1031
- if (ok && stdout.includes("SSH_OK")) {
1032
- return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
1033
- }
1034
- if (stdout.includes("Permission denied")) {
1035
- return {
1036
- status: "error",
1037
- message: `Authentication failed to ${host}:${port} (${elapsed}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
1038
- };
1039
- }
1040
- if (stdout.includes("Connection refused")) {
1041
- return {
1042
- status: "error",
1043
- message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
1044
- };
1045
- }
1046
- if (stdout.includes("timed out")) {
1047
- return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
1048
- }
1049
- if (stdout.includes("Host key verification failed")) {
1050
- return {
1051
- status: "error",
1052
- message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
1053
- };
1054
- }
1055
- if (stdout.includes("Could not resolve")) {
1056
- return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
1120
+ /** Total number of successful SSH connects made by this pool since construction. */
1121
+ get connectCount() {
1122
+ return this._connectCount;
1057
1123
  }
1058
- return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
1059
- }
1124
+ };
1125
+
1126
+ // src/server.ts
1127
+ import { readFileSync as readFileSync4 } from "fs";
1128
+ import { dirname, join as join4 } from "path";
1129
+ import { fileURLToPath } from "url";
1130
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1131
+
1132
+ // src/tools.ts
1133
+ import { z } from "zod";
1060
1134
 
1061
1135
  // src/ops.ts
1062
1136
  function shellQuote(s) {
@@ -1096,7 +1170,7 @@ async function find(client, options, timeoutMs = 3e4) {
1096
1170
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
1097
1171
  );
1098
1172
  }
1099
- const args = [shellQuote(options.path)];
1173
+ const args = ["--", shellQuote(options.path)];
1100
1174
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1101
1175
  if (options.type) args.push("-type", options.type);
1102
1176
  if (options.name) args.push("-name", shellQuote(options.name));
@@ -1111,9 +1185,9 @@ async function find(client, options, timeoutMs = 3e4) {
1111
1185
  return result.stdout.split("\n").filter(Boolean);
1112
1186
  }
1113
1187
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1114
- let command = `tail -n ${lines} ${shellQuote(path)}`;
1188
+ let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1115
1189
  if (grep) {
1116
- command += ` | grep -i ${shellQuote(grep)}`;
1190
+ command += ` | grep -i -e ${shellQuote(grep)}`;
1117
1191
  }
1118
1192
  const result = await exec(client, command, timeoutMs);
1119
1193
  if (result.stderr.trim()) {
@@ -1122,16 +1196,17 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1122
1196
  return result.stdout;
1123
1197
  }
1124
1198
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1125
- const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1199
+ const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1126
1200
  const raw = result.stdout;
1127
1201
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1128
1202
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1129
1203
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1130
1204
  const sinceMatch = raw.match(/since\s+(.+?);/);
1205
+ const fallbackStatus = result.code === 0 ? "active" : "inactive";
1131
1206
  return {
1132
1207
  name: serviceName,
1133
1208
  active: activeMatch?.[1] === "active",
1134
- status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
1209
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
1135
1210
  description: descMatch?.[1]?.trim(),
1136
1211
  since: sinceMatch?.[1]?.trim(),
1137
1212
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
@@ -1514,10 +1589,12 @@ ${files.join("\n")}` }] };
1514
1589
  }
1515
1590
 
1516
1591
  // src/server.ts
1592
+ var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1593
+ var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1517
1594
  function createServer(pool) {
1518
1595
  const server = new McpServer({
1519
1596
  name: "ssh-mcp",
1520
- version: "0.7.0"
1597
+ version
1521
1598
  });
1522
1599
  registerTools(server, pool);
1523
1600
  return server;
@@ -1528,12 +1605,24 @@ async function main() {
1528
1605
  const pool = new ConnectionPool();
1529
1606
  const server = createServer(pool);
1530
1607
  const transport = new StdioServerTransport();
1531
- const shutdown = () => {
1608
+ let shuttingDown = false;
1609
+ const shutdown = async () => {
1610
+ if (shuttingDown) return;
1611
+ shuttingDown = true;
1612
+ try {
1613
+ await server.close();
1614
+ } catch {
1615
+ }
1532
1616
  pool.drain();
1533
- process.exit(0);
1617
+ killStartedAgent();
1618
+ setTimeout(() => process.exit(0), 100);
1534
1619
  };
1535
- process.on("SIGINT", shutdown);
1536
- process.on("SIGTERM", shutdown);
1620
+ process.on("SIGINT", () => {
1621
+ shutdown().catch(() => process.exit(1));
1622
+ });
1623
+ process.on("SIGTERM", () => {
1624
+ shutdown().catch(() => process.exit(1));
1625
+ });
1537
1626
  await server.connect(transport);
1538
1627
  }
1539
1628
  main().catch((err) => {