@yawlabs/ssh-mcp 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ function isValidHostname(host) {
19
19
  if (host.startsWith("[")) {
20
20
  return /^\[[0-9a-fA-F:]+\]$/.test(host);
21
21
  }
22
+ if (host.startsWith("-")) return false;
22
23
  return /^[a-zA-Z0-9._-]+$/.test(host);
23
24
  }
24
25
  function runArgs(cmd, args) {
@@ -146,6 +147,7 @@ function checkConnectivity(host, port = 22) {
146
147
  "StrictHostKeyChecking=no",
147
148
  "-p",
148
149
  String(port),
150
+ "--",
149
151
  host,
150
152
  "echo",
151
153
  "SSH_OK"
@@ -265,7 +267,7 @@ function diagnose(host, port = 22) {
265
267
  function parseSshConfigOutput(stdout) {
266
268
  const all = {};
267
269
  const identityFiles = [];
268
- for (const line of stdout.split("\n")) {
270
+ for (const line of stdout.split(/\r?\n/)) {
269
271
  const spaceIdx = line.indexOf(" ");
270
272
  if (spaceIdx > 0) {
271
273
  const key = line.substring(0, spaceIdx);
@@ -569,6 +571,7 @@ function testConnection(host, port = 22) {
569
571
  "StrictHostKeyChecking=no",
570
572
  "-p",
571
573
  String(port),
574
+ "--",
572
575
  host,
573
576
  "echo",
574
577
  "SSH_OK"
@@ -604,6 +607,9 @@ function testConnection(host, port = 22) {
604
607
  return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
605
608
  }
606
609
 
610
+ // src/pool.ts
611
+ import { createHash } from "crypto";
612
+
607
613
  // src/ssh.ts
608
614
  import { readFileSync as readFileSync3 } from "fs";
609
615
  import { homedir as homedir3 } from "os";
@@ -655,6 +661,25 @@ function buildHostVerifier(hosts, port) {
655
661
  return known.some((k) => k.equals(key));
656
662
  };
657
663
  }
664
+ function isEncryptedKey(content) {
665
+ const text = content.toString("utf8");
666
+ if (text.includes("ENCRYPTED")) return true;
667
+ const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
668
+ if (m) {
669
+ try {
670
+ const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
671
+ const magic = "openssh-key-v1\0";
672
+ if (raw.toString("latin1", 0, magic.length) === magic) {
673
+ const cipherLen = raw.readUInt32BE(magic.length);
674
+ const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
675
+ return cipher !== "none";
676
+ }
677
+ } catch {
678
+ return true;
679
+ }
680
+ }
681
+ return false;
682
+ }
658
683
  function resolveConfig(config) {
659
684
  const sshConfig = resolveFromSshConfig(config.host);
660
685
  const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
@@ -678,15 +703,16 @@ function resolveConfig(config) {
678
703
  const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
679
704
  if (agentSock) {
680
705
  connectConfig.agent = agentSock;
681
- } else {
682
- const home = homedir3();
683
- 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")];
684
- for (const keyPath of keyPaths) {
685
- try {
686
- connectConfig.privateKey = readFileSync3(keyPath);
687
- break;
688
- } catch {
689
- }
706
+ }
707
+ const home = homedir3();
708
+ 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")];
709
+ for (const keyPath of keyPaths) {
710
+ try {
711
+ const keyData = readFileSync3(keyPath);
712
+ if (agentSock && isEncryptedKey(keyData)) continue;
713
+ connectConfig.privateKey = keyData;
714
+ break;
715
+ } catch {
690
716
  }
691
717
  }
692
718
  }
@@ -1032,6 +1058,22 @@ function defaultMaxPoolSize() {
1032
1058
  const parsed = Number.parseInt(raw, 10);
1033
1059
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1034
1060
  }
1061
+ function authFingerprint(cc) {
1062
+ const h = createHash("sha256");
1063
+ if (cc.privateKey) {
1064
+ h.update("k");
1065
+ h.update(cc.privateKey);
1066
+ }
1067
+ if (cc.password !== void 0) {
1068
+ h.update("p");
1069
+ h.update(cc.password);
1070
+ }
1071
+ if (typeof cc.agent === "string") {
1072
+ h.update("a");
1073
+ h.update(cc.agent);
1074
+ }
1075
+ return h.digest("hex").slice(0, 16);
1076
+ }
1035
1077
  var ConnectionPool = class {
1036
1078
  entries = /* @__PURE__ */ new Map();
1037
1079
  // Coalesces concurrent connect attempts for the same key so we don't open N
@@ -1053,7 +1095,7 @@ var ConnectionPool = class {
1053
1095
  async acquire(config) {
1054
1096
  const resolved = resolveConfig(config);
1055
1097
  const cc = resolved.connectConfig;
1056
- const key = `${cc.username}@${cc.host}:${cc.port}`;
1098
+ const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
1057
1099
  const MAX_ACQUIRE_ATTEMPTS = 3;
1058
1100
  let lastErr;
1059
1101
  for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
@@ -1295,7 +1337,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1295
1337
  const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1296
1338
  const raw = result.stdout;
1297
1339
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1298
- const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1340
+ const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
1299
1341
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1300
1342
  const sinceMatch = raw.match(/since\s+(.+?);/);
1301
1343
  const unknown = !activeMatch && result.code !== 0;
@@ -1814,6 +1856,10 @@ async function main() {
1814
1856
  process.on("SIGTERM", () => {
1815
1857
  shutdown().catch(() => process.exit(1));
1816
1858
  });
1859
+ process.on("exit", () => {
1860
+ pool.drain();
1861
+ killStartedAgent();
1862
+ });
1817
1863
  await server.connect(transport);
1818
1864
  }
1819
1865
  main().catch((err) => {
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ function isValidHostname(host) {
17
17
  if (host.startsWith("[")) {
18
18
  return /^\[[0-9a-fA-F:]+\]$/.test(host);
19
19
  }
20
+ if (host.startsWith("-")) return false;
20
21
  return /^[a-zA-Z0-9._-]+$/.test(host);
21
22
  }
22
23
  function runArgs(cmd, args) {
@@ -144,6 +145,7 @@ function checkConnectivity(host, port = 22) {
144
145
  "StrictHostKeyChecking=no",
145
146
  "-p",
146
147
  String(port),
148
+ "--",
147
149
  host,
148
150
  "echo",
149
151
  "SSH_OK"
@@ -269,7 +271,7 @@ import { join as join2 } from "path";
269
271
  function parseSshConfigOutput(stdout) {
270
272
  const all = {};
271
273
  const identityFiles = [];
272
- for (const line of stdout.split("\n")) {
274
+ for (const line of stdout.split(/\r?\n/)) {
273
275
  const spaceIdx = line.indexOf(" ");
274
276
  if (spaceIdx > 0) {
275
277
  const key = line.substring(0, spaceIdx);
@@ -565,6 +567,7 @@ function testConnection(host, port = 22) {
565
567
  "StrictHostKeyChecking=no",
566
568
  "-p",
567
569
  String(port),
570
+ "--",
568
571
  host,
569
572
  "echo",
570
573
  "SSH_OK"
@@ -651,6 +654,25 @@ function buildHostVerifier(hosts, port) {
651
654
  return known.some((k) => k.equals(key));
652
655
  };
653
656
  }
657
+ function isEncryptedKey(content) {
658
+ const text = content.toString("utf8");
659
+ if (text.includes("ENCRYPTED")) return true;
660
+ const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
661
+ if (m) {
662
+ try {
663
+ const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
664
+ const magic = "openssh-key-v1\0";
665
+ if (raw.toString("latin1", 0, magic.length) === magic) {
666
+ const cipherLen = raw.readUInt32BE(magic.length);
667
+ const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
668
+ return cipher !== "none";
669
+ }
670
+ } catch {
671
+ return true;
672
+ }
673
+ }
674
+ return false;
675
+ }
654
676
  function resolveConfig(config) {
655
677
  const sshConfig = resolveFromSshConfig(config.host);
656
678
  const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
@@ -674,15 +696,16 @@ function resolveConfig(config) {
674
696
  const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
675
697
  if (agentSock) {
676
698
  connectConfig.agent = agentSock;
677
- } else {
678
- const home = homedir3();
679
- 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")];
680
- for (const keyPath of keyPaths) {
681
- try {
682
- connectConfig.privateKey = readFileSync3(keyPath);
683
- break;
684
- } catch {
685
- }
699
+ }
700
+ const home = homedir3();
701
+ 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")];
702
+ for (const keyPath of keyPaths) {
703
+ try {
704
+ const keyData = readFileSync3(keyPath);
705
+ if (agentSock && isEncryptedKey(keyData)) continue;
706
+ connectConfig.privateKey = keyData;
707
+ break;
708
+ } catch {
686
709
  }
687
710
  }
688
711
  }
@@ -1106,7 +1129,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1106
1129
  const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1107
1130
  const raw = result.stdout;
1108
1131
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1109
- const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1132
+ const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
1110
1133
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1111
1134
  const sinceMatch = raw.match(/since\s+(.+?);/);
1112
1135
  const unknown = !activeMatch && result.code !== 0;
@@ -1157,12 +1180,29 @@ function isPolicyConfigured() {
1157
1180
  }
1158
1181
 
1159
1182
  // src/pool.ts
1183
+ import { createHash } from "crypto";
1160
1184
  function defaultMaxPoolSize() {
1161
1185
  const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1162
1186
  if (!raw) return 100;
1163
1187
  const parsed = Number.parseInt(raw, 10);
1164
1188
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1165
1189
  }
1190
+ function authFingerprint(cc) {
1191
+ const h = createHash("sha256");
1192
+ if (cc.privateKey) {
1193
+ h.update("k");
1194
+ h.update(cc.privateKey);
1195
+ }
1196
+ if (cc.password !== void 0) {
1197
+ h.update("p");
1198
+ h.update(cc.password);
1199
+ }
1200
+ if (typeof cc.agent === "string") {
1201
+ h.update("a");
1202
+ h.update(cc.agent);
1203
+ }
1204
+ return h.digest("hex").slice(0, 16);
1205
+ }
1166
1206
  var ConnectionPool = class {
1167
1207
  entries = /* @__PURE__ */ new Map();
1168
1208
  // Coalesces concurrent connect attempts for the same key so we don't open N
@@ -1184,7 +1224,7 @@ var ConnectionPool = class {
1184
1224
  async acquire(config) {
1185
1225
  const resolved = resolveConfig(config);
1186
1226
  const cc = resolved.connectConfig;
1187
- const key = `${cc.username}@${cc.host}:${cc.port}`;
1227
+ const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
1188
1228
  const MAX_ACQUIRE_ATTEMPTS = 3;
1189
1229
  let lastErr;
1190
1230
  for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "MCP server for SSH operations with built-in diagnostics",
6
6
  "type": "module",