@yawlabs/ssh-mcp 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,4 +1,7 @@
1
1
  // src/server.ts
2
+ import { readFileSync as readFileSync4 } from "fs";
3
+ import { dirname, join as join4 } from "path";
4
+ import { fileURLToPath } from "url";
2
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
6
 
4
7
  // src/tools.ts
@@ -6,7 +9,7 @@ import { z } from "zod";
6
9
 
7
10
  // src/diagnose.ts
8
11
  import { execFileSync } from "child_process";
9
- import { existsSync, readFileSync, readdirSync } from "fs";
12
+ import { existsSync, readdirSync, readFileSync } from "fs";
10
13
  import { homedir } from "os";
11
14
  import { join } from "path";
12
15
  function isValidHostname(host) {
@@ -14,7 +17,7 @@ function isValidHostname(host) {
14
17
  if (host.startsWith("[")) {
15
18
  return /^\[[0-9a-fA-F:]+\]$/.test(host);
16
19
  }
17
- return /^[a-zA-Z0-9._\-]+$/.test(host);
20
+ return /^[a-zA-Z0-9._-]+$/.test(host);
18
21
  }
19
22
  function runArgs(cmd, args) {
20
23
  try {
@@ -256,40 +259,33 @@ function diagnose(host, port = 22) {
256
259
  }
257
260
 
258
261
  // src/env.ts
259
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
262
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
260
263
  import { homedir as homedir2 } from "os";
261
264
  import { join as join2 } from "path";
265
+ function probeAgent(socket, agentLabel) {
266
+ const { stdout, ok } = runArgs("ssh-add", ["-l"]);
267
+ const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
268
+ if (!ok && !noIdentities) return null;
269
+ const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
270
+ return {
271
+ running: true,
272
+ reachable: true,
273
+ socket,
274
+ keys,
275
+ started: false,
276
+ 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.`
277
+ };
278
+ }
279
+ var startedAgentPid = null;
262
280
  function ensureAgent() {
263
281
  const sock = process.env.SSH_AUTH_SOCK;
264
282
  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
- }
283
+ const result = probeAgent(sock, "ssh-agent");
284
+ if (result) return result;
278
285
  }
279
286
  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
- }
287
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
288
+ if (result) return result;
293
289
  }
294
290
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
295
291
  if (ok) {
@@ -297,7 +293,10 @@ function ensureAgent() {
297
293
  const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
298
294
  if (sockMatch) {
299
295
  process.env.SSH_AUTH_SOCK = sockMatch[1];
300
- if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
296
+ if (pidMatch) {
297
+ process.env.SSH_AGENT_PID = pidMatch[1];
298
+ startedAgentPid = Number.parseInt(pidMatch[1], 10);
299
+ }
301
300
  return {
302
301
  running: true,
303
302
  reachable: true,
@@ -305,7 +304,7 @@ function ensureAgent() {
305
304
  keys: [],
306
305
  started: true,
307
306
  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."
307
+ 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
308
  };
310
309
  }
311
310
  }
@@ -431,8 +430,8 @@ function configLookup(host) {
431
430
  user: all.user || "",
432
431
  port: all.port || "22",
433
432
  identityFile: identityFiles,
434
- proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
435
- proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
433
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
434
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
436
435
  all,
437
436
  raw: stdout
438
437
  };
@@ -474,7 +473,7 @@ function checkGitSsh(host = "github.com", user = "git") {
474
473
  const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
475
474
  const text = stdout;
476
475
  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+)/);
476
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
478
477
  return {
479
478
  status: "ok",
480
479
  message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
@@ -626,33 +625,54 @@ function resolveConfig(config) {
626
625
  keepaliveCountMax: 3,
627
626
  hostVerifier: buildHostVerifier(verifierHosts, port)
628
627
  };
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
628
  if (config.privateKeyPath) {
637
629
  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 {
630
+ } else if (config.password) {
631
+ connectConfig.password = config.password;
632
+ } else {
633
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
634
+ if (agentSock) {
635
+ connectConfig.agent = agentSock;
636
+ } else {
637
+ const home = homedir3();
638
+ 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")];
639
+ for (const keyPath of keyPaths) {
640
+ try {
641
+ connectConfig.privateKey = readFileSync3(keyPath);
642
+ break;
643
+ } catch {
644
+ }
646
645
  }
647
646
  }
648
647
  }
649
648
  return { connectConfig, proxyJump: sshConfig?.proxyJump };
650
649
  }
650
+ var DIAG_CACHE_TTL_MS = 2e3;
651
+ var diagAgentCache = null;
652
+ var diagKeysCache = null;
653
+ function cachedAgentCheck() {
654
+ const now = Date.now();
655
+ if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
656
+ return diagAgentCache.result;
657
+ }
658
+ const result = checkSshAgent();
659
+ diagAgentCache = { at: now, result };
660
+ return result;
661
+ }
662
+ function cachedKeysCheck() {
663
+ const now = Date.now();
664
+ if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
665
+ return diagKeysCache.result;
666
+ }
667
+ const result = checkSshKeys();
668
+ diagKeysCache = { at: now, result };
669
+ return result;
670
+ }
651
671
  function formatDiagnostics(host) {
652
672
  try {
653
673
  const checks = [
654
- { name: "SSH Agent", ...checkSshAgent() },
655
- { name: "SSH Keys", ...checkSshKeys() },
674
+ { name: "SSH Agent", ...cachedAgentCheck() },
675
+ { name: "SSH Keys", ...cachedKeysCheck() },
656
676
  { name: "SSH Config", ...checkSshConfig(host) },
657
677
  { name: "Known Hosts", ...checkKnownHosts(host) }
658
678
  ];
@@ -729,9 +749,11 @@ ${diag}`);
729
749
  throw err;
730
750
  }
731
751
  }
732
- function exec(client, command, timeoutMs = 3e4) {
752
+ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
753
+ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
733
754
  return new Promise((resolve, reject) => {
734
755
  let settled = false;
756
+ let activeStream = null;
735
757
  const settle = (fn) => {
736
758
  if (settled) return;
737
759
  settled = true;
@@ -739,6 +761,16 @@ function exec(client, command, timeoutMs = 3e4) {
739
761
  fn();
740
762
  };
741
763
  const timer = setTimeout(() => {
764
+ if (activeStream) {
765
+ try {
766
+ activeStream.signal("TERM");
767
+ } catch {
768
+ }
769
+ try {
770
+ activeStream.close();
771
+ } catch {
772
+ }
773
+ }
742
774
  settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
743
775
  }, timeoutMs);
744
776
  client.exec(command, (err, stream) => {
@@ -746,18 +778,53 @@ function exec(client, command, timeoutMs = 3e4) {
746
778
  settle(() => reject(err));
747
779
  return;
748
780
  }
749
- let stdout = "";
750
- let stderr = "";
781
+ activeStream = stream;
782
+ const stdoutChunks = [];
783
+ const stderrChunks = [];
784
+ let stdoutBytes = 0;
785
+ let stderrBytes = 0;
786
+ let stdoutTruncated = false;
787
+ let stderrTruncated = false;
788
+ const appendStdout = (data) => {
789
+ if (stdoutTruncated) return;
790
+ const remaining = maxBytes - stdoutBytes;
791
+ if (data.length <= remaining) {
792
+ stdoutChunks.push(data);
793
+ stdoutBytes += data.length;
794
+ } else {
795
+ if (remaining > 0) {
796
+ stdoutChunks.push(data.subarray(0, remaining));
797
+ stdoutBytes += remaining;
798
+ }
799
+ stdoutTruncated = true;
800
+ }
801
+ };
802
+ const appendStderr = (data) => {
803
+ if (stderrTruncated) return;
804
+ const remaining = maxBytes - stderrBytes;
805
+ if (data.length <= remaining) {
806
+ stderrChunks.push(data);
807
+ stderrBytes += data.length;
808
+ } else {
809
+ if (remaining > 0) {
810
+ stderrChunks.push(data.subarray(0, remaining));
811
+ stderrBytes += remaining;
812
+ }
813
+ stderrTruncated = true;
814
+ }
815
+ };
751
816
  stream.on("close", (code) => {
817
+ let stdout = Buffer.concat(stdoutChunks).toString("utf8");
818
+ let stderr = Buffer.concat(stderrChunks).toString("utf8");
819
+ if (stdoutTruncated) stdout += `
820
+ [output truncated at ${maxBytes} bytes]`;
821
+ if (stderrTruncated) stderr += `
822
+ [stderr truncated at ${maxBytes} bytes]`;
752
823
  settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
753
- }).on("data", (data) => {
754
- stdout += data.toString();
755
- }).on("error", (err2) => {
824
+ }).on("data", appendStdout).on("error", (err2) => {
756
825
  settle(() => reject(err2));
757
826
  });
758
- stream.stderr.on("data", (data) => {
759
- stderr += data.toString();
760
- }).on("error", (err2) => {
827
+ stream.stderr.on("data", appendStderr).on("error", (err2) => {
761
828
  settle(() => reject(err2));
762
829
  });
763
830
  });
@@ -887,39 +954,43 @@ async function find(client, options, timeoutMs = 3e4) {
887
954
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
888
955
  );
889
956
  }
890
- const args = [shellQuote(options.path)];
957
+ const args = ["--", shellQuote(options.path)];
891
958
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
892
959
  if (options.type) args.push("-type", options.type);
893
960
  if (options.name) args.push("-name", shellQuote(options.name));
894
961
  if (options.minsize) args.push("-size", `+${options.minsize}`);
895
962
  if (options.maxsize) args.push("-size", `-${options.maxsize}`);
896
963
  if (options.newer) args.push("-newer", shellQuote(options.newer));
897
- const command = `find ${args.join(" ")} 2>/dev/null`;
964
+ const command = `find ${args.join(" ")}`;
898
965
  const result = await exec(client, command, timeoutMs);
966
+ if (!result.stdout.trim() && result.stderr.trim()) {
967
+ throw new Error(result.stderr.trim());
968
+ }
899
969
  return result.stdout.split("\n").filter(Boolean);
900
970
  }
901
971
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
902
- let command = `tail -n ${lines} ${shellQuote(path)}`;
972
+ let command = `tail -n ${lines} -- ${shellQuote(path)}`;
903
973
  if (grep) {
904
- command += ` | grep -i ${shellQuote(grep)}`;
974
+ command += ` | grep -i -e ${shellQuote(grep)}`;
905
975
  }
906
976
  const result = await exec(client, command, timeoutMs);
907
- if (result.code !== 0 && result.stderr && !grep) {
977
+ if (result.stderr.trim()) {
908
978
  throw new Error(result.stderr.trim());
909
979
  }
910
980
  return result.stdout;
911
981
  }
912
982
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
913
- const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
983
+ const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
914
984
  const raw = result.stdout;
915
985
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
916
986
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
917
987
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
918
988
  const sinceMatch = raw.match(/since\s+(.+?);/);
989
+ const fallbackStatus = result.code === 0 ? "active" : "inactive";
919
990
  return {
920
991
  name: serviceName,
921
992
  active: activeMatch?.[1] === "active",
922
- status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
993
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
923
994
  description: descMatch?.[1]?.trim(),
924
995
  since: sinceMatch?.[1]?.trim(),
925
996
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
@@ -930,8 +1001,14 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
930
1001
  // src/pool.ts
931
1002
  var ConnectionPool = class {
932
1003
  entries = /* @__PURE__ */ new Map();
1004
+ // Coalesces concurrent connect attempts for the same key so we don't open N
1005
+ // duplicate TCP connections when N tool calls fire simultaneously.
1006
+ pending = /* @__PURE__ */ new Map();
933
1007
  idleTtlMs;
934
1008
  maxPoolSize;
1009
+ // Total number of successful connects ever made by this pool. Useful for
1010
+ // introspection and for tests that want to prove connection reuse.
1011
+ _connectCount = 0;
935
1012
  constructor(options) {
936
1013
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
937
1014
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -940,67 +1017,98 @@ var ConnectionPool = class {
940
1017
  const resolved = resolveConfig(config);
941
1018
  const cc = resolved.connectConfig;
942
1019
  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;
1020
+ const MAX_ACQUIRE_ATTEMPTS = 3;
1021
+ let lastErr;
1022
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
1023
+ const existing = this.entries.get(key);
1024
+ if (existing && !existing.dead) {
1025
+ existing.refCount++;
1026
+ if (existing.idleTimer) {
1027
+ clearTimeout(existing.idleTimer);
1028
+ existing.idleTimer = null;
967
1029
  }
1030
+ return existing.client;
968
1031
  }
969
- if (!evicted) {
970
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1032
+ if (existing?.dead) {
1033
+ this.entries.delete(key);
971
1034
  }
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);
1035
+ let pending = this.pending.get(key);
1036
+ if (!pending) {
1037
+ if (this.entries.size >= this.maxPoolSize) {
1038
+ let evicted = false;
1039
+ for (const [k, e] of this.entries) {
1040
+ if (e.refCount === 0) {
1041
+ if (e.idleTimer) clearTimeout(e.idleTimer);
1042
+ try {
1043
+ e.client.end();
1044
+ } catch {
1045
+ }
1046
+ this.entries.delete(k);
1047
+ evicted = true;
1048
+ break;
1049
+ }
1050
+ }
1051
+ if (!evicted) {
1052
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1053
+ }
984
1054
  }
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}
1055
+ pending = (async () => {
1056
+ try {
1057
+ const client2 = await connectWithProxy(resolved);
1058
+ this._connectCount++;
1059
+ const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
1060
+ const markDead = () => {
1061
+ entry2.dead = true;
1062
+ if (entry2.idleTimer) {
1063
+ clearTimeout(entry2.idleTimer);
1064
+ entry2.idleTimer = null;
1065
+ }
1066
+ if (this.entries.get(key) === entry2) {
1067
+ this.entries.delete(key);
1068
+ }
1069
+ };
1070
+ client2.on("close", markDead);
1071
+ client2.on("end", markDead);
1072
+ client2.on("error", markDead);
1073
+ this.entries.set(key, entry2);
1074
+ return client2;
1075
+ } finally {
1076
+ this.pending.delete(key);
1077
+ }
1078
+ })();
1079
+ this.pending.set(key, pending);
1080
+ }
1081
+ let client;
1082
+ try {
1083
+ client = await pending;
1084
+ } catch (err) {
1085
+ const diag = formatDiagnostics(config.host);
1086
+ if (diag) {
1087
+ const message = err instanceof Error ? err.message : String(err);
1088
+ const enhanced = new Error(`${message}
996
1089
 
997
1090
  SSH Diagnostics:
998
1091
  ${diag}`);
999
- enhanced.cause = err;
1000
- throw enhanced;
1092
+ enhanced.cause = err;
1093
+ throw enhanced;
1094
+ }
1095
+ throw err;
1096
+ }
1097
+ const entry = this.entries.get(key);
1098
+ if (!entry || entry.dead || entry.client !== client) {
1099
+ lastErr = new Error("connection died before acquire could take a ref");
1100
+ continue;
1101
+ }
1102
+ entry.refCount++;
1103
+ if (entry.idleTimer) {
1104
+ clearTimeout(entry.idleTimer);
1105
+ entry.idleTimer = null;
1001
1106
  }
1002
- throw err;
1107
+ return client;
1003
1108
  }
1109
+ throw new Error(
1110
+ `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1111
+ );
1004
1112
  }
1005
1113
  release(client) {
1006
1114
  for (const entry of this.entries.values()) {
@@ -1056,6 +1164,10 @@ ${diag}`);
1056
1164
  }
1057
1165
  return { active, idle };
1058
1166
  }
1167
+ /** Total number of successful SSH connects made by this pool since construction. */
1168
+ get connectCount() {
1169
+ return this._connectCount;
1170
+ }
1059
1171
  };
1060
1172
 
1061
1173
  // src/tools.ts
@@ -1063,7 +1175,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
1063
1175
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1064
1176
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1065
1177
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1066
- var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
1178
+ var PasswordSchema = z.string().optional().describe(
1179
+ "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."
1180
+ );
1067
1181
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1068
1182
  var connectionParams = {
1069
1183
  host: HostSchema,
@@ -1082,8 +1196,8 @@ function registerTools(server, pool) {
1082
1196
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1083
1197
  timeout: TimeoutSchema
1084
1198
  },
1085
- async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
1086
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1199
+ async ({ command, timeout, ...conn }) => {
1200
+ return connectionPool.withConnection(conn, async (client) => {
1087
1201
  const result = await exec(client, command, timeout || 3e4);
1088
1202
  const parts = [];
1089
1203
  if (result.stdout) parts.push(result.stdout);
@@ -1101,8 +1215,8 @@ ${result.stderr}`);
1101
1215
  ...connectionParams,
1102
1216
  path: z.string().describe("Absolute path to the remote file")
1103
1217
  },
1104
- async ({ host, port, username, privateKeyPath, password, path }) => {
1105
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1218
+ async ({ path, ...conn }) => {
1219
+ return connectionPool.withConnection(conn, async (client) => {
1106
1220
  const content = await readFile(client, path);
1107
1221
  return { content: [{ type: "text", text: content }] };
1108
1222
  });
@@ -1116,8 +1230,8 @@ ${result.stderr}`);
1116
1230
  path: z.string().describe("Absolute path to the remote file"),
1117
1231
  content: z.string().describe("File content to write")
1118
1232
  },
1119
- async ({ host, port, username, privateKeyPath, password, path, content }) => {
1120
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1233
+ async ({ path, content, ...conn }) => {
1234
+ return connectionPool.withConnection(conn, async (client) => {
1121
1235
  await writeFile(client, path, content);
1122
1236
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1123
1237
  });
@@ -1131,8 +1245,8 @@ ${result.stderr}`);
1131
1245
  localPath: z.string().describe("Path to the local file to upload"),
1132
1246
  remotePath: z.string().describe("Absolute path on the remote host")
1133
1247
  },
1134
- async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
1135
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1248
+ async ({ localPath, remotePath, ...conn }) => {
1249
+ return connectionPool.withConnection(conn, async (client) => {
1136
1250
  await uploadFile(client, localPath, remotePath);
1137
1251
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1138
1252
  });
@@ -1146,8 +1260,8 @@ ${result.stderr}`);
1146
1260
  remotePath: z.string().describe("Absolute path to the remote file"),
1147
1261
  localPath: z.string().describe("Local path to save the downloaded file")
1148
1262
  },
1149
- async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
1150
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1263
+ async ({ remotePath, localPath, ...conn }) => {
1264
+ return connectionPool.withConnection(conn, async (client) => {
1151
1265
  await downloadFile(client, remotePath, localPath);
1152
1266
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1153
1267
  });
@@ -1160,8 +1274,8 @@ ${result.stderr}`);
1160
1274
  ...connectionParams,
1161
1275
  path: z.string().describe("Absolute path to the remote directory")
1162
1276
  },
1163
- async ({ host, port, username, privateKeyPath, password, path }) => {
1164
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1277
+ async ({ path, ...conn }) => {
1278
+ return connectionPool.withConnection(conn, async (client) => {
1165
1279
  const files = await listDir(client, path);
1166
1280
  return { content: [{ type: "text", text: files.join("\n") }] };
1167
1281
  });
@@ -1367,21 +1481,8 @@ ${result.stderr}`);
1367
1481
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1368
1482
  timeout: TimeoutSchema
1369
1483
  },
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) => {
1484
+ async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
1485
+ return connectionPool.withConnection(conn, async (client) => {
1385
1486
  const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1386
1487
  if (files.length === 0) {
1387
1488
  return { content: [{ type: "text", text: "No files found." }] };
@@ -1401,8 +1502,8 @@ ${files.join("\n")}` }] };
1401
1502
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1402
1503
  timeout: TimeoutSchema
1403
1504
  },
1404
- async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1405
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1505
+ async ({ path, lines, grep, timeout, ...conn }) => {
1506
+ return connectionPool.withConnection(conn, async (client) => {
1406
1507
  const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1407
1508
  if (!output.trim()) {
1408
1509
  return {
@@ -1426,8 +1527,8 @@ ${files.join("\n")}` }] };
1426
1527
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1427
1528
  timeout: TimeoutSchema
1428
1529
  },
1429
- async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1430
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1530
+ async ({ service, timeout, ...conn }) => {
1531
+ return connectionPool.withConnection(conn, async (client) => {
1431
1532
  const status = await serviceStatus(client, service, timeout || 3e4);
1432
1533
  const lines = [];
1433
1534
  lines.push(`Service: ${status.name}`);
@@ -1444,10 +1545,12 @@ ${files.join("\n")}` }] };
1444
1545
  }
1445
1546
 
1446
1547
  // src/server.ts
1548
+ var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1549
+ var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1447
1550
  function createServer(pool) {
1448
1551
  const server = new McpServer({
1449
1552
  name: "ssh-mcp",
1450
- version: "0.7.0"
1553
+ version
1451
1554
  });
1452
1555
  registerTools(server, pool);
1453
1556
  return server;