@yawlabs/ssh-mcp 0.7.0 → 0.8.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 CHANGED
@@ -126,7 +126,13 @@ All remote operations accept connection parameters:
126
126
  | `privateKeyPath` | Path to SSH private key | Auto-detect |
127
127
  | `password` | SSH password (prefer keys) | — |
128
128
 
129
- **Auth resolution order:** explicit key > explicit password > ssh-agent (`SSH_AUTH_SOCK`) > SSH config identity files > default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`).
129
+ **Auth resolution order:** ssh-mcp picks the first match from this list and does not fall through to later entries this makes the auth method deterministic and predictable.
130
+
131
+ 1. Explicit `privateKeyPath`
132
+ 2. Explicit `password`
133
+ 3. ssh-agent (`SSH_AUTH_SOCK` on Unix, `\\.\pipe\openssh-ssh-agent` on Windows)
134
+ 4. Identity files from `~/.ssh/config` for the host
135
+ 5. Default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`)
130
136
 
131
137
  ## Example workflows
132
138
 
package/dist/index.js CHANGED
@@ -335,33 +335,54 @@ function resolveConfig(config) {
335
335
  keepaliveCountMax: 3,
336
336
  hostVerifier: buildHostVerifier(verifierHosts, port)
337
337
  };
338
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
339
- if (agentSock) {
340
- connectConfig.agent = agentSock;
341
- }
342
- if (config.password) {
343
- connectConfig.password = config.password;
344
- }
345
338
  if (config.privateKeyPath) {
346
339
  connectConfig.privateKey = readFileSync2(config.privateKeyPath);
347
- } else if (!agentSock) {
348
- const home = homedir2();
349
- 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")];
350
- for (const keyPath of keyPaths) {
351
- try {
352
- connectConfig.privateKey = readFileSync2(keyPath);
353
- break;
354
- } catch {
340
+ } else if (config.password) {
341
+ connectConfig.password = config.password;
342
+ } else {
343
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
344
+ if (agentSock) {
345
+ connectConfig.agent = agentSock;
346
+ } 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")];
349
+ for (const keyPath of keyPaths) {
350
+ try {
351
+ connectConfig.privateKey = readFileSync2(keyPath);
352
+ break;
353
+ } catch {
354
+ }
355
355
  }
356
356
  }
357
357
  }
358
358
  return { connectConfig, proxyJump: sshConfig?.proxyJump };
359
359
  }
360
+ var DIAG_CACHE_TTL_MS = 2e3;
361
+ var diagAgentCache = null;
362
+ var diagKeysCache = null;
363
+ function cachedAgentCheck() {
364
+ const now = Date.now();
365
+ if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
366
+ return diagAgentCache.result;
367
+ }
368
+ const result = checkSshAgent();
369
+ diagAgentCache = { at: now, result };
370
+ return result;
371
+ }
372
+ function cachedKeysCheck() {
373
+ const now = Date.now();
374
+ if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
375
+ return diagKeysCache.result;
376
+ }
377
+ const result = checkSshKeys();
378
+ diagKeysCache = { at: now, result };
379
+ return result;
380
+ }
360
381
  function formatDiagnostics(host) {
361
382
  try {
362
383
  const checks = [
363
- { name: "SSH Agent", ...checkSshAgent() },
364
- { name: "SSH Keys", ...checkSshKeys() },
384
+ { name: "SSH Agent", ...cachedAgentCheck() },
385
+ { name: "SSH Keys", ...cachedKeysCheck() },
365
386
  { name: "SSH Config", ...checkSshConfig(host) },
366
387
  { name: "Known Hosts", ...checkKnownHosts(host) }
367
388
  ];
@@ -420,7 +441,8 @@ async function connectWithProxy(resolved) {
420
441
  }).connect({ ...resolved.connectConfig, sock: stream });
421
442
  });
422
443
  }
423
- function exec(client, command, timeoutMs = 3e4) {
444
+ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
445
+ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
424
446
  return new Promise((resolve, reject) => {
425
447
  let settled = false;
426
448
  const settle = (fn) => {
@@ -437,18 +459,52 @@ function exec(client, command, timeoutMs = 3e4) {
437
459
  settle(() => reject(err));
438
460
  return;
439
461
  }
440
- let stdout = "";
441
- let stderr = "";
462
+ const stdoutChunks = [];
463
+ const stderrChunks = [];
464
+ let stdoutBytes = 0;
465
+ let stderrBytes = 0;
466
+ let stdoutTruncated = false;
467
+ let stderrTruncated = false;
468
+ const appendStdout = (data) => {
469
+ if (stdoutTruncated) return;
470
+ const remaining = maxBytes - stdoutBytes;
471
+ if (data.length <= remaining) {
472
+ stdoutChunks.push(data);
473
+ stdoutBytes += data.length;
474
+ } else {
475
+ if (remaining > 0) {
476
+ stdoutChunks.push(data.subarray(0, remaining));
477
+ stdoutBytes += remaining;
478
+ }
479
+ stdoutTruncated = true;
480
+ }
481
+ };
482
+ const appendStderr = (data) => {
483
+ if (stderrTruncated) return;
484
+ const remaining = maxBytes - stderrBytes;
485
+ if (data.length <= remaining) {
486
+ stderrChunks.push(data);
487
+ stderrBytes += data.length;
488
+ } else {
489
+ if (remaining > 0) {
490
+ stderrChunks.push(data.subarray(0, remaining));
491
+ stderrBytes += remaining;
492
+ }
493
+ stderrTruncated = true;
494
+ }
495
+ };
442
496
  stream.on("close", (code) => {
497
+ let stdout = Buffer.concat(stdoutChunks).toString("utf8");
498
+ let stderr = Buffer.concat(stderrChunks).toString("utf8");
499
+ if (stdoutTruncated) stdout += `
500
+ [output truncated at ${maxBytes} bytes]`;
501
+ if (stderrTruncated) stderr += `
502
+ [stderr truncated at ${maxBytes} bytes]`;
443
503
  settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
444
- }).on("data", (data) => {
445
- stdout += data.toString();
446
- }).on("error", (err2) => {
504
+ }).on("data", appendStdout).on("error", (err2) => {
447
505
  settle(() => reject(err2));
448
506
  });
449
- stream.stderr.on("data", (data) => {
450
- stderr += data.toString();
451
- }).on("error", (err2) => {
507
+ stream.stderr.on("data", appendStderr).on("error", (err2) => {
452
508
  settle(() => reject(err2));
453
509
  });
454
510
  });
@@ -543,8 +599,14 @@ async function listDir(client, remotePath) {
543
599
  // src/pool.ts
544
600
  var ConnectionPool = class {
545
601
  entries = /* @__PURE__ */ new Map();
602
+ // Coalesces concurrent connect attempts for the same key so we don't open N
603
+ // duplicate TCP connections when N tool calls fire simultaneously.
604
+ pending = /* @__PURE__ */ new Map();
546
605
  idleTtlMs;
547
606
  maxPoolSize;
607
+ // Total number of successful connects ever made by this pool. Useful for
608
+ // introspection and for tests that want to prove connection reuse.
609
+ _connectCount = 0;
548
610
  constructor(options) {
549
611
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
550
612
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -553,67 +615,98 @@ var ConnectionPool = class {
553
615
  const resolved = resolveConfig(config);
554
616
  const cc = resolved.connectConfig;
555
617
  const key = `${cc.username}@${cc.host}:${cc.port}`;
556
- const existing = this.entries.get(key);
557
- if (existing && !existing.dead) {
558
- existing.refCount++;
559
- if (existing.idleTimer) {
560
- clearTimeout(existing.idleTimer);
561
- existing.idleTimer = null;
562
- }
563
- return existing.client;
564
- }
565
- if (existing?.dead) {
566
- this.entries.delete(key);
567
- }
568
- if (this.entries.size >= this.maxPoolSize) {
569
- let evicted = false;
570
- for (const [k, e] of this.entries) {
571
- if (e.refCount === 0) {
572
- if (e.idleTimer) clearTimeout(e.idleTimer);
573
- try {
574
- e.client.end();
575
- } catch {
576
- }
577
- this.entries.delete(k);
578
- evicted = true;
579
- break;
618
+ const MAX_ACQUIRE_ATTEMPTS = 3;
619
+ let lastErr;
620
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
621
+ const existing = this.entries.get(key);
622
+ if (existing && !existing.dead) {
623
+ existing.refCount++;
624
+ if (existing.idleTimer) {
625
+ clearTimeout(existing.idleTimer);
626
+ existing.idleTimer = null;
580
627
  }
628
+ return existing.client;
581
629
  }
582
- if (!evicted) {
583
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
630
+ if (existing?.dead) {
631
+ this.entries.delete(key);
584
632
  }
585
- }
586
- try {
587
- const client = await connectWithProxy(resolved);
588
- const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
589
- const markDead = () => {
590
- entry.dead = true;
591
- if (entry.idleTimer) {
592
- clearTimeout(entry.idleTimer);
593
- entry.idleTimer = null;
594
- }
595
- if (this.entries.get(key) === entry) {
596
- this.entries.delete(key);
633
+ let pending = this.pending.get(key);
634
+ if (!pending) {
635
+ if (this.entries.size >= this.maxPoolSize) {
636
+ let evicted = false;
637
+ for (const [k, e] of this.entries) {
638
+ if (e.refCount === 0) {
639
+ if (e.idleTimer) clearTimeout(e.idleTimer);
640
+ try {
641
+ e.client.end();
642
+ } catch {
643
+ }
644
+ this.entries.delete(k);
645
+ evicted = true;
646
+ break;
647
+ }
648
+ }
649
+ if (!evicted) {
650
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
651
+ }
597
652
  }
598
- };
599
- client.on("close", markDead);
600
- client.on("end", markDead);
601
- client.on("error", markDead);
602
- this.entries.set(key, entry);
603
- return client;
604
- } catch (err) {
605
- const diag = formatDiagnostics(config.host);
606
- if (diag) {
607
- const message = err instanceof Error ? err.message : String(err);
608
- const enhanced = new Error(`${message}
653
+ pending = (async () => {
654
+ try {
655
+ const client2 = await connectWithProxy(resolved);
656
+ this._connectCount++;
657
+ const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
658
+ const markDead = () => {
659
+ entry2.dead = true;
660
+ if (entry2.idleTimer) {
661
+ clearTimeout(entry2.idleTimer);
662
+ entry2.idleTimer = null;
663
+ }
664
+ if (this.entries.get(key) === entry2) {
665
+ this.entries.delete(key);
666
+ }
667
+ };
668
+ client2.on("close", markDead);
669
+ client2.on("end", markDead);
670
+ client2.on("error", markDead);
671
+ this.entries.set(key, entry2);
672
+ return client2;
673
+ } finally {
674
+ this.pending.delete(key);
675
+ }
676
+ })();
677
+ this.pending.set(key, pending);
678
+ }
679
+ let client;
680
+ try {
681
+ client = await pending;
682
+ } catch (err) {
683
+ const diag = formatDiagnostics(config.host);
684
+ if (diag) {
685
+ const message = err instanceof Error ? err.message : String(err);
686
+ const enhanced = new Error(`${message}
609
687
 
610
688
  SSH Diagnostics:
611
689
  ${diag}`);
612
- enhanced.cause = err;
613
- throw enhanced;
690
+ enhanced.cause = err;
691
+ throw enhanced;
692
+ }
693
+ throw err;
614
694
  }
615
- throw err;
695
+ const entry = this.entries.get(key);
696
+ if (!entry || entry.dead || entry.client !== client) {
697
+ lastErr = new Error("connection died before acquire could take a ref");
698
+ continue;
699
+ }
700
+ entry.refCount++;
701
+ if (entry.idleTimer) {
702
+ clearTimeout(entry.idleTimer);
703
+ entry.idleTimer = null;
704
+ }
705
+ return client;
616
706
  }
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
+ );
617
710
  }
618
711
  release(client) {
619
712
  for (const entry of this.entries.values()) {
@@ -669,6 +762,10 @@ ${diag}`);
669
762
  }
670
763
  return { active, idle };
671
764
  }
765
+ /** Total number of successful SSH connects made by this pool since construction. */
766
+ get connectCount() {
767
+ return this._connectCount;
768
+ }
672
769
  };
673
770
 
674
771
  // src/server.ts
@@ -681,37 +778,29 @@ import { z } from "zod";
681
778
  import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
682
779
  import { homedir as homedir3 } from "os";
683
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
+ }
684
795
  function ensureAgent() {
685
796
  const sock = process.env.SSH_AUTH_SOCK;
686
797
  if (sock) {
687
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
688
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
689
- if (ok2 || noIdentities) {
690
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
691
- return {
692
- running: true,
693
- reachable: true,
694
- socket: sock,
695
- keys,
696
- started: false,
697
- message: keys.length > 0 ? `ssh-agent running with ${keys.length} key(s) loaded` : "ssh-agent running but no keys loaded. Use ssh_key_load to add one."
698
- };
699
- }
798
+ const result = probeAgent(sock, "ssh-agent");
799
+ if (result) return result;
700
800
  }
701
801
  if (!sock && process.platform === "win32") {
702
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
703
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
704
- if (ok2 || noIdentities) {
705
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
706
- return {
707
- running: true,
708
- reachable: true,
709
- socket: "\\\\.\\pipe\\openssh-ssh-agent",
710
- keys,
711
- started: false,
712
- 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."
713
- };
714
- }
802
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
803
+ if (result) return result;
715
804
  }
716
805
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
717
806
  if (ok) {
@@ -727,7 +816,7 @@ function ensureAgent() {
727
816
  keys: [],
728
817
  started: true,
729
818
  env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
730
- message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
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."
731
820
  };
732
821
  }
733
822
  }
@@ -853,8 +942,8 @@ function configLookup(host) {
853
942
  user: all.user || "",
854
943
  port: all.port || "22",
855
944
  identityFile: identityFiles,
856
- proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
857
- proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
945
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
946
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
858
947
  all,
859
948
  raw: stdout
860
949
  };
@@ -896,7 +985,7 @@ function checkGitSsh(host = "github.com", user = "git") {
896
985
  const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
897
986
  const text = stdout;
898
987
  if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
899
- const userMatch = text.match(/Hi (\S+?)!/) || text.match(/@(\S+?)!/) || text.match(/logged in as (\S+)/);
988
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
900
989
  return {
901
990
  status: "ok",
902
991
  message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
@@ -1014,8 +1103,11 @@ async function find(client, options, timeoutMs = 3e4) {
1014
1103
  if (options.minsize) args.push("-size", `+${options.minsize}`);
1015
1104
  if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1016
1105
  if (options.newer) args.push("-newer", shellQuote(options.newer));
1017
- const command = `find ${args.join(" ")} 2>/dev/null`;
1106
+ const command = `find ${args.join(" ")}`;
1018
1107
  const result = await exec(client, command, timeoutMs);
1108
+ if (!result.stdout.trim() && result.stderr.trim()) {
1109
+ throw new Error(result.stderr.trim());
1110
+ }
1019
1111
  return result.stdout.split("\n").filter(Boolean);
1020
1112
  }
1021
1113
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
@@ -1024,7 +1116,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1024
1116
  command += ` | grep -i ${shellQuote(grep)}`;
1025
1117
  }
1026
1118
  const result = await exec(client, command, timeoutMs);
1027
- if (result.code !== 0 && result.stderr && !grep) {
1119
+ if (result.stderr.trim()) {
1028
1120
  throw new Error(result.stderr.trim());
1029
1121
  }
1030
1122
  return result.stdout;
@@ -1052,7 +1144,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
1052
1144
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1053
1145
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1054
1146
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1055
- var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
1147
+ var PasswordSchema = z.string().optional().describe(
1148
+ "SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
1149
+ );
1056
1150
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1057
1151
  var connectionParams = {
1058
1152
  host: HostSchema,
@@ -1071,8 +1165,8 @@ function registerTools(server, pool) {
1071
1165
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1072
1166
  timeout: TimeoutSchema
1073
1167
  },
1074
- async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
1075
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1168
+ async ({ command, timeout, ...conn }) => {
1169
+ return connectionPool.withConnection(conn, async (client) => {
1076
1170
  const result = await exec(client, command, timeout || 3e4);
1077
1171
  const parts = [];
1078
1172
  if (result.stdout) parts.push(result.stdout);
@@ -1090,8 +1184,8 @@ ${result.stderr}`);
1090
1184
  ...connectionParams,
1091
1185
  path: z.string().describe("Absolute path to the remote file")
1092
1186
  },
1093
- async ({ host, port, username, privateKeyPath, password, path }) => {
1094
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1187
+ async ({ path, ...conn }) => {
1188
+ return connectionPool.withConnection(conn, async (client) => {
1095
1189
  const content = await readFile(client, path);
1096
1190
  return { content: [{ type: "text", text: content }] };
1097
1191
  });
@@ -1105,8 +1199,8 @@ ${result.stderr}`);
1105
1199
  path: z.string().describe("Absolute path to the remote file"),
1106
1200
  content: z.string().describe("File content to write")
1107
1201
  },
1108
- async ({ host, port, username, privateKeyPath, password, path, content }) => {
1109
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1202
+ async ({ path, content, ...conn }) => {
1203
+ return connectionPool.withConnection(conn, async (client) => {
1110
1204
  await writeFile(client, path, content);
1111
1205
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1112
1206
  });
@@ -1120,8 +1214,8 @@ ${result.stderr}`);
1120
1214
  localPath: z.string().describe("Path to the local file to upload"),
1121
1215
  remotePath: z.string().describe("Absolute path on the remote host")
1122
1216
  },
1123
- async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
1124
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1217
+ async ({ localPath, remotePath, ...conn }) => {
1218
+ return connectionPool.withConnection(conn, async (client) => {
1125
1219
  await uploadFile(client, localPath, remotePath);
1126
1220
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1127
1221
  });
@@ -1135,8 +1229,8 @@ ${result.stderr}`);
1135
1229
  remotePath: z.string().describe("Absolute path to the remote file"),
1136
1230
  localPath: z.string().describe("Local path to save the downloaded file")
1137
1231
  },
1138
- async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
1139
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1232
+ async ({ remotePath, localPath, ...conn }) => {
1233
+ return connectionPool.withConnection(conn, async (client) => {
1140
1234
  await downloadFile(client, remotePath, localPath);
1141
1235
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1142
1236
  });
@@ -1149,8 +1243,8 @@ ${result.stderr}`);
1149
1243
  ...connectionParams,
1150
1244
  path: z.string().describe("Absolute path to the remote directory")
1151
1245
  },
1152
- async ({ host, port, username, privateKeyPath, password, path }) => {
1153
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1246
+ async ({ path, ...conn }) => {
1247
+ return connectionPool.withConnection(conn, async (client) => {
1154
1248
  const files = await listDir(client, path);
1155
1249
  return { content: [{ type: "text", text: files.join("\n") }] };
1156
1250
  });
@@ -1356,21 +1450,8 @@ ${result.stderr}`);
1356
1450
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1357
1451
  timeout: TimeoutSchema
1358
1452
  },
1359
- async ({
1360
- host,
1361
- port,
1362
- username,
1363
- privateKeyPath,
1364
- password,
1365
- path,
1366
- name,
1367
- type,
1368
- maxdepth,
1369
- minsize,
1370
- maxsize,
1371
- timeout
1372
- }) => {
1373
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1453
+ async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
1454
+ return connectionPool.withConnection(conn, async (client) => {
1374
1455
  const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1375
1456
  if (files.length === 0) {
1376
1457
  return { content: [{ type: "text", text: "No files found." }] };
@@ -1390,8 +1471,8 @@ ${files.join("\n")}` }] };
1390
1471
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1391
1472
  timeout: TimeoutSchema
1392
1473
  },
1393
- async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1394
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1474
+ async ({ path, lines, grep, timeout, ...conn }) => {
1475
+ return connectionPool.withConnection(conn, async (client) => {
1395
1476
  const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1396
1477
  if (!output.trim()) {
1397
1478
  return {
@@ -1415,8 +1496,8 @@ ${files.join("\n")}` }] };
1415
1496
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1416
1497
  timeout: TimeoutSchema
1417
1498
  },
1418
- async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1419
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1499
+ async ({ service, timeout, ...conn }) => {
1500
+ return connectionPool.withConnection(conn, async (client) => {
1420
1501
  const status = await serviceStatus(client, service, timeout || 3e4);
1421
1502
  const lines = [];
1422
1503
  lines.push(`Service: ${status.name}`);
package/dist/server.d.ts CHANGED
@@ -24,7 +24,7 @@ declare function formatDiagnostics(host: string): string;
24
24
  declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
25
25
  declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
26
26
  declare function connect(config: SSHConfig): Promise<Client>;
27
- declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
27
+ declare function exec(client: Client, command: string, timeoutMs?: number, maxBytes?: number): Promise<ExecResult>;
28
28
  declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
29
29
  declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
30
30
  declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
@@ -39,8 +39,10 @@ interface PoolOptions {
39
39
  }
40
40
  declare class ConnectionPool {
41
41
  private entries;
42
+ private pending;
42
43
  private idleTtlMs;
43
44
  private maxPoolSize;
45
+ private _connectCount;
44
46
  constructor(options?: PoolOptions);
45
47
  acquire(config: SSHConfig): Promise<Client>;
46
48
  release(client: Client): void;
@@ -51,6 +53,8 @@ declare class ConnectionPool {
51
53
  active: number;
52
54
  idle: number;
53
55
  };
56
+ /** Total number of successful SSH connects made by this pool since construction. */
57
+ get connectCount(): number;
54
58
  }
55
59
 
56
60
  declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
package/dist/server.js CHANGED
@@ -259,37 +259,29 @@ function diagnose(host, port = 22) {
259
259
  import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
260
260
  import { homedir as homedir2 } from "os";
261
261
  import { join as join2 } from "path";
262
+ function probeAgent(socket, agentLabel) {
263
+ const { stdout, ok } = runArgs("ssh-add", ["-l"]);
264
+ const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
265
+ if (!ok && !noIdentities) return null;
266
+ const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
267
+ return {
268
+ running: true,
269
+ reachable: true,
270
+ socket,
271
+ keys,
272
+ started: false,
273
+ 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.`
274
+ };
275
+ }
262
276
  function ensureAgent() {
263
277
  const sock = process.env.SSH_AUTH_SOCK;
264
278
  if (sock) {
265
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
266
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
267
- if (ok2 || noIdentities) {
268
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
269
- return {
270
- running: true,
271
- reachable: true,
272
- socket: sock,
273
- keys,
274
- started: false,
275
- message: keys.length > 0 ? `ssh-agent running with ${keys.length} key(s) loaded` : "ssh-agent running but no keys loaded. Use ssh_key_load to add one."
276
- };
277
- }
279
+ const result = probeAgent(sock, "ssh-agent");
280
+ if (result) return result;
278
281
  }
279
282
  if (!sock && process.platform === "win32") {
280
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
281
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
282
- if (ok2 || noIdentities) {
283
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
284
- return {
285
- running: true,
286
- reachable: true,
287
- socket: "\\\\.\\pipe\\openssh-ssh-agent",
288
- keys,
289
- started: false,
290
- message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
291
- };
292
- }
283
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
284
+ if (result) return result;
293
285
  }
294
286
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
295
287
  if (ok) {
@@ -305,7 +297,7 @@ function ensureAgent() {
305
297
  keys: [],
306
298
  started: true,
307
299
  env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
308
- message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
300
+ 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."
309
301
  };
310
302
  }
311
303
  }
@@ -431,8 +423,8 @@ function configLookup(host) {
431
423
  user: all.user || "",
432
424
  port: all.port || "22",
433
425
  identityFile: identityFiles,
434
- proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
435
- proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
426
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
427
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
436
428
  all,
437
429
  raw: stdout
438
430
  };
@@ -474,7 +466,7 @@ function checkGitSsh(host = "github.com", user = "git") {
474
466
  const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
475
467
  const text = stdout;
476
468
  if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
477
- const userMatch = text.match(/Hi (\S+?)!/) || text.match(/@(\S+?)!/) || text.match(/logged in as (\S+)/);
469
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
478
470
  return {
479
471
  status: "ok",
480
472
  message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
@@ -626,33 +618,54 @@ function resolveConfig(config) {
626
618
  keepaliveCountMax: 3,
627
619
  hostVerifier: buildHostVerifier(verifierHosts, port)
628
620
  };
629
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
630
- if (agentSock) {
631
- connectConfig.agent = agentSock;
632
- }
633
- if (config.password) {
634
- connectConfig.password = config.password;
635
- }
636
621
  if (config.privateKeyPath) {
637
622
  connectConfig.privateKey = readFileSync3(config.privateKeyPath);
638
- } else if (!agentSock) {
639
- const home = homedir3();
640
- 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")];
641
- for (const keyPath of keyPaths) {
642
- try {
643
- connectConfig.privateKey = readFileSync3(keyPath);
644
- break;
645
- } catch {
623
+ } else if (config.password) {
624
+ connectConfig.password = config.password;
625
+ } else {
626
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
627
+ if (agentSock) {
628
+ connectConfig.agent = agentSock;
629
+ } else {
630
+ const home = homedir3();
631
+ 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")];
632
+ for (const keyPath of keyPaths) {
633
+ try {
634
+ connectConfig.privateKey = readFileSync3(keyPath);
635
+ break;
636
+ } catch {
637
+ }
646
638
  }
647
639
  }
648
640
  }
649
641
  return { connectConfig, proxyJump: sshConfig?.proxyJump };
650
642
  }
643
+ var DIAG_CACHE_TTL_MS = 2e3;
644
+ var diagAgentCache = null;
645
+ var diagKeysCache = null;
646
+ function cachedAgentCheck() {
647
+ const now = Date.now();
648
+ if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
649
+ return diagAgentCache.result;
650
+ }
651
+ const result = checkSshAgent();
652
+ diagAgentCache = { at: now, result };
653
+ return result;
654
+ }
655
+ function cachedKeysCheck() {
656
+ const now = Date.now();
657
+ if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
658
+ return diagKeysCache.result;
659
+ }
660
+ const result = checkSshKeys();
661
+ diagKeysCache = { at: now, result };
662
+ return result;
663
+ }
651
664
  function formatDiagnostics(host) {
652
665
  try {
653
666
  const checks = [
654
- { name: "SSH Agent", ...checkSshAgent() },
655
- { name: "SSH Keys", ...checkSshKeys() },
667
+ { name: "SSH Agent", ...cachedAgentCheck() },
668
+ { name: "SSH Keys", ...cachedKeysCheck() },
656
669
  { name: "SSH Config", ...checkSshConfig(host) },
657
670
  { name: "Known Hosts", ...checkKnownHosts(host) }
658
671
  ];
@@ -729,7 +742,8 @@ ${diag}`);
729
742
  throw err;
730
743
  }
731
744
  }
732
- function exec(client, command, timeoutMs = 3e4) {
745
+ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
746
+ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
733
747
  return new Promise((resolve, reject) => {
734
748
  let settled = false;
735
749
  const settle = (fn) => {
@@ -746,18 +760,52 @@ function exec(client, command, timeoutMs = 3e4) {
746
760
  settle(() => reject(err));
747
761
  return;
748
762
  }
749
- let stdout = "";
750
- let stderr = "";
763
+ const stdoutChunks = [];
764
+ const stderrChunks = [];
765
+ let stdoutBytes = 0;
766
+ let stderrBytes = 0;
767
+ let stdoutTruncated = false;
768
+ let stderrTruncated = false;
769
+ const appendStdout = (data) => {
770
+ if (stdoutTruncated) return;
771
+ const remaining = maxBytes - stdoutBytes;
772
+ if (data.length <= remaining) {
773
+ stdoutChunks.push(data);
774
+ stdoutBytes += data.length;
775
+ } else {
776
+ if (remaining > 0) {
777
+ stdoutChunks.push(data.subarray(0, remaining));
778
+ stdoutBytes += remaining;
779
+ }
780
+ stdoutTruncated = true;
781
+ }
782
+ };
783
+ const appendStderr = (data) => {
784
+ if (stderrTruncated) return;
785
+ const remaining = maxBytes - stderrBytes;
786
+ if (data.length <= remaining) {
787
+ stderrChunks.push(data);
788
+ stderrBytes += data.length;
789
+ } else {
790
+ if (remaining > 0) {
791
+ stderrChunks.push(data.subarray(0, remaining));
792
+ stderrBytes += remaining;
793
+ }
794
+ stderrTruncated = true;
795
+ }
796
+ };
751
797
  stream.on("close", (code) => {
798
+ let stdout = Buffer.concat(stdoutChunks).toString("utf8");
799
+ let stderr = Buffer.concat(stderrChunks).toString("utf8");
800
+ if (stdoutTruncated) stdout += `
801
+ [output truncated at ${maxBytes} bytes]`;
802
+ if (stderrTruncated) stderr += `
803
+ [stderr truncated at ${maxBytes} bytes]`;
752
804
  settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
753
- }).on("data", (data) => {
754
- stdout += data.toString();
755
- }).on("error", (err2) => {
805
+ }).on("data", appendStdout).on("error", (err2) => {
756
806
  settle(() => reject(err2));
757
807
  });
758
- stream.stderr.on("data", (data) => {
759
- stderr += data.toString();
760
- }).on("error", (err2) => {
808
+ stream.stderr.on("data", appendStderr).on("error", (err2) => {
761
809
  settle(() => reject(err2));
762
810
  });
763
811
  });
@@ -894,8 +942,11 @@ async function find(client, options, timeoutMs = 3e4) {
894
942
  if (options.minsize) args.push("-size", `+${options.minsize}`);
895
943
  if (options.maxsize) args.push("-size", `-${options.maxsize}`);
896
944
  if (options.newer) args.push("-newer", shellQuote(options.newer));
897
- const command = `find ${args.join(" ")} 2>/dev/null`;
945
+ const command = `find ${args.join(" ")}`;
898
946
  const result = await exec(client, command, timeoutMs);
947
+ if (!result.stdout.trim() && result.stderr.trim()) {
948
+ throw new Error(result.stderr.trim());
949
+ }
899
950
  return result.stdout.split("\n").filter(Boolean);
900
951
  }
901
952
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
@@ -904,7 +955,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
904
955
  command += ` | grep -i ${shellQuote(grep)}`;
905
956
  }
906
957
  const result = await exec(client, command, timeoutMs);
907
- if (result.code !== 0 && result.stderr && !grep) {
958
+ if (result.stderr.trim()) {
908
959
  throw new Error(result.stderr.trim());
909
960
  }
910
961
  return result.stdout;
@@ -930,8 +981,14 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
930
981
  // src/pool.ts
931
982
  var ConnectionPool = class {
932
983
  entries = /* @__PURE__ */ new Map();
984
+ // Coalesces concurrent connect attempts for the same key so we don't open N
985
+ // duplicate TCP connections when N tool calls fire simultaneously.
986
+ pending = /* @__PURE__ */ new Map();
933
987
  idleTtlMs;
934
988
  maxPoolSize;
989
+ // Total number of successful connects ever made by this pool. Useful for
990
+ // introspection and for tests that want to prove connection reuse.
991
+ _connectCount = 0;
935
992
  constructor(options) {
936
993
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
937
994
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -940,67 +997,98 @@ var ConnectionPool = class {
940
997
  const resolved = resolveConfig(config);
941
998
  const cc = resolved.connectConfig;
942
999
  const key = `${cc.username}@${cc.host}:${cc.port}`;
943
- const existing = this.entries.get(key);
944
- if (existing && !existing.dead) {
945
- existing.refCount++;
946
- if (existing.idleTimer) {
947
- clearTimeout(existing.idleTimer);
948
- existing.idleTimer = null;
949
- }
950
- return existing.client;
951
- }
952
- if (existing?.dead) {
953
- this.entries.delete(key);
954
- }
955
- if (this.entries.size >= this.maxPoolSize) {
956
- let evicted = false;
957
- for (const [k, e] of this.entries) {
958
- if (e.refCount === 0) {
959
- if (e.idleTimer) clearTimeout(e.idleTimer);
960
- try {
961
- e.client.end();
962
- } catch {
963
- }
964
- this.entries.delete(k);
965
- evicted = true;
966
- break;
1000
+ const MAX_ACQUIRE_ATTEMPTS = 3;
1001
+ let lastErr;
1002
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
1003
+ const existing = this.entries.get(key);
1004
+ if (existing && !existing.dead) {
1005
+ existing.refCount++;
1006
+ if (existing.idleTimer) {
1007
+ clearTimeout(existing.idleTimer);
1008
+ existing.idleTimer = null;
967
1009
  }
1010
+ return existing.client;
968
1011
  }
969
- if (!evicted) {
970
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1012
+ if (existing?.dead) {
1013
+ this.entries.delete(key);
971
1014
  }
972
- }
973
- try {
974
- const client = await connectWithProxy(resolved);
975
- const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
976
- const markDead = () => {
977
- entry.dead = true;
978
- if (entry.idleTimer) {
979
- clearTimeout(entry.idleTimer);
980
- entry.idleTimer = null;
981
- }
982
- if (this.entries.get(key) === entry) {
983
- this.entries.delete(key);
1015
+ let pending = this.pending.get(key);
1016
+ if (!pending) {
1017
+ if (this.entries.size >= this.maxPoolSize) {
1018
+ let evicted = false;
1019
+ for (const [k, e] of this.entries) {
1020
+ if (e.refCount === 0) {
1021
+ if (e.idleTimer) clearTimeout(e.idleTimer);
1022
+ try {
1023
+ e.client.end();
1024
+ } catch {
1025
+ }
1026
+ this.entries.delete(k);
1027
+ evicted = true;
1028
+ break;
1029
+ }
1030
+ }
1031
+ if (!evicted) {
1032
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1033
+ }
984
1034
  }
985
- };
986
- client.on("close", markDead);
987
- client.on("end", markDead);
988
- client.on("error", markDead);
989
- this.entries.set(key, entry);
990
- return client;
991
- } catch (err) {
992
- const diag = formatDiagnostics(config.host);
993
- if (diag) {
994
- const message = err instanceof Error ? err.message : String(err);
995
- const enhanced = new Error(`${message}
1035
+ pending = (async () => {
1036
+ try {
1037
+ const client2 = await connectWithProxy(resolved);
1038
+ this._connectCount++;
1039
+ const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
1040
+ const markDead = () => {
1041
+ entry2.dead = true;
1042
+ if (entry2.idleTimer) {
1043
+ clearTimeout(entry2.idleTimer);
1044
+ entry2.idleTimer = null;
1045
+ }
1046
+ if (this.entries.get(key) === entry2) {
1047
+ this.entries.delete(key);
1048
+ }
1049
+ };
1050
+ client2.on("close", markDead);
1051
+ client2.on("end", markDead);
1052
+ client2.on("error", markDead);
1053
+ this.entries.set(key, entry2);
1054
+ return client2;
1055
+ } finally {
1056
+ this.pending.delete(key);
1057
+ }
1058
+ })();
1059
+ this.pending.set(key, pending);
1060
+ }
1061
+ let client;
1062
+ try {
1063
+ client = await pending;
1064
+ } catch (err) {
1065
+ const diag = formatDiagnostics(config.host);
1066
+ if (diag) {
1067
+ const message = err instanceof Error ? err.message : String(err);
1068
+ const enhanced = new Error(`${message}
996
1069
 
997
1070
  SSH Diagnostics:
998
1071
  ${diag}`);
999
- enhanced.cause = err;
1000
- throw enhanced;
1072
+ enhanced.cause = err;
1073
+ throw enhanced;
1074
+ }
1075
+ throw err;
1001
1076
  }
1002
- throw err;
1077
+ const entry = this.entries.get(key);
1078
+ if (!entry || entry.dead || entry.client !== client) {
1079
+ lastErr = new Error("connection died before acquire could take a ref");
1080
+ continue;
1081
+ }
1082
+ entry.refCount++;
1083
+ if (entry.idleTimer) {
1084
+ clearTimeout(entry.idleTimer);
1085
+ entry.idleTimer = null;
1086
+ }
1087
+ return client;
1003
1088
  }
1089
+ throw new Error(
1090
+ `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1091
+ );
1004
1092
  }
1005
1093
  release(client) {
1006
1094
  for (const entry of this.entries.values()) {
@@ -1056,6 +1144,10 @@ ${diag}`);
1056
1144
  }
1057
1145
  return { active, idle };
1058
1146
  }
1147
+ /** Total number of successful SSH connects made by this pool since construction. */
1148
+ get connectCount() {
1149
+ return this._connectCount;
1150
+ }
1059
1151
  };
1060
1152
 
1061
1153
  // src/tools.ts
@@ -1063,7 +1155,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
1063
1155
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1064
1156
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1065
1157
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1066
- var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
1158
+ var PasswordSchema = z.string().optional().describe(
1159
+ "SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
1160
+ );
1067
1161
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1068
1162
  var connectionParams = {
1069
1163
  host: HostSchema,
@@ -1082,8 +1176,8 @@ function registerTools(server, pool) {
1082
1176
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1083
1177
  timeout: TimeoutSchema
1084
1178
  },
1085
- async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
1086
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1179
+ async ({ command, timeout, ...conn }) => {
1180
+ return connectionPool.withConnection(conn, async (client) => {
1087
1181
  const result = await exec(client, command, timeout || 3e4);
1088
1182
  const parts = [];
1089
1183
  if (result.stdout) parts.push(result.stdout);
@@ -1101,8 +1195,8 @@ ${result.stderr}`);
1101
1195
  ...connectionParams,
1102
1196
  path: z.string().describe("Absolute path to the remote file")
1103
1197
  },
1104
- async ({ host, port, username, privateKeyPath, password, path }) => {
1105
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1198
+ async ({ path, ...conn }) => {
1199
+ return connectionPool.withConnection(conn, async (client) => {
1106
1200
  const content = await readFile(client, path);
1107
1201
  return { content: [{ type: "text", text: content }] };
1108
1202
  });
@@ -1116,8 +1210,8 @@ ${result.stderr}`);
1116
1210
  path: z.string().describe("Absolute path to the remote file"),
1117
1211
  content: z.string().describe("File content to write")
1118
1212
  },
1119
- async ({ host, port, username, privateKeyPath, password, path, content }) => {
1120
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1213
+ async ({ path, content, ...conn }) => {
1214
+ return connectionPool.withConnection(conn, async (client) => {
1121
1215
  await writeFile(client, path, content);
1122
1216
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1123
1217
  });
@@ -1131,8 +1225,8 @@ ${result.stderr}`);
1131
1225
  localPath: z.string().describe("Path to the local file to upload"),
1132
1226
  remotePath: z.string().describe("Absolute path on the remote host")
1133
1227
  },
1134
- async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
1135
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1228
+ async ({ localPath, remotePath, ...conn }) => {
1229
+ return connectionPool.withConnection(conn, async (client) => {
1136
1230
  await uploadFile(client, localPath, remotePath);
1137
1231
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1138
1232
  });
@@ -1146,8 +1240,8 @@ ${result.stderr}`);
1146
1240
  remotePath: z.string().describe("Absolute path to the remote file"),
1147
1241
  localPath: z.string().describe("Local path to save the downloaded file")
1148
1242
  },
1149
- async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
1150
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1243
+ async ({ remotePath, localPath, ...conn }) => {
1244
+ return connectionPool.withConnection(conn, async (client) => {
1151
1245
  await downloadFile(client, remotePath, localPath);
1152
1246
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1153
1247
  });
@@ -1160,8 +1254,8 @@ ${result.stderr}`);
1160
1254
  ...connectionParams,
1161
1255
  path: z.string().describe("Absolute path to the remote directory")
1162
1256
  },
1163
- async ({ host, port, username, privateKeyPath, password, path }) => {
1164
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1257
+ async ({ path, ...conn }) => {
1258
+ return connectionPool.withConnection(conn, async (client) => {
1165
1259
  const files = await listDir(client, path);
1166
1260
  return { content: [{ type: "text", text: files.join("\n") }] };
1167
1261
  });
@@ -1367,21 +1461,8 @@ ${result.stderr}`);
1367
1461
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1368
1462
  timeout: TimeoutSchema
1369
1463
  },
1370
- async ({
1371
- host,
1372
- port,
1373
- username,
1374
- privateKeyPath,
1375
- password,
1376
- path,
1377
- name,
1378
- type,
1379
- maxdepth,
1380
- minsize,
1381
- maxsize,
1382
- timeout
1383
- }) => {
1384
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1464
+ async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
1465
+ return connectionPool.withConnection(conn, async (client) => {
1385
1466
  const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1386
1467
  if (files.length === 0) {
1387
1468
  return { content: [{ type: "text", text: "No files found." }] };
@@ -1401,8 +1482,8 @@ ${files.join("\n")}` }] };
1401
1482
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1402
1483
  timeout: TimeoutSchema
1403
1484
  },
1404
- async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1405
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1485
+ async ({ path, lines, grep, timeout, ...conn }) => {
1486
+ return connectionPool.withConnection(conn, async (client) => {
1406
1487
  const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1407
1488
  if (!output.trim()) {
1408
1489
  return {
@@ -1426,8 +1507,8 @@ ${files.join("\n")}` }] };
1426
1507
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1427
1508
  timeout: TimeoutSchema
1428
1509
  },
1429
- async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1430
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1510
+ async ({ service, timeout, ...conn }) => {
1511
+ return connectionPool.withConnection(conn, async (client) => {
1431
1512
  const status = await serviceStatus(client, service, timeout || 3e4);
1432
1513
  const lines = [];
1433
1514
  lines.push(`Service: ${status.name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {