@yawlabs/ssh-mcp 0.6.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
@@ -98,6 +98,18 @@ All connections respect your `~/.ssh/config`. Host aliases, custom ports, userna
98
98
 
99
99
  **ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
100
100
 
101
+ ### Host key verification
102
+
103
+ All remote operations verify the server's host key against `~/.ssh/known_hosts`:
104
+
105
+ - **Known host, key matches** — accept.
106
+ - **Known host, key changed** — reject (MITM protection).
107
+ - **Unknown host** — accept on first connection (TOFU). Use `ssh_known_hosts_fix` to pin the key for future mismatch detection.
108
+
109
+ For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
110
+
111
+ The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
112
+
101
113
  ### Windows support
102
114
 
103
115
  On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
@@ -114,7 +126,13 @@ All remote operations accept connection parameters:
114
126
  | `privateKeyPath` | Path to SSH private key | Auto-detect |
115
127
  | `password` | SSH password (prefer keys) | — |
116
128
 
117
- **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`)
118
136
 
119
137
  ## Example workflows
120
138
 
package/dist/index.js CHANGED
@@ -290,42 +290,99 @@ function resolveFromSshConfig(host) {
290
290
  return null;
291
291
  }
292
292
  }
293
+ function readKnownHostsKeys(host, port) {
294
+ if (!isValidHostname(host)) return [];
295
+ const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
296
+ const keys = [];
297
+ for (const target of targets) {
298
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
299
+ if (!ok || !stdout.trim()) continue;
300
+ for (const line of stdout.split("\n")) {
301
+ const trimmed = line.trim();
302
+ if (!trimmed || trimmed.startsWith("#")) continue;
303
+ const parts = trimmed.split(/\s+/);
304
+ if (parts.length < 3) continue;
305
+ try {
306
+ keys.push(Buffer.from(parts[2], "base64"));
307
+ } catch {
308
+ }
309
+ }
310
+ }
311
+ return keys;
312
+ }
313
+ function buildHostVerifier(hosts, port) {
314
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
315
+ return (key) => {
316
+ const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
317
+ if (known.length === 0) {
318
+ return !strict;
319
+ }
320
+ return known.some((k) => k.equals(key));
321
+ };
322
+ }
293
323
  function resolveConfig(config) {
294
324
  const sshConfig = resolveFromSshConfig(config.host);
325
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
326
+ const verifierHosts = [config.host];
327
+ if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
328
+ verifierHosts.push(sshConfig.hostname);
329
+ }
295
330
  const connectConfig = {
296
331
  host: sshConfig?.hostname || config.host,
297
- port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
332
+ port,
298
333
  username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
299
334
  keepaliveInterval: 15e3,
300
- keepaliveCountMax: 3
335
+ keepaliveCountMax: 3,
336
+ hostVerifier: buildHostVerifier(verifierHosts, port)
301
337
  };
302
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
303
- if (agentSock) {
304
- connectConfig.agent = agentSock;
305
- }
306
- if (config.password) {
307
- connectConfig.password = config.password;
308
- }
309
338
  if (config.privateKeyPath) {
310
339
  connectConfig.privateKey = readFileSync2(config.privateKeyPath);
311
- } else if (!agentSock) {
312
- const home = homedir2();
313
- 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")];
314
- for (const keyPath of keyPaths) {
315
- try {
316
- connectConfig.privateKey = readFileSync2(keyPath);
317
- break;
318
- } 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
+ }
319
355
  }
320
356
  }
321
357
  }
322
358
  return { connectConfig, proxyJump: sshConfig?.proxyJump };
323
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
+ }
324
381
  function formatDiagnostics(host) {
325
382
  try {
326
383
  const checks = [
327
- { name: "SSH Agent", ...checkSshAgent() },
328
- { name: "SSH Keys", ...checkSshKeys() },
384
+ { name: "SSH Agent", ...cachedAgentCheck() },
385
+ { name: "SSH Keys", ...cachedKeysCheck() },
329
386
  { name: "SSH Config", ...checkSshConfig(host) },
330
387
  { name: "Known Hosts", ...checkKnownHosts(host) }
331
388
  ];
@@ -384,7 +441,8 @@ async function connectWithProxy(resolved) {
384
441
  }).connect({ ...resolved.connectConfig, sock: stream });
385
442
  });
386
443
  }
387
- 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) {
388
446
  return new Promise((resolve, reject) => {
389
447
  let settled = false;
390
448
  const settle = (fn) => {
@@ -401,18 +459,52 @@ function exec(client, command, timeoutMs = 3e4) {
401
459
  settle(() => reject(err));
402
460
  return;
403
461
  }
404
- let stdout = "";
405
- 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
+ };
406
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]`;
407
503
  settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
408
- }).on("data", (data) => {
409
- stdout += data.toString();
410
- }).on("error", (err2) => {
504
+ }).on("data", appendStdout).on("error", (err2) => {
411
505
  settle(() => reject(err2));
412
506
  });
413
- stream.stderr.on("data", (data) => {
414
- stderr += data.toString();
415
- }).on("error", (err2) => {
507
+ stream.stderr.on("data", appendStderr).on("error", (err2) => {
416
508
  settle(() => reject(err2));
417
509
  });
418
510
  });
@@ -507,8 +599,14 @@ async function listDir(client, remotePath) {
507
599
  // src/pool.ts
508
600
  var ConnectionPool = class {
509
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();
510
605
  idleTtlMs;
511
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;
512
610
  constructor(options) {
513
611
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
514
612
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -517,67 +615,98 @@ var ConnectionPool = class {
517
615
  const resolved = resolveConfig(config);
518
616
  const cc = resolved.connectConfig;
519
617
  const key = `${cc.username}@${cc.host}:${cc.port}`;
520
- const existing = this.entries.get(key);
521
- if (existing && !existing.dead) {
522
- existing.refCount++;
523
- if (existing.idleTimer) {
524
- clearTimeout(existing.idleTimer);
525
- existing.idleTimer = null;
526
- }
527
- return existing.client;
528
- }
529
- if (existing?.dead) {
530
- this.entries.delete(key);
531
- }
532
- if (this.entries.size >= this.maxPoolSize) {
533
- let evicted = false;
534
- for (const [k, e] of this.entries) {
535
- if (e.refCount === 0) {
536
- if (e.idleTimer) clearTimeout(e.idleTimer);
537
- try {
538
- e.client.end();
539
- } catch {
540
- }
541
- this.entries.delete(k);
542
- evicted = true;
543
- 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;
544
627
  }
628
+ return existing.client;
545
629
  }
546
- if (!evicted) {
547
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
630
+ if (existing?.dead) {
631
+ this.entries.delete(key);
548
632
  }
549
- }
550
- try {
551
- const client = await connectWithProxy(resolved);
552
- const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
553
- const markDead = () => {
554
- entry.dead = true;
555
- if (entry.idleTimer) {
556
- clearTimeout(entry.idleTimer);
557
- entry.idleTimer = null;
558
- }
559
- if (this.entries.get(key) === entry) {
560
- 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
+ }
561
652
  }
562
- };
563
- client.on("close", markDead);
564
- client.on("end", markDead);
565
- client.on("error", markDead);
566
- this.entries.set(key, entry);
567
- return client;
568
- } catch (err) {
569
- const diag = formatDiagnostics(config.host);
570
- if (diag) {
571
- const message = err instanceof Error ? err.message : String(err);
572
- 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}
573
687
 
574
688
  SSH Diagnostics:
575
689
  ${diag}`);
576
- enhanced.cause = err;
577
- throw enhanced;
690
+ enhanced.cause = err;
691
+ throw enhanced;
692
+ }
693
+ throw err;
694
+ }
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;
578
704
  }
579
- throw err;
705
+ return client;
580
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
+ );
581
710
  }
582
711
  release(client) {
583
712
  for (const entry of this.entries.values()) {
@@ -633,6 +762,10 @@ ${diag}`);
633
762
  }
634
763
  return { active, idle };
635
764
  }
765
+ /** Total number of successful SSH connects made by this pool since construction. */
766
+ get connectCount() {
767
+ return this._connectCount;
768
+ }
636
769
  };
637
770
 
638
771
  // src/server.ts
@@ -645,37 +778,29 @@ import { z } from "zod";
645
778
  import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
646
779
  import { homedir as homedir3 } from "os";
647
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
+ }
648
795
  function ensureAgent() {
649
796
  const sock = process.env.SSH_AUTH_SOCK;
650
797
  if (sock) {
651
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
652
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
653
- if (ok2 || noIdentities) {
654
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
655
- return {
656
- running: true,
657
- reachable: true,
658
- socket: sock,
659
- keys,
660
- started: false,
661
- 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."
662
- };
663
- }
798
+ const result = probeAgent(sock, "ssh-agent");
799
+ if (result) return result;
664
800
  }
665
801
  if (!sock && process.platform === "win32") {
666
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
667
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
668
- if (ok2 || noIdentities) {
669
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
670
- return {
671
- running: true,
672
- reachable: true,
673
- socket: "\\\\.\\pipe\\openssh-ssh-agent",
674
- keys,
675
- started: false,
676
- 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."
677
- };
678
- }
802
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
803
+ if (result) return result;
679
804
  }
680
805
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
681
806
  if (ok) {
@@ -691,7 +816,7 @@ function ensureAgent() {
691
816
  keys: [],
692
817
  started: true,
693
818
  env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
694
- 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."
695
820
  };
696
821
  }
697
822
  }
@@ -817,8 +942,8 @@ function configLookup(host) {
817
942
  user: all.user || "",
818
943
  port: all.port || "22",
819
944
  identityFile: identityFiles,
820
- proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
821
- 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,
822
947
  all,
823
948
  raw: stdout
824
949
  };
@@ -860,7 +985,7 @@ function checkGitSsh(host = "github.com", user = "git") {
860
985
  const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
861
986
  const text = stdout;
862
987
  if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
863
- 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+)/);
864
989
  return {
865
990
  status: "ok",
866
991
  message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
@@ -978,8 +1103,11 @@ async function find(client, options, timeoutMs = 3e4) {
978
1103
  if (options.minsize) args.push("-size", `+${options.minsize}`);
979
1104
  if (options.maxsize) args.push("-size", `-${options.maxsize}`);
980
1105
  if (options.newer) args.push("-newer", shellQuote(options.newer));
981
- const command = `find ${args.join(" ")} 2>/dev/null`;
1106
+ const command = `find ${args.join(" ")}`;
982
1107
  const result = await exec(client, command, timeoutMs);
1108
+ if (!result.stdout.trim() && result.stderr.trim()) {
1109
+ throw new Error(result.stderr.trim());
1110
+ }
983
1111
  return result.stdout.split("\n").filter(Boolean);
984
1112
  }
985
1113
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
@@ -988,7 +1116,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
988
1116
  command += ` | grep -i ${shellQuote(grep)}`;
989
1117
  }
990
1118
  const result = await exec(client, command, timeoutMs);
991
- if (result.code !== 0 && result.stderr && !grep) {
1119
+ if (result.stderr.trim()) {
992
1120
  throw new Error(result.stderr.trim());
993
1121
  }
994
1122
  return result.stdout;
@@ -1016,7 +1144,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
1016
1144
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1017
1145
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1018
1146
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1019
- 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
+ );
1020
1150
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1021
1151
  var connectionParams = {
1022
1152
  host: HostSchema,
@@ -1029,14 +1159,14 @@ function registerTools(server, pool) {
1029
1159
  const connectionPool = pool ?? new ConnectionPool();
1030
1160
  server.tool(
1031
1161
  "ssh_exec",
1032
- "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
1162
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
1033
1163
  {
1034
1164
  ...connectionParams,
1035
- command: z.string().describe("Shell command to execute on the remote host"),
1165
+ command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1036
1166
  timeout: TimeoutSchema
1037
1167
  },
1038
- async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
1039
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1168
+ async ({ command, timeout, ...conn }) => {
1169
+ return connectionPool.withConnection(conn, async (client) => {
1040
1170
  const result = await exec(client, command, timeout || 3e4);
1041
1171
  const parts = [];
1042
1172
  if (result.stdout) parts.push(result.stdout);
@@ -1054,8 +1184,8 @@ ${result.stderr}`);
1054
1184
  ...connectionParams,
1055
1185
  path: z.string().describe("Absolute path to the remote file")
1056
1186
  },
1057
- async ({ host, port, username, privateKeyPath, password, path }) => {
1058
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1187
+ async ({ path, ...conn }) => {
1188
+ return connectionPool.withConnection(conn, async (client) => {
1059
1189
  const content = await readFile(client, path);
1060
1190
  return { content: [{ type: "text", text: content }] };
1061
1191
  });
@@ -1069,8 +1199,8 @@ ${result.stderr}`);
1069
1199
  path: z.string().describe("Absolute path to the remote file"),
1070
1200
  content: z.string().describe("File content to write")
1071
1201
  },
1072
- async ({ host, port, username, privateKeyPath, password, path, content }) => {
1073
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1202
+ async ({ path, content, ...conn }) => {
1203
+ return connectionPool.withConnection(conn, async (client) => {
1074
1204
  await writeFile(client, path, content);
1075
1205
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1076
1206
  });
@@ -1084,8 +1214,8 @@ ${result.stderr}`);
1084
1214
  localPath: z.string().describe("Path to the local file to upload"),
1085
1215
  remotePath: z.string().describe("Absolute path on the remote host")
1086
1216
  },
1087
- async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
1088
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1217
+ async ({ localPath, remotePath, ...conn }) => {
1218
+ return connectionPool.withConnection(conn, async (client) => {
1089
1219
  await uploadFile(client, localPath, remotePath);
1090
1220
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1091
1221
  });
@@ -1099,8 +1229,8 @@ ${result.stderr}`);
1099
1229
  remotePath: z.string().describe("Absolute path to the remote file"),
1100
1230
  localPath: z.string().describe("Local path to save the downloaded file")
1101
1231
  },
1102
- async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
1103
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1232
+ async ({ remotePath, localPath, ...conn }) => {
1233
+ return connectionPool.withConnection(conn, async (client) => {
1104
1234
  await downloadFile(client, remotePath, localPath);
1105
1235
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1106
1236
  });
@@ -1113,8 +1243,8 @@ ${result.stderr}`);
1113
1243
  ...connectionParams,
1114
1244
  path: z.string().describe("Absolute path to the remote directory")
1115
1245
  },
1116
- async ({ host, port, username, privateKeyPath, password, path }) => {
1117
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1246
+ async ({ path, ...conn }) => {
1247
+ return connectionPool.withConnection(conn, async (client) => {
1118
1248
  const files = await listDir(client, path);
1119
1249
  return { content: [{ type: "text", text: files.join("\n") }] };
1120
1250
  });
@@ -1320,21 +1450,8 @@ ${result.stderr}`);
1320
1450
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1321
1451
  timeout: TimeoutSchema
1322
1452
  },
1323
- async ({
1324
- host,
1325
- port,
1326
- username,
1327
- privateKeyPath,
1328
- password,
1329
- path,
1330
- name,
1331
- type,
1332
- maxdepth,
1333
- minsize,
1334
- maxsize,
1335
- timeout
1336
- }) => {
1337
- 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) => {
1338
1455
  const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1339
1456
  if (files.length === 0) {
1340
1457
  return { content: [{ type: "text", text: "No files found." }] };
@@ -1354,8 +1471,8 @@ ${files.join("\n")}` }] };
1354
1471
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1355
1472
  timeout: TimeoutSchema
1356
1473
  },
1357
- async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1358
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1474
+ async ({ path, lines, grep, timeout, ...conn }) => {
1475
+ return connectionPool.withConnection(conn, async (client) => {
1359
1476
  const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1360
1477
  if (!output.trim()) {
1361
1478
  return {
@@ -1379,8 +1496,8 @@ ${files.join("\n")}` }] };
1379
1496
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1380
1497
  timeout: TimeoutSchema
1381
1498
  },
1382
- async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1383
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1499
+ async ({ service, timeout, ...conn }) => {
1500
+ return connectionPool.withConnection(conn, async (client) => {
1384
1501
  const status = await serviceStatus(client, service, timeout || 3e4);
1385
1502
  const lines = [];
1386
1503
  lines.push(`Service: ${status.name}`);
@@ -1400,7 +1517,7 @@ ${files.join("\n")}` }] };
1400
1517
  function createServer(pool) {
1401
1518
  const server = new McpServer({
1402
1519
  name: "ssh-mcp",
1403
- version: "0.6.0"
1520
+ version: "0.7.0"
1404
1521
  });
1405
1522
  registerTools(server, pool);
1406
1523
  return server;
package/dist/server.d.ts CHANGED
@@ -18,12 +18,13 @@ interface ResolvedConfig {
18
18
  connectConfig: ConnectConfig;
19
19
  proxyJump?: string;
20
20
  }
21
+ declare function readKnownHostsKeys(host: string, port?: number): Buffer[];
21
22
  declare function resolveConfig(config: SSHConfig): ResolvedConfig;
22
23
  declare function formatDiagnostics(host: string): string;
23
24
  declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
24
25
  declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
25
26
  declare function connect(config: SSHConfig): Promise<Client>;
26
- 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>;
27
28
  declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
28
29
  declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
29
30
  declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
@@ -38,8 +39,10 @@ interface PoolOptions {
38
39
  }
39
40
  declare class ConnectionPool {
40
41
  private entries;
42
+ private pending;
41
43
  private idleTtlMs;
42
44
  private maxPoolSize;
45
+ private _connectCount;
43
46
  constructor(options?: PoolOptions);
44
47
  acquire(config: SSHConfig): Promise<Client>;
45
48
  release(client: Client): void;
@@ -50,6 +53,8 @@ declare class ConnectionPool {
50
53
  active: number;
51
54
  idle: number;
52
55
  };
56
+ /** Total number of successful SSH connects made by this pool since construction. */
57
+ get connectCount(): number;
53
58
  }
54
59
 
55
60
  declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
@@ -164,4 +169,4 @@ declare function testConnection(host: string, port?: number): {
164
169
 
165
170
  declare function createServer(pool?: ConnectionPool): McpServer;
166
171
 
167
- export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
172
+ export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
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]}` : ""}`,
@@ -581,42 +573,99 @@ function resolveFromSshConfig(host) {
581
573
  return null;
582
574
  }
583
575
  }
576
+ function readKnownHostsKeys(host, port) {
577
+ if (!isValidHostname(host)) return [];
578
+ const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
579
+ const keys = [];
580
+ for (const target of targets) {
581
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
582
+ if (!ok || !stdout.trim()) continue;
583
+ for (const line of stdout.split("\n")) {
584
+ const trimmed = line.trim();
585
+ if (!trimmed || trimmed.startsWith("#")) continue;
586
+ const parts = trimmed.split(/\s+/);
587
+ if (parts.length < 3) continue;
588
+ try {
589
+ keys.push(Buffer.from(parts[2], "base64"));
590
+ } catch {
591
+ }
592
+ }
593
+ }
594
+ return keys;
595
+ }
596
+ function buildHostVerifier(hosts, port) {
597
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
598
+ return (key) => {
599
+ const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
600
+ if (known.length === 0) {
601
+ return !strict;
602
+ }
603
+ return known.some((k) => k.equals(key));
604
+ };
605
+ }
584
606
  function resolveConfig(config) {
585
607
  const sshConfig = resolveFromSshConfig(config.host);
608
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
609
+ const verifierHosts = [config.host];
610
+ if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
611
+ verifierHosts.push(sshConfig.hostname);
612
+ }
586
613
  const connectConfig = {
587
614
  host: sshConfig?.hostname || config.host,
588
- port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
615
+ port,
589
616
  username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
590
617
  keepaliveInterval: 15e3,
591
- keepaliveCountMax: 3
618
+ keepaliveCountMax: 3,
619
+ hostVerifier: buildHostVerifier(verifierHosts, port)
592
620
  };
593
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
594
- if (agentSock) {
595
- connectConfig.agent = agentSock;
596
- }
597
- if (config.password) {
598
- connectConfig.password = config.password;
599
- }
600
621
  if (config.privateKeyPath) {
601
622
  connectConfig.privateKey = readFileSync3(config.privateKeyPath);
602
- } else if (!agentSock) {
603
- const home = homedir3();
604
- 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")];
605
- for (const keyPath of keyPaths) {
606
- try {
607
- connectConfig.privateKey = readFileSync3(keyPath);
608
- break;
609
- } 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
+ }
610
638
  }
611
639
  }
612
640
  }
613
641
  return { connectConfig, proxyJump: sshConfig?.proxyJump };
614
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
+ }
615
664
  function formatDiagnostics(host) {
616
665
  try {
617
666
  const checks = [
618
- { name: "SSH Agent", ...checkSshAgent() },
619
- { name: "SSH Keys", ...checkSshKeys() },
667
+ { name: "SSH Agent", ...cachedAgentCheck() },
668
+ { name: "SSH Keys", ...cachedKeysCheck() },
620
669
  { name: "SSH Config", ...checkSshConfig(host) },
621
670
  { name: "Known Hosts", ...checkKnownHosts(host) }
622
671
  ];
@@ -693,7 +742,8 @@ ${diag}`);
693
742
  throw err;
694
743
  }
695
744
  }
696
- 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) {
697
747
  return new Promise((resolve, reject) => {
698
748
  let settled = false;
699
749
  const settle = (fn) => {
@@ -710,18 +760,52 @@ function exec(client, command, timeoutMs = 3e4) {
710
760
  settle(() => reject(err));
711
761
  return;
712
762
  }
713
- let stdout = "";
714
- 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
+ };
715
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]`;
716
804
  settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
717
- }).on("data", (data) => {
718
- stdout += data.toString();
719
- }).on("error", (err2) => {
805
+ }).on("data", appendStdout).on("error", (err2) => {
720
806
  settle(() => reject(err2));
721
807
  });
722
- stream.stderr.on("data", (data) => {
723
- stderr += data.toString();
724
- }).on("error", (err2) => {
808
+ stream.stderr.on("data", appendStderr).on("error", (err2) => {
725
809
  settle(() => reject(err2));
726
810
  });
727
811
  });
@@ -858,8 +942,11 @@ async function find(client, options, timeoutMs = 3e4) {
858
942
  if (options.minsize) args.push("-size", `+${options.minsize}`);
859
943
  if (options.maxsize) args.push("-size", `-${options.maxsize}`);
860
944
  if (options.newer) args.push("-newer", shellQuote(options.newer));
861
- const command = `find ${args.join(" ")} 2>/dev/null`;
945
+ const command = `find ${args.join(" ")}`;
862
946
  const result = await exec(client, command, timeoutMs);
947
+ if (!result.stdout.trim() && result.stderr.trim()) {
948
+ throw new Error(result.stderr.trim());
949
+ }
863
950
  return result.stdout.split("\n").filter(Boolean);
864
951
  }
865
952
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
@@ -868,7 +955,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
868
955
  command += ` | grep -i ${shellQuote(grep)}`;
869
956
  }
870
957
  const result = await exec(client, command, timeoutMs);
871
- if (result.code !== 0 && result.stderr && !grep) {
958
+ if (result.stderr.trim()) {
872
959
  throw new Error(result.stderr.trim());
873
960
  }
874
961
  return result.stdout;
@@ -894,8 +981,14 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
894
981
  // src/pool.ts
895
982
  var ConnectionPool = class {
896
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();
897
987
  idleTtlMs;
898
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;
899
992
  constructor(options) {
900
993
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
901
994
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -904,67 +997,98 @@ var ConnectionPool = class {
904
997
  const resolved = resolveConfig(config);
905
998
  const cc = resolved.connectConfig;
906
999
  const key = `${cc.username}@${cc.host}:${cc.port}`;
907
- const existing = this.entries.get(key);
908
- if (existing && !existing.dead) {
909
- existing.refCount++;
910
- if (existing.idleTimer) {
911
- clearTimeout(existing.idleTimer);
912
- existing.idleTimer = null;
913
- }
914
- return existing.client;
915
- }
916
- if (existing?.dead) {
917
- this.entries.delete(key);
918
- }
919
- if (this.entries.size >= this.maxPoolSize) {
920
- let evicted = false;
921
- for (const [k, e] of this.entries) {
922
- if (e.refCount === 0) {
923
- if (e.idleTimer) clearTimeout(e.idleTimer);
924
- try {
925
- e.client.end();
926
- } catch {
927
- }
928
- this.entries.delete(k);
929
- evicted = true;
930
- 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;
931
1009
  }
1010
+ return existing.client;
932
1011
  }
933
- if (!evicted) {
934
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1012
+ if (existing?.dead) {
1013
+ this.entries.delete(key);
935
1014
  }
936
- }
937
- try {
938
- const client = await connectWithProxy(resolved);
939
- const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
940
- const markDead = () => {
941
- entry.dead = true;
942
- if (entry.idleTimer) {
943
- clearTimeout(entry.idleTimer);
944
- entry.idleTimer = null;
945
- }
946
- if (this.entries.get(key) === entry) {
947
- 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
+ }
948
1034
  }
949
- };
950
- client.on("close", markDead);
951
- client.on("end", markDead);
952
- client.on("error", markDead);
953
- this.entries.set(key, entry);
954
- return client;
955
- } catch (err) {
956
- const diag = formatDiagnostics(config.host);
957
- if (diag) {
958
- const message = err instanceof Error ? err.message : String(err);
959
- 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}
960
1069
 
961
1070
  SSH Diagnostics:
962
1071
  ${diag}`);
963
- enhanced.cause = err;
964
- throw enhanced;
1072
+ enhanced.cause = err;
1073
+ throw enhanced;
1074
+ }
1075
+ throw err;
1076
+ }
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;
965
1086
  }
966
- throw err;
1087
+ return client;
967
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
+ );
968
1092
  }
969
1093
  release(client) {
970
1094
  for (const entry of this.entries.values()) {
@@ -1020,6 +1144,10 @@ ${diag}`);
1020
1144
  }
1021
1145
  return { active, idle };
1022
1146
  }
1147
+ /** Total number of successful SSH connects made by this pool since construction. */
1148
+ get connectCount() {
1149
+ return this._connectCount;
1150
+ }
1023
1151
  };
1024
1152
 
1025
1153
  // src/tools.ts
@@ -1027,7 +1155,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
1027
1155
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1028
1156
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1029
1157
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1030
- 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
+ );
1031
1161
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1032
1162
  var connectionParams = {
1033
1163
  host: HostSchema,
@@ -1040,14 +1170,14 @@ function registerTools(server, pool) {
1040
1170
  const connectionPool = pool ?? new ConnectionPool();
1041
1171
  server.tool(
1042
1172
  "ssh_exec",
1043
- "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
1173
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
1044
1174
  {
1045
1175
  ...connectionParams,
1046
- command: z.string().describe("Shell command to execute on the remote host"),
1176
+ command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1047
1177
  timeout: TimeoutSchema
1048
1178
  },
1049
- async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
1050
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1179
+ async ({ command, timeout, ...conn }) => {
1180
+ return connectionPool.withConnection(conn, async (client) => {
1051
1181
  const result = await exec(client, command, timeout || 3e4);
1052
1182
  const parts = [];
1053
1183
  if (result.stdout) parts.push(result.stdout);
@@ -1065,8 +1195,8 @@ ${result.stderr}`);
1065
1195
  ...connectionParams,
1066
1196
  path: z.string().describe("Absolute path to the remote file")
1067
1197
  },
1068
- async ({ host, port, username, privateKeyPath, password, path }) => {
1069
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1198
+ async ({ path, ...conn }) => {
1199
+ return connectionPool.withConnection(conn, async (client) => {
1070
1200
  const content = await readFile(client, path);
1071
1201
  return { content: [{ type: "text", text: content }] };
1072
1202
  });
@@ -1080,8 +1210,8 @@ ${result.stderr}`);
1080
1210
  path: z.string().describe("Absolute path to the remote file"),
1081
1211
  content: z.string().describe("File content to write")
1082
1212
  },
1083
- async ({ host, port, username, privateKeyPath, password, path, content }) => {
1084
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1213
+ async ({ path, content, ...conn }) => {
1214
+ return connectionPool.withConnection(conn, async (client) => {
1085
1215
  await writeFile(client, path, content);
1086
1216
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1087
1217
  });
@@ -1095,8 +1225,8 @@ ${result.stderr}`);
1095
1225
  localPath: z.string().describe("Path to the local file to upload"),
1096
1226
  remotePath: z.string().describe("Absolute path on the remote host")
1097
1227
  },
1098
- async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
1099
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1228
+ async ({ localPath, remotePath, ...conn }) => {
1229
+ return connectionPool.withConnection(conn, async (client) => {
1100
1230
  await uploadFile(client, localPath, remotePath);
1101
1231
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1102
1232
  });
@@ -1110,8 +1240,8 @@ ${result.stderr}`);
1110
1240
  remotePath: z.string().describe("Absolute path to the remote file"),
1111
1241
  localPath: z.string().describe("Local path to save the downloaded file")
1112
1242
  },
1113
- async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
1114
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1243
+ async ({ remotePath, localPath, ...conn }) => {
1244
+ return connectionPool.withConnection(conn, async (client) => {
1115
1245
  await downloadFile(client, remotePath, localPath);
1116
1246
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1117
1247
  });
@@ -1124,8 +1254,8 @@ ${result.stderr}`);
1124
1254
  ...connectionParams,
1125
1255
  path: z.string().describe("Absolute path to the remote directory")
1126
1256
  },
1127
- async ({ host, port, username, privateKeyPath, password, path }) => {
1128
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1257
+ async ({ path, ...conn }) => {
1258
+ return connectionPool.withConnection(conn, async (client) => {
1129
1259
  const files = await listDir(client, path);
1130
1260
  return { content: [{ type: "text", text: files.join("\n") }] };
1131
1261
  });
@@ -1331,21 +1461,8 @@ ${result.stderr}`);
1331
1461
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1332
1462
  timeout: TimeoutSchema
1333
1463
  },
1334
- async ({
1335
- host,
1336
- port,
1337
- username,
1338
- privateKeyPath,
1339
- password,
1340
- path,
1341
- name,
1342
- type,
1343
- maxdepth,
1344
- minsize,
1345
- maxsize,
1346
- timeout
1347
- }) => {
1348
- 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) => {
1349
1466
  const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1350
1467
  if (files.length === 0) {
1351
1468
  return { content: [{ type: "text", text: "No files found." }] };
@@ -1365,8 +1482,8 @@ ${files.join("\n")}` }] };
1365
1482
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1366
1483
  timeout: TimeoutSchema
1367
1484
  },
1368
- async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1369
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1485
+ async ({ path, lines, grep, timeout, ...conn }) => {
1486
+ return connectionPool.withConnection(conn, async (client) => {
1370
1487
  const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1371
1488
  if (!output.trim()) {
1372
1489
  return {
@@ -1390,8 +1507,8 @@ ${files.join("\n")}` }] };
1390
1507
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1391
1508
  timeout: TimeoutSchema
1392
1509
  },
1393
- async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1394
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1510
+ async ({ service, timeout, ...conn }) => {
1511
+ return connectionPool.withConnection(conn, async (client) => {
1395
1512
  const status = await serviceStatus(client, service, timeout || 3e4);
1396
1513
  const lines = [];
1397
1514
  lines.push(`Service: ${status.name}`);
@@ -1411,7 +1528,7 @@ ${files.join("\n")}` }] };
1411
1528
  function createServer(pool) {
1412
1529
  const server = new McpServer({
1413
1530
  name: "ssh-mcp",
1414
- version: "0.6.0"
1531
+ version: "0.7.0"
1415
1532
  });
1416
1533
  registerTools(server, pool);
1417
1534
  return server;
@@ -1441,6 +1558,7 @@ export {
1441
1558
  loadKey,
1442
1559
  multiExec,
1443
1560
  readFile,
1561
+ readKnownHostsKeys,
1444
1562
  registerTools,
1445
1563
  resolveConfig,
1446
1564
  serviceStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.6.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": {