@yawlabs/ssh-mcp 0.14.0 → 0.15.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,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/server.ts
2
9
  import { readFileSync as readFileSync4 } from "fs";
3
10
  import { dirname, join as join4 } from "path";
@@ -12,6 +19,13 @@ import { execFileSync } from "child_process";
12
19
  import { existsSync, readdirSync, readFileSync } from "fs";
13
20
  import { homedir } from "os";
14
21
  import { join } from "path";
22
+ var SSH_NON_KEY_FILES = /* @__PURE__ */ new Set([
23
+ "known_hosts",
24
+ "known_hosts.old",
25
+ "config",
26
+ "authorized_keys",
27
+ "environment"
28
+ ]);
15
29
  function isValidHostname(host) {
16
30
  if (host.length === 0 || host.length > 253) return false;
17
31
  if (host.startsWith("[")) {
@@ -58,16 +72,16 @@ ${stdout2}` };
58
72
  };
59
73
  }
60
74
  const { stdout, ok } = runArgs("ssh-add", ["-l"]);
61
- if (!ok && stdout.includes("Could not open a connection")) {
75
+ if (stdout.includes("The agent has no identities") || stdout.includes("no identities")) {
62
76
  return {
63
- status: "error",
64
- message: `SSH_AUTH_SOCK is set to "${sock}" but the agent is not reachable. The agent process may have died. Run: eval "$(ssh-agent -s)"`
77
+ status: "warning",
78
+ message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
65
79
  };
66
80
  }
67
- if (stdout.includes("The agent has no identities")) {
81
+ if (!ok) {
68
82
  return {
69
- status: "warning",
70
- message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
83
+ status: "error",
84
+ message: stdout.includes("Could not open a connection") ? `SSH_AUTH_SOCK is set to "${sock}" but the agent is not reachable. The agent process may have died. Run: eval "$(ssh-agent -s)"` : `SSH_AUTH_SOCK is set to "${sock}" but ssh-add could not query the agent: ${stdout || "no output"}. Run: eval "$(ssh-agent -s)"`
71
85
  };
72
86
  }
73
87
  return { status: "ok", message: `ssh-agent running with keys:
@@ -88,9 +102,7 @@ function checkSshKeys() {
88
102
  }
89
103
  }
90
104
  try {
91
- const allFiles = readdirSync(sshDir).filter(
92
- (f) => !f.endsWith(".pub") && !["known_hosts", "known_hosts.old", "config", "authorized_keys"].includes(f)
93
- );
105
+ const allFiles = readdirSync(sshDir).filter((f) => !f.endsWith(".pub") && !SSH_NON_KEY_FILES.has(f));
94
106
  for (const f of allFiles) {
95
107
  if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
96
108
  try {
@@ -132,10 +144,17 @@ function checkKnownHosts(host) {
132
144
  }
133
145
  return { status: "ok", message: `Host "${host}" found in known_hosts` };
134
146
  }
135
- function checkConnectivity(host, port = 22) {
136
- if (!isValidHostname(host)) {
137
- return { status: "error", message: `Invalid hostname: "${host}"` };
138
- }
147
+ function classifySshProbe(ok, output) {
148
+ if (ok && output.includes("SSH_OK")) return "ok";
149
+ if (output.includes("Permission denied")) return "permission-denied";
150
+ if (output.includes("Connection refused")) return "connection-refused";
151
+ if (output.includes("timed out")) return "timed-out";
152
+ if (output.includes("Host key verification failed")) return "host-key-mismatch";
153
+ if (output.includes("Could not resolve")) return "dns-failure";
154
+ return "unknown";
155
+ }
156
+ function probeSshConnection(host, port) {
157
+ const start = Date.now();
139
158
  const { ok, stdout } = runArgs("ssh", [
140
159
  "-o",
141
160
  "ConnectTimeout=5",
@@ -150,40 +169,66 @@ function checkConnectivity(host, port = 22) {
150
169
  "echo",
151
170
  "SSH_OK"
152
171
  ]);
153
- if (ok && stdout.includes("SSH_OK")) {
154
- return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
155
- }
156
- if (stdout.includes("Permission denied")) {
157
- return {
158
- status: "error",
159
- message: `Permission denied connecting to ${host}:${port}. Your key is not authorized on this host. Check: 1) correct key is loaded (ssh-add -l), 2) key is in remote authorized_keys, 3) correct username.`
160
- };
161
- }
162
- if (stdout.includes("Connection refused")) {
163
- return {
164
- status: "error",
165
- message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
166
- };
167
- }
168
- if (stdout.includes("Connection timed out") || stdout.includes("timed out")) {
169
- return {
170
- status: "error",
171
- message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
172
- };
172
+ return { outcome: classifySshProbe(ok, stdout), output: stdout, elapsedMs: Date.now() - start };
173
+ }
174
+ function checkConnectivity(host, port = 22) {
175
+ if (!isValidHostname(host)) {
176
+ return { status: "error", message: `Invalid hostname: "${host}"` };
173
177
  }
174
- if (stdout.includes("Host key verification failed")) {
175
- return {
176
- status: "error",
177
- message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R "${host}" && ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`
178
- };
178
+ const { outcome, output } = probeSshConnection(host, port);
179
+ switch (outcome) {
180
+ case "ok":
181
+ return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
182
+ case "permission-denied":
183
+ return {
184
+ status: "error",
185
+ message: `Permission denied connecting to ${host}:${port}. Your key is not authorized on this host. Check: 1) correct key is loaded (ssh-add -l), 2) key is in remote authorized_keys, 3) correct username.`
186
+ };
187
+ case "connection-refused":
188
+ return {
189
+ status: "error",
190
+ message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
191
+ };
192
+ case "timed-out":
193
+ return {
194
+ status: "error",
195
+ message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
196
+ };
197
+ case "host-key-mismatch":
198
+ return {
199
+ status: "error",
200
+ message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R "${host}" && ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`
201
+ };
202
+ case "dns-failure":
203
+ return {
204
+ status: "error",
205
+ message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
206
+ };
207
+ default:
208
+ return { status: "error", message: `SSH connection failed: ${output}` };
179
209
  }
180
- if (stdout.includes("Could not resolve hostname")) {
181
- return {
182
- status: "error",
183
- message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
184
- };
210
+ }
211
+ function matchesHostPattern(pattern, host) {
212
+ if (pattern === "*") return true;
213
+ if (pattern === host) return true;
214
+ if (pattern.includes("*") || pattern.includes("?")) {
215
+ const escaped = pattern.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
216
+ return new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$").test(host);
185
217
  }
186
- return { status: "error", message: `SSH connection failed: ${stdout}` };
218
+ return false;
219
+ }
220
+ function hostLineSelects(patternList, host) {
221
+ let positive = false;
222
+ for (const raw of patternList.split(/\s+/)) {
223
+ if (!raw) continue;
224
+ const negated = raw.startsWith("!");
225
+ const pattern = negated ? raw.slice(1) : raw;
226
+ if (!pattern) continue;
227
+ if (!matchesHostPattern(pattern, host)) continue;
228
+ if (negated) return false;
229
+ positive = true;
230
+ }
231
+ return positive;
187
232
  }
188
233
  function checkSshConfig(host) {
189
234
  const configPath = join(homedir(), ".ssh", "config");
@@ -192,24 +237,17 @@ function checkSshConfig(host) {
192
237
  }
193
238
  try {
194
239
  const content = readFileSync(configPath, "utf8");
195
- const lines = content.split("\n");
240
+ const lines = content.split(/\r?\n/);
196
241
  let inHostBlock = false;
197
242
  const hostConfig = [];
198
- for (const line of lines) {
199
- const trimmed = line.trim();
200
- if (/^Host\s+/i.test(trimmed)) {
201
- const patterns = trimmed.replace(/^Host\s+/i, "").trim().split(/\s+/);
202
- inHostBlock = patterns.some((p) => {
203
- if (p === "*") return true;
204
- if (p === host) return true;
205
- if (p.includes("*") || p.includes("?")) {
206
- const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
207
- const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
208
- return regex.test(host);
209
- }
210
- return false;
211
- });
243
+ for (const rawLine of lines) {
244
+ const trimmed = rawLine.replace(/(^|\s)#.*/, "$1").trim();
245
+ if (/^Host[\s=]/i.test(trimmed)) {
246
+ const patternList = trimmed.replace(/^Host[\s=]+/i, "").trim();
247
+ inHostBlock = hostLineSelects(patternList, host);
212
248
  if (inHostBlock) hostConfig.push(trimmed);
249
+ } else if (/^Match[\s=]/i.test(trimmed)) {
250
+ inHostBlock = false;
213
251
  } else if (inHostBlock && trimmed) {
214
252
  hostConfig.push(trimmed);
215
253
  }
@@ -261,9 +299,18 @@ function diagnose(host, port = 22) {
261
299
 
262
300
  // src/env.ts
263
301
  import { execFileSync as execFileSync2 } from "child_process";
264
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
302
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
303
+ import { homedir as homedir3 } from "os";
304
+ import { join as join3 } from "path";
305
+
306
+ // src/ssh.ts
307
+ import { readFileSync as readFileSync2 } from "fs";
308
+ import { createRequire } from "module";
265
309
  import { homedir as homedir2 } from "os";
266
310
  import { join as join2 } from "path";
311
+ import {
312
+ Client
313
+ } from "ssh2";
267
314
 
268
315
  // src/ssh-config.ts
269
316
  function parseSshConfigOutput(stdout) {
@@ -284,439 +331,223 @@ function parseSshConfigOutput(stdout) {
284
331
  return { all, identityFiles };
285
332
  }
286
333
 
287
- // src/env.ts
288
- function runArgsWithEnv(cmd, args, extraEnv) {
289
- const env = {};
290
- for (const [k, v] of Object.entries(process.env)) {
291
- if (typeof v === "string") env[k] = v;
334
+ // src/ssh.ts
335
+ var sshConfigCache = /* @__PURE__ */ new Map();
336
+ function resolveFromSshConfig(host) {
337
+ const cached = sshConfigCache.get(host);
338
+ if (cached !== void 0) return cached;
339
+ const result = resolveFromSshConfigUncached(host);
340
+ sshConfigCache.set(host, result);
341
+ return result;
342
+ }
343
+ function resolveFromSshConfigUncached(host) {
344
+ try {
345
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
346
+ if (!ok) return null;
347
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
348
+ return {
349
+ hostname: all.hostname || host,
350
+ user: all.user || "",
351
+ port: all.port || "22",
352
+ identityFiles,
353
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
354
+ };
355
+ } catch {
356
+ return null;
292
357
  }
293
- for (const [k, v] of Object.entries(extraEnv)) {
294
- if (v === void 0) {
295
- delete env[k];
296
- } else {
297
- env[k] = v;
358
+ }
359
+ function unbracketHost(host) {
360
+ return host.length > 2 && host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
361
+ }
362
+ function knownHostsTargets(host, port) {
363
+ const bare = unbracketHost(host);
364
+ const isIpv6 = bare.includes(":");
365
+ if (!isValidHostname(isIpv6 ? `[${bare}]` : bare)) return [];
366
+ return port && port !== 22 ? [`[${bare}]:${port}`, bare] : [bare];
367
+ }
368
+ function readKnownHostsEntries(host, port) {
369
+ const entries = [];
370
+ for (const target of knownHostsTargets(host, port)) {
371
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
372
+ if (!ok || !stdout.trim()) continue;
373
+ for (const line of stdout.split("\n")) {
374
+ const trimmed = line.trim();
375
+ if (!trimmed || trimmed.startsWith("#")) continue;
376
+ const parts = trimmed.split(/\s+/);
377
+ if (parts[0].startsWith("@")) continue;
378
+ if (parts.length < 3) continue;
379
+ try {
380
+ entries.push({ type: parts[1], key: Buffer.from(parts[2], "base64") });
381
+ } catch {
382
+ }
298
383
  }
299
384
  }
300
- try {
301
- const stdout = execFileSync2(cmd, args, {
302
- env,
303
- encoding: "utf8",
304
- timeout: 1e4,
305
- stdio: ["pipe", "pipe", "pipe"]
306
- });
307
- return { stdout: stdout.trim(), ok: true };
308
- } catch (e) {
309
- const err = e;
310
- const so = err.stdout?.toString().trim() || "";
311
- const se = err.stderr?.toString().trim() || "";
312
- const output = [so, se].filter(Boolean).join("\n") || err.message || "";
313
- return { stdout: output, ok: false };
314
- }
385
+ return entries;
315
386
  }
316
- function probeAgent(socket, agentLabel) {
317
- const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
318
- const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
319
- const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
320
- const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
321
- if (!ok && !noIdentities) return null;
322
- const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
323
- return {
324
- running: true,
325
- reachable: true,
326
- socket,
327
- keys,
328
- started: false,
329
- 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.`
330
- };
387
+ function readKnownHostsKeys(host, port) {
388
+ return readKnownHostsEntries(host, port).map((e) => e.key);
331
389
  }
332
- var startedAgentPid = null;
333
- function ensureAgent() {
334
- const sock = process.env.SSH_AUTH_SOCK;
335
- if (sock) {
336
- const result = probeAgent(sock, "ssh-agent");
337
- if (result) return result;
390
+ function hostKeyBlobType(key) {
391
+ if (key.length < 4) return null;
392
+ const len = key.readUInt32BE(0);
393
+ if (len === 0 || len > 64 || key.length < 4 + len) return null;
394
+ const type = key.toString("utf8", 4, 4 + len);
395
+ return /^[a-zA-Z0-9@._-]+$/.test(type) ? type : null;
396
+ }
397
+ var HOST_KEY_TYPE_TO_ALGORITHMS = {
398
+ "ssh-rsa": ["rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"]
399
+ };
400
+ var SSH2_CONSTANTS_MODULE = "ssh2/lib/protocol/constants.js";
401
+ function loadSsh2Constants() {
402
+ try {
403
+ return __require("ssh2/lib/protocol/constants.js");
404
+ } catch {
338
405
  }
339
- if (process.platform === "win32") {
340
- const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
341
- if (result) return result;
342
- return {
343
- running: false,
344
- reachable: false,
345
- keys: [],
346
- started: false,
347
- message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
348
- };
406
+ try {
407
+ return createRequire(import.meta.url)(SSH2_CONSTANTS_MODULE);
408
+ } catch {
409
+ return null;
349
410
  }
350
- const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
351
- if (ok) {
352
- const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
353
- const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
354
- if (sockMatch) {
355
- process.env.SSH_AUTH_SOCK = sockMatch[1];
356
- if (pidMatch) {
357
- process.env.SSH_AGENT_PID = pidMatch[1];
358
- startedAgentPid = Number.parseInt(pidMatch[1], 10);
411
+ }
412
+ var ssh2DefaultHostKeyAlgos;
413
+ function defaultServerHostKeyAlgorithms() {
414
+ if (ssh2DefaultHostKeyAlgos !== void 0) return ssh2DefaultHostKeyAlgos;
415
+ const DEFAULT_SERVER_HOST_KEY = loadSsh2Constants()?.DEFAULT_SERVER_HOST_KEY;
416
+ ssh2DefaultHostKeyAlgos = Array.isArray(DEFAULT_SERVER_HOST_KEY) && DEFAULT_SERVER_HOST_KEY.length > 0 && DEFAULT_SERVER_HOST_KEY.every((a) => typeof a === "string") ? DEFAULT_SERVER_HOST_KEY.slice() : null;
417
+ return ssh2DefaultHostKeyAlgos;
418
+ }
419
+ function hostKeyAlgorithmOrder(knownHostTypes) {
420
+ if (knownHostTypes.length === 0) return null;
421
+ const defaults = defaultServerHostKeyAlgorithms();
422
+ if (!defaults) return null;
423
+ const preferred = /* @__PURE__ */ new Set();
424
+ for (const type of knownHostTypes) {
425
+ for (const algo of HOST_KEY_TYPE_TO_ALGORITHMS[type] ?? [type]) preferred.add(algo);
426
+ }
427
+ const front = defaults.filter((a) => preferred.has(a));
428
+ const back = defaults.filter((a) => !preferred.has(a));
429
+ if (front.length === 0 || back.length === 0) return null;
430
+ return [...front, ...back];
431
+ }
432
+ var KNOWN_HOST_TYPE_TTL_MS = 5e3;
433
+ var knownHostTypeCache = /* @__PURE__ */ new Map();
434
+ function cachedKnownHostTypes(hosts, port) {
435
+ const cacheKey = `${hosts.join(" ")}:${port ?? ""}`;
436
+ const now = Date.now();
437
+ const hit = knownHostTypeCache.get(cacheKey);
438
+ if (hit && now - hit.at < KNOWN_HOST_TYPE_TTL_MS) return hit.types;
439
+ const types = [...new Set(hosts.flatMap((h) => readKnownHostsEntries(h, port).map((e) => e.type)))];
440
+ knownHostTypeCache.set(cacheKey, { at: now, types });
441
+ return types;
442
+ }
443
+ function buildHostVerifier(hosts, port, rejection) {
444
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
445
+ const label = hosts.join(" / ");
446
+ const remediationHost = unbracketHost(hosts[0]);
447
+ return (key) => {
448
+ rejection.current = null;
449
+ const known = hosts.flatMap((h) => readKnownHostsEntries(h, port));
450
+ if (known.length === 0) {
451
+ if (strict) {
452
+ rejection.current = {
453
+ reason: "unknown-host-strict",
454
+ message: `no known_hosts entry for ${label}, and SSH_MCP_STRICT_HOST_KEY=1 requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
455
+ };
359
456
  }
360
- return {
361
- running: true,
362
- reachable: true,
363
- socket: sockMatch[1],
364
- keys: [],
365
- started: true,
366
- env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
367
- 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."
368
- };
457
+ return !strict;
369
458
  }
370
- }
371
- return {
372
- running: false,
373
- reachable: false,
374
- keys: [],
375
- started: false,
376
- message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
459
+ if (known.some((e) => e.key.equals(key))) return true;
460
+ const offered = hostKeyBlobType(key);
461
+ const knownTypes = [...new Set(known.map((e) => e.type))];
462
+ rejection.current = offered && !knownTypes.includes(offered) ? {
463
+ reason: "algorithm-not-in-known-hosts",
464
+ message: `the server offered a ${offered} host key, but known_hosts has only ${knownTypes.join(", ")} for ${label}. This is NOT a key mismatch -- there is no ${offered} entry to compare it against. Refresh the entry: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
465
+ } : {
466
+ reason: "key-mismatch",
467
+ message: `the server's ${offered ?? "offered"} host key does NOT match the known_hosts entry of the same type for ${label}. This can mean a man-in-the-middle attack, or that the host was legitimately rekeyed. Verify the fingerprint out of band before removing the old entry with: ssh-keygen -R "${remediationHost}"`
468
+ };
469
+ return false;
377
470
  };
378
471
  }
379
- function detectKeyType(filePath, fileName) {
380
- const pubPath = `${filePath}.pub`;
381
- if (existsSync2(pubPath)) {
472
+ var PRIVATE_KEY_MARKER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
473
+ function looksLikePrivateKey(content) {
474
+ const text = content.toString("utf8");
475
+ return PRIVATE_KEY_MARKER.test(text) || text.trimStart().startsWith("PuTTY-User-Key-File-");
476
+ }
477
+ function isEncryptedKey(content) {
478
+ const text = content.toString("utf8");
479
+ if (text.includes("ENCRYPTED")) return true;
480
+ const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
481
+ if (m) {
382
482
  try {
383
- const pub = readFileSync2(pubPath, "utf8");
384
- if (pub.includes("ssh-ed25519")) return "ed25519";
385
- if (pub.includes("ssh-rsa")) return "rsa";
386
- if (pub.includes("ecdsa")) return "ecdsa";
387
- if (pub.includes("ssh-dss")) return "dsa";
483
+ const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
484
+ const magic = "openssh-key-v1\0";
485
+ if (raw.toString("latin1", 0, magic.length) !== magic) return true;
486
+ const cipherLen = raw.readUInt32BE(magic.length);
487
+ const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
488
+ return cipher !== "none";
388
489
  } catch {
490
+ return true;
389
491
  }
390
492
  }
391
- if (fileName.includes("ed25519")) return "ed25519";
392
- if (fileName.includes("rsa")) return "rsa";
393
- if (fileName.includes("ecdsa")) return "ecdsa";
394
- if (fileName.includes("dsa")) return "dsa";
395
- try {
396
- const content = readFileSync2(filePath, "utf8");
397
- if (content.includes("RSA PRIVATE KEY")) return "rsa";
398
- if (content.includes("EC PRIVATE KEY")) return "ecdsa";
399
- if (content.includes("DSA PRIVATE KEY")) return "dsa";
400
- if (content.includes("OPENSSH PRIVATE KEY")) {
401
- const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
402
- if (ok) {
403
- const match = stdout.match(/\(([^)]+)\)\s*$/);
404
- if (match) return match[1].toLowerCase();
493
+ return false;
494
+ }
495
+ function resolveConfig(config) {
496
+ const sshConfig = resolveFromSshConfig(config.host);
497
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
498
+ const verifierHosts = [];
499
+ const seenHosts = /* @__PURE__ */ new Set();
500
+ for (const candidate of [config.host, sshConfig?.hostname]) {
501
+ if (!candidate) continue;
502
+ const canonical = unbracketHost(candidate);
503
+ if (seenHosts.has(canonical)) continue;
504
+ seenHosts.add(canonical);
505
+ verifierHosts.push(candidate);
506
+ }
507
+ const hostKeyRejection = { current: null };
508
+ const connectConfig = {
509
+ host: sshConfig?.hostname || config.host,
510
+ port,
511
+ username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
512
+ keepaliveInterval: 15e3,
513
+ keepaliveCountMax: 3,
514
+ hostVerifier: buildHostVerifier(verifierHosts, port, hostKeyRejection)
515
+ };
516
+ let algorithmsApplied = false;
517
+ const applyHostKeyAlgorithms = () => {
518
+ if (algorithmsApplied) return;
519
+ algorithmsApplied = true;
520
+ const algorithmOrder = hostKeyAlgorithmOrder(cachedKnownHostTypes(verifierHosts, port));
521
+ if (algorithmOrder) {
522
+ connectConfig.algorithms = { serverHostKey: algorithmOrder };
523
+ }
524
+ };
525
+ const home = homedir2();
526
+ if (config.privateKeyPath) {
527
+ const keyPath = config.privateKeyPath.startsWith("~") ? join2(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
528
+ connectConfig.privateKey = readFileSync2(keyPath);
529
+ } else if (config.password) {
530
+ connectConfig.password = config.password;
531
+ } else {
532
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
533
+ if (agentSock) {
534
+ connectConfig.agent = agentSock;
535
+ }
536
+ 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")];
537
+ for (const keyPath of keyPaths) {
538
+ let keyData;
539
+ try {
540
+ keyData = readFileSync2(keyPath);
541
+ } catch {
542
+ continue;
405
543
  }
544
+ if (!looksLikePrivateKey(keyData)) continue;
545
+ if (agentSock && isEncryptedKey(keyData)) continue;
546
+ connectConfig.privateKey = keyData;
547
+ break;
406
548
  }
407
- } catch {
408
549
  }
409
- return "unknown";
410
- }
411
- function listSshKeys() {
412
- const sshDir = join2(homedir2(), ".ssh");
413
- if (!existsSync2(sshDir)) return [];
414
- const loadedFingerprints = /* @__PURE__ */ new Set();
415
- const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
416
- if (agentOk && !agentOut.includes("no identities")) {
417
- for (const line of agentOut.split("\n").filter(Boolean)) {
418
- const match = line.match(/(\S+:\S+)/);
419
- if (match) loadedFingerprints.add(match[1]);
420
- }
421
- }
422
- const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
423
- const keys = [];
424
- let files;
425
- try {
426
- files = readdirSync2(sshDir);
427
- } catch {
428
- return [];
429
- }
430
- for (const file of files) {
431
- if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
432
- const filePath = join2(sshDir, file);
433
- try {
434
- const stat = statSync(filePath);
435
- if (!stat.isFile()) continue;
436
- const content = readFileSync2(filePath, "utf8");
437
- if (!content.includes("PRIVATE KEY")) continue;
438
- const type = detectKeyType(filePath, file);
439
- let fingerprint;
440
- const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
441
- if (fpOk) {
442
- const match = fpOut.match(/(\S+:\S+)/);
443
- fingerprint = match?.[1];
444
- }
445
- const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
446
- keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
447
- } catch {
448
- }
449
- }
450
- return keys;
451
- }
452
- function loadKey(keyPath) {
453
- const agent = ensureAgent();
454
- if (!agent.reachable) {
455
- return { status: "error", message: agent.message };
456
- }
457
- const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
458
- if (!existsSync2(resolved)) {
459
- return { status: "error", message: `Key not found: ${resolved}` };
460
- }
461
- const { stdout, ok } = runArgs("ssh-add", [resolved]);
462
- if (ok) {
463
- return { status: "ok", message: `Key loaded: ${resolved}` };
464
- }
465
- if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
466
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
467
- }
468
- if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
469
- return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
470
- }
471
- return { status: "error", message: `Failed to load key: ${stdout}` };
472
- }
473
- function configLookup(host) {
474
- if (!isValidHostname(host)) {
475
- return { error: `Invalid hostname: "${host}"` };
476
- }
477
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
478
- if (!ok) {
479
- return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
480
- }
481
- const { all, identityFiles } = parseSshConfigOutput(stdout);
482
- return {
483
- hostname: all.hostname || host,
484
- user: all.user || "",
485
- port: all.port || "22",
486
- identityFile: identityFiles,
487
- proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
488
- proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
489
- all,
490
- raw: stdout
491
- };
492
- }
493
- function fixKnownHosts(host, port = 22) {
494
- if (!isValidHostname(host)) {
495
- return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
496
- }
497
- const actions = [];
498
- const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
499
- if (removeOk) {
500
- actions.push(`Removed old host key for ${host}`);
501
- }
502
- if (port !== 22) {
503
- const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
504
- if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
505
- }
506
- const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
507
- const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
508
- if (scanOk && scanOut.trim()) {
509
- try {
510
- const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
511
- appendFileSync(knownHostsPath, `
512
- ${scanOut.trim()}
513
- `);
514
- actions.push(`Added new host key for ${host}`);
515
- return { status: "ok", message: `Host key refreshed for ${host}`, actions };
516
- } catch (e) {
517
- const msg = e instanceof Error ? e.message : String(e);
518
- return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
519
- }
520
- }
521
- return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
522
- }
523
- function checkGitSsh(host = "github.com", user = "git") {
524
- if (!isValidHostname(host)) {
525
- return { status: "error", message: `Invalid hostname: "${host}"` };
526
- }
527
- const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
528
- const text = stdout;
529
- if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
530
- const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
531
- return {
532
- status: "ok",
533
- message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
534
- authenticatedAs: userMatch?.[1]
535
- };
536
- }
537
- if (text.includes("Permission denied")) {
538
- return {
539
- status: "error",
540
- message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
541
- };
542
- }
543
- if (text.includes("Connection refused")) {
544
- return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
545
- }
546
- if (text.includes("timed out") || text.includes("Connection timed out")) {
547
- return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
548
- }
549
- if (text.includes("Could not resolve")) {
550
- return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
551
- }
552
- return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
553
- }
554
- function testConnection(host, port = 22) {
555
- if (!isValidHostname(host)) {
556
- return { status: "error", message: `Invalid hostname: "${host}"` };
557
- }
558
- const start = Date.now();
559
- const { ok, stdout } = runArgs("ssh", [
560
- "-o",
561
- "ConnectTimeout=5",
562
- "-o",
563
- "BatchMode=yes",
564
- "-o",
565
- "StrictHostKeyChecking=no",
566
- "-p",
567
- String(port),
568
- "--",
569
- host,
570
- "echo",
571
- "SSH_OK"
572
- ]);
573
- const elapsed = Date.now() - start;
574
- if (ok && stdout.includes("SSH_OK")) {
575
- return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
576
- }
577
- if (stdout.includes("Permission denied")) {
578
- return {
579
- status: "error",
580
- message: `Authentication failed to ${host}:${port} (${elapsed}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
581
- };
582
- }
583
- if (stdout.includes("Connection refused")) {
584
- return {
585
- status: "error",
586
- message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
587
- };
588
- }
589
- if (stdout.includes("timed out")) {
590
- return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
591
- }
592
- if (stdout.includes("Host key verification failed")) {
593
- return {
594
- status: "error",
595
- message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
596
- };
597
- }
598
- if (stdout.includes("Could not resolve")) {
599
- return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
600
- }
601
- return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
602
- }
603
-
604
- // src/ssh.ts
605
- import { readFileSync as readFileSync3 } from "fs";
606
- import { homedir as homedir3 } from "os";
607
- import { join as join3 } from "path";
608
- import { Client } from "ssh2";
609
- var sshConfigCache = /* @__PURE__ */ new Map();
610
- function resolveFromSshConfig(host) {
611
- const cached = sshConfigCache.get(host);
612
- if (cached !== void 0) return cached;
613
- const result = resolveFromSshConfigUncached(host);
614
- sshConfigCache.set(host, result);
615
- return result;
616
- }
617
- function resolveFromSshConfigUncached(host) {
618
- try {
619
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
620
- if (!ok) return null;
621
- const { all, identityFiles } = parseSshConfigOutput(stdout);
622
- return {
623
- hostname: all.hostname || host,
624
- user: all.user || "",
625
- port: all.port || "22",
626
- identityFiles,
627
- proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
628
- };
629
- } catch {
630
- return null;
631
- }
632
- }
633
- function readKnownHostsKeys(host, port) {
634
- if (!isValidHostname(host)) return [];
635
- const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
636
- const keys = [];
637
- for (const target of targets) {
638
- const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
639
- if (!ok || !stdout.trim()) continue;
640
- for (const line of stdout.split("\n")) {
641
- const trimmed = line.trim();
642
- if (!trimmed || trimmed.startsWith("#")) continue;
643
- const parts = trimmed.split(/\s+/);
644
- if (parts.length < 3) continue;
645
- try {
646
- keys.push(Buffer.from(parts[2], "base64"));
647
- } catch {
648
- }
649
- }
650
- }
651
- return keys;
652
- }
653
- function buildHostVerifier(hosts, port) {
654
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
655
- return (key) => {
656
- const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
657
- if (known.length === 0) {
658
- return !strict;
659
- }
660
- return known.some((k) => k.equals(key));
661
- };
662
- }
663
- function isEncryptedKey(content) {
664
- const text = content.toString("utf8");
665
- if (text.includes("ENCRYPTED")) return true;
666
- const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
667
- if (m) {
668
- try {
669
- const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
670
- const magic = "openssh-key-v1\0";
671
- if (raw.toString("latin1", 0, magic.length) === magic) {
672
- const cipherLen = raw.readUInt32BE(magic.length);
673
- const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
674
- return cipher !== "none";
675
- }
676
- } catch {
677
- return true;
678
- }
679
- }
680
- return false;
681
- }
682
- function resolveConfig(config) {
683
- const sshConfig = resolveFromSshConfig(config.host);
684
- const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
685
- const verifierHosts = [config.host];
686
- if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
687
- verifierHosts.push(sshConfig.hostname);
688
- }
689
- const connectConfig = {
690
- host: sshConfig?.hostname || config.host,
691
- port,
692
- username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
693
- keepaliveInterval: 15e3,
694
- keepaliveCountMax: 3,
695
- hostVerifier: buildHostVerifier(verifierHosts, port)
696
- };
697
- const home = homedir3();
698
- if (config.privateKeyPath) {
699
- const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
700
- connectConfig.privateKey = readFileSync3(keyPath);
701
- } else if (config.password) {
702
- connectConfig.password = config.password;
703
- } else {
704
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
705
- if (agentSock) {
706
- connectConfig.agent = agentSock;
707
- }
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 {
716
- }
717
- }
718
- }
719
- return { connectConfig, proxyJump: sshConfig?.proxyJump };
550
+ return { connectConfig, proxyJump: sshConfig?.proxyJump, hostKeyRejection, applyHostKeyAlgorithms };
720
551
  }
721
552
  var DIAG_CACHE_TTL_MS = 2e3;
722
553
  var diagAgentCache = null;
@@ -775,12 +606,82 @@ function connectRaw(connectConfig) {
775
606
  client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
776
607
  });
777
608
  }
609
+ function parsePort(text) {
610
+ if (!/^\d{1,5}$/.test(text)) return void 0;
611
+ const port = Number.parseInt(text, 10);
612
+ return port >= 1 && port <= 65535 ? port : void 0;
613
+ }
614
+ function parseJumpSpec(spec) {
615
+ const hops = [];
616
+ for (const piece of spec.split(",")) {
617
+ const hop = parseJumpHop(piece.trim());
618
+ if (hop) hops.push(hop);
619
+ }
620
+ return hops;
621
+ }
622
+ function parseJumpHop(piece) {
623
+ if (!piece) return null;
624
+ let rest = piece.startsWith("ssh://") ? piece.slice("ssh://".length) : piece;
625
+ if (!rest) return null;
626
+ let username;
627
+ const at = rest.lastIndexOf("@");
628
+ if (at !== -1) {
629
+ username = rest.slice(0, at) || void 0;
630
+ rest = rest.slice(at + 1);
631
+ }
632
+ let host = rest;
633
+ let port;
634
+ if (host.startsWith("[")) {
635
+ const close = host.indexOf("]");
636
+ if (close !== -1) {
637
+ const after = host.slice(close + 1);
638
+ host = host.slice(1, close);
639
+ if (after.startsWith(":")) port = parsePort(after.slice(1));
640
+ }
641
+ } else if (host.indexOf(":") !== -1 && host.indexOf(":") === host.lastIndexOf(":")) {
642
+ const colon = host.lastIndexOf(":");
643
+ const parsed = parsePort(host.slice(colon + 1));
644
+ if (parsed !== void 0) {
645
+ port = parsed;
646
+ host = host.slice(0, colon);
647
+ }
648
+ }
649
+ if (!host) return null;
650
+ const hop = { host };
651
+ if (port !== void 0) hop.port = port;
652
+ if (username !== void 0) hop.username = username;
653
+ return hop;
654
+ }
655
+ function formatJumpHop(hop) {
656
+ const host = hop.host.includes(":") ? `[${hop.host}]` : hop.host;
657
+ const withPort = hop.port ? `${host}:${hop.port}` : host;
658
+ return hop.username ? `${hop.username}@${withPort}` : withPort;
659
+ }
778
660
  async function connectWithProxy(resolved) {
779
- if (!resolved.proxyJump) {
661
+ resolved.applyHostKeyAlgorithms?.();
662
+ const hops = resolved.proxyJump ? parseJumpSpec(resolved.proxyJump) : [];
663
+ if (hops.length === 0) {
780
664
  return connectRaw(resolved.connectConfig);
781
665
  }
782
- const jumpResolved = resolveConfig({ host: resolved.proxyJump });
783
- const jumpClient = await connectWithProxy(jumpResolved);
666
+ const jumpHop = hops[hops.length - 1];
667
+ const jumpLabel = formatJumpHop(jumpHop);
668
+ const jumpResolved = resolveConfig({ host: jumpHop.host, port: jumpHop.port, username: jumpHop.username });
669
+ if (hops.length > 1) {
670
+ jumpResolved.proxyJump = hops.slice(0, -1).map(formatJumpHop).join(",");
671
+ }
672
+ let jumpClient;
673
+ try {
674
+ jumpClient = await connectWithProxy(jumpResolved);
675
+ } catch (err) {
676
+ const jumpRejection = jumpResolved.hostKeyRejection?.current;
677
+ if (jumpRejection && resolved.hostKeyRejection) {
678
+ resolved.hostKeyRejection.current = {
679
+ reason: jumpRejection.reason,
680
+ message: `on jump host ${jumpLabel} -- ${jumpRejection.message}`
681
+ };
682
+ }
683
+ throw err;
684
+ }
784
685
  const targetHost = resolved.connectConfig.host;
785
686
  const targetPort = resolved.connectConfig.port;
786
687
  const endJump = () => {
@@ -808,22 +709,26 @@ async function connectWithProxy(resolved) {
808
709
  }).connect({ ...resolved.connectConfig, sock: stream });
809
710
  });
810
711
  }
712
+ function enhanceSshError(err, host, resolved) {
713
+ const extra = [];
714
+ const rejection = resolved?.hostKeyRejection?.current;
715
+ if (rejection) extra.push(`Host key check failed -- ${rejection.message}`);
716
+ const diag = formatDiagnostics(host);
717
+ if (diag) extra.push(`SSH Diagnostics:
718
+ ${diag}`);
719
+ if (extra.length === 0) return err;
720
+ const message = err instanceof Error ? err.message : String(err);
721
+ const enhanced = new Error([message, ...extra].join("\n\n"));
722
+ enhanced.cause = err;
723
+ return enhanced;
724
+ }
811
725
  async function connect(config) {
812
- const resolved = resolveConfig(config);
726
+ let resolved;
813
727
  try {
728
+ resolved = resolveConfig(config);
814
729
  return await connectWithProxy(resolved);
815
730
  } catch (err) {
816
- const diag = formatDiagnostics(config.host);
817
- if (diag) {
818
- const message = err instanceof Error ? err.message : String(err);
819
- const enhanced = new Error(`${message}
820
-
821
- SSH Diagnostics:
822
- ${diag}`);
823
- enhanced.cause = err;
824
- throw enhanced;
825
- }
826
- throw err;
731
+ throw enhanceSshError(err, config.host, resolved);
827
732
  }
828
733
  }
829
734
  var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
@@ -831,23 +736,26 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
831
736
  return new Promise((resolve, reject) => {
832
737
  let settled = false;
833
738
  let activeStream = null;
739
+ let releaseCapture = null;
834
740
  const settle = (fn) => {
835
741
  if (settled) return;
836
742
  settled = true;
837
743
  clearTimeout(timer);
838
744
  fn();
839
745
  };
840
- const timer = setTimeout(() => {
841
- if (activeStream) {
842
- try {
843
- activeStream.signal("TERM");
844
- } catch {
845
- }
846
- try {
847
- activeStream.close();
848
- } catch {
849
- }
746
+ const teardownStream = (stream) => {
747
+ try {
748
+ stream.signal("TERM");
749
+ } catch {
750
+ }
751
+ try {
752
+ stream.close();
753
+ } catch {
850
754
  }
755
+ };
756
+ const timer = setTimeout(() => {
757
+ releaseCapture?.();
758
+ if (activeStream) teardownStream(activeStream);
851
759
  settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
852
760
  }, timeoutMs);
853
761
  client.exec(command, (err, stream) => {
@@ -909,6 +817,18 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
909
817
  stream.stderr.on("data", appendStderr).on("error", (err2) => {
910
818
  settle(() => reject(err2));
911
819
  });
820
+ releaseCapture = () => {
821
+ stream.removeListener("data", appendStdout);
822
+ stream.stderr.removeListener("data", appendStderr);
823
+ stdoutChunks.length = 0;
824
+ stderrChunks.length = 0;
825
+ stdoutBytes = 0;
826
+ stderrBytes = 0;
827
+ };
828
+ if (settled) {
829
+ releaseCapture();
830
+ teardownStream(stream);
831
+ }
912
832
  });
913
833
  });
914
834
  }
@@ -935,139 +855,465 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
935
855
  `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
936
856
  );
937
857
  }
938
- return await new Promise((resolve, reject) => {
939
- sftp.readFile(remotePath, (err, data) => {
940
- if (err) return reject(err);
941
- resolve(data.toString("utf8"));
942
- });
943
- });
944
- } finally {
945
- sftp.end();
858
+ return await new Promise((resolve, reject) => {
859
+ sftp.readFile(remotePath, (err, data) => {
860
+ if (err) return reject(err);
861
+ resolve(data.toString("utf8"));
862
+ });
863
+ });
864
+ } finally {
865
+ sftp.end();
866
+ }
867
+ }
868
+ async function writeFile(client, remotePath, content) {
869
+ const sftp = await getSftp(client);
870
+ try {
871
+ await new Promise((resolve, reject) => {
872
+ sftp.writeFile(remotePath, content, (err) => {
873
+ if (err) return reject(err);
874
+ resolve();
875
+ });
876
+ });
877
+ } finally {
878
+ sftp.end();
879
+ }
880
+ }
881
+ async function uploadFile(client, localPath, remotePath) {
882
+ const resolvedLocal = localPath.startsWith("~") ? join2(homedir2(), localPath.slice(1)) : localPath;
883
+ const sftp = await getSftp(client);
884
+ try {
885
+ await new Promise((resolve, reject) => {
886
+ sftp.fastPut(resolvedLocal, remotePath, (err) => {
887
+ if (err) return reject(err);
888
+ resolve();
889
+ });
890
+ });
891
+ } finally {
892
+ sftp.end();
893
+ }
894
+ }
895
+ async function downloadFile(client, remotePath, localPath) {
896
+ const resolvedLocal = localPath.startsWith("~") ? join2(homedir2(), localPath.slice(1)) : localPath;
897
+ const sftp = await getSftp(client);
898
+ try {
899
+ await new Promise((resolve, reject) => {
900
+ sftp.fastGet(remotePath, resolvedLocal, (err) => {
901
+ if (err) return reject(err);
902
+ resolve();
903
+ });
904
+ });
905
+ } finally {
906
+ sftp.end();
907
+ }
908
+ }
909
+ async function listDir(client, remotePath) {
910
+ const sftp = await getSftp(client);
911
+ try {
912
+ return await new Promise((resolve, reject) => {
913
+ sftp.readdir(remotePath, (err, list) => {
914
+ if (err) return reject(err);
915
+ resolve(list.map((item) => item.filename));
916
+ });
917
+ });
918
+ } finally {
919
+ sftp.end();
920
+ }
921
+ }
922
+ async function statFile(client, remotePath) {
923
+ const sftp = await getSftp(client);
924
+ const call = (fn) => new Promise((resolve, reject) => {
925
+ sftp[fn](remotePath, (err, stats) => err ? reject(err) : resolve(stats));
926
+ });
927
+ try {
928
+ const link = await call("lstat");
929
+ const isSymbolicLink = link.isSymbolicLink();
930
+ let meta = link;
931
+ if (isSymbolicLink) {
932
+ try {
933
+ meta = await call("stat");
934
+ } catch {
935
+ }
936
+ }
937
+ return {
938
+ size: meta.size,
939
+ mode: meta.mode,
940
+ modeOctal: (meta.mode & 4095).toString(8).padStart(4, "0"),
941
+ uid: meta.uid,
942
+ gid: meta.gid,
943
+ mtime: meta.mtime,
944
+ atime: meta.atime,
945
+ // isFile / isDirectory describe the TARGET (they pair with the metadata above);
946
+ // isSymbolicLink describes the PATH. A symlink to a directory is therefore both
947
+ // a directory and a symlink, and the caller decides which matters.
948
+ isFile: meta.isFile(),
949
+ isDirectory: meta.isDirectory(),
950
+ isSymbolicLink
951
+ };
952
+ } finally {
953
+ sftp.end();
954
+ }
955
+ }
956
+ async function deleteFile(client, remotePath) {
957
+ const sftp = await getSftp(client);
958
+ try {
959
+ const stats = await new Promise((resolve, reject) => {
960
+ sftp.lstat(remotePath, (err, stats2) => {
961
+ if (err) return reject(err);
962
+ resolve(stats2);
963
+ });
964
+ });
965
+ await new Promise((resolve, reject) => {
966
+ const done = (err) => err ? reject(err) : resolve();
967
+ if (stats.isDirectory()) {
968
+ sftp.rmdir(remotePath, done);
969
+ } else {
970
+ sftp.unlink(remotePath, done);
971
+ }
972
+ });
973
+ } finally {
974
+ sftp.end();
975
+ }
976
+ }
977
+ async function makeDir(client, remotePath, recursive = false) {
978
+ const sftp = await getSftp(client);
979
+ try {
980
+ const mkOne = (path) => new Promise((resolve, reject) => {
981
+ sftp.mkdir(path, (err) => err ? reject(err) : resolve());
982
+ });
983
+ if (!recursive) {
984
+ await mkOne(remotePath);
985
+ return;
986
+ }
987
+ const parts = remotePath.split("/").filter(Boolean);
988
+ let cur = remotePath.startsWith("/") ? "" : ".";
989
+ for (let i = 0; i < parts.length; i++) {
990
+ cur = `${cur}/${parts[i]}`;
991
+ const isLeaf = i === parts.length - 1;
992
+ try {
993
+ await mkOne(cur);
994
+ } catch (e) {
995
+ if (isLeaf) throw e;
996
+ }
997
+ }
998
+ } finally {
999
+ sftp.end();
1000
+ }
1001
+ }
1002
+
1003
+ // src/env.ts
1004
+ function runArgsWithEnv(cmd, args, extraEnv) {
1005
+ const env = {};
1006
+ for (const [k, v] of Object.entries(process.env)) {
1007
+ if (typeof v === "string") env[k] = v;
1008
+ }
1009
+ for (const [k, v] of Object.entries(extraEnv)) {
1010
+ if (v === void 0) {
1011
+ delete env[k];
1012
+ } else {
1013
+ env[k] = v;
1014
+ }
1015
+ }
1016
+ try {
1017
+ const stdout = execFileSync2(cmd, args, {
1018
+ env,
1019
+ encoding: "utf8",
1020
+ timeout: 1e4,
1021
+ stdio: ["pipe", "pipe", "pipe"]
1022
+ });
1023
+ return { stdout: stdout.trim(), ok: true };
1024
+ } catch (e) {
1025
+ const err = e;
1026
+ const so = err.stdout?.toString().trim() || "";
1027
+ const se = err.stderr?.toString().trim() || "";
1028
+ const output = [so, se].filter(Boolean).join("\n") || err.message || "";
1029
+ return { stdout: output, ok: false };
1030
+ }
1031
+ }
1032
+ function probeAgent(socket, agentLabel) {
1033
+ const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
1034
+ const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
1035
+ const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
1036
+ const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
1037
+ if (!ok && !noIdentities) return null;
1038
+ const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
1039
+ return {
1040
+ running: true,
1041
+ reachable: true,
1042
+ socket,
1043
+ keys,
1044
+ started: false,
1045
+ 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.`
1046
+ };
1047
+ }
1048
+ var startedAgentPid = null;
1049
+ function ensureAgent() {
1050
+ const sock = process.env.SSH_AUTH_SOCK;
1051
+ if (sock) {
1052
+ const result = probeAgent(sock, "ssh-agent");
1053
+ if (result) return result;
1054
+ }
1055
+ if (process.platform === "win32") {
1056
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
1057
+ if (result) return result;
1058
+ return {
1059
+ running: false,
1060
+ reachable: false,
1061
+ keys: [],
1062
+ started: false,
1063
+ message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
1064
+ };
1065
+ }
1066
+ const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
1067
+ if (ok) {
1068
+ const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
1069
+ const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
1070
+ if (sockMatch) {
1071
+ process.env.SSH_AUTH_SOCK = sockMatch[1];
1072
+ if (pidMatch) {
1073
+ process.env.SSH_AGENT_PID = pidMatch[1];
1074
+ startedAgentPid = Number.parseInt(pidMatch[1], 10);
1075
+ }
1076
+ return {
1077
+ running: true,
1078
+ reachable: true,
1079
+ socket: sockMatch[1],
1080
+ keys: [],
1081
+ started: true,
1082
+ env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
1083
+ 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."
1084
+ };
1085
+ }
1086
+ }
1087
+ return {
1088
+ running: false,
1089
+ reachable: false,
1090
+ keys: [],
1091
+ started: false,
1092
+ message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
1093
+ };
1094
+ }
1095
+ function detectKeyType(filePath, fileName) {
1096
+ const pubPath = `${filePath}.pub`;
1097
+ if (existsSync2(pubPath)) {
1098
+ try {
1099
+ const pub = readFileSync3(pubPath, "utf8");
1100
+ if (pub.includes("ssh-ed25519")) return "ed25519";
1101
+ if (pub.includes("ssh-rsa")) return "rsa";
1102
+ if (pub.includes("ecdsa")) return "ecdsa";
1103
+ if (pub.includes("ssh-dss")) return "dsa";
1104
+ } catch {
1105
+ }
1106
+ }
1107
+ if (fileName.includes("ed25519")) return "ed25519";
1108
+ if (fileName.includes("rsa")) return "rsa";
1109
+ if (fileName.includes("ecdsa")) return "ecdsa";
1110
+ if (fileName.includes("dsa")) return "dsa";
1111
+ try {
1112
+ const content = readFileSync3(filePath, "utf8");
1113
+ if (content.includes("RSA PRIVATE KEY")) return "rsa";
1114
+ if (content.includes("EC PRIVATE KEY")) return "ecdsa";
1115
+ if (content.includes("DSA PRIVATE KEY")) return "dsa";
1116
+ if (content.includes("OPENSSH PRIVATE KEY")) {
1117
+ const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
1118
+ if (ok) {
1119
+ const match = stdout.match(/\(([^)]+)\)\s*$/);
1120
+ if (match) return match[1].toLowerCase();
1121
+ }
1122
+ }
1123
+ } catch {
1124
+ }
1125
+ return "unknown";
1126
+ }
1127
+ function listSshKeys() {
1128
+ return listSshKeysDetailed().keys;
1129
+ }
1130
+ function listSshKeysDetailed() {
1131
+ const sshDir = join3(homedir3(), ".ssh");
1132
+ if (!existsSync2(sshDir)) return { status: "no-dir", dir: sshDir, keys: [] };
1133
+ const loadedFingerprints = /* @__PURE__ */ new Set();
1134
+ const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
1135
+ if (agentOk && !agentOut.includes("no identities")) {
1136
+ for (const line of agentOut.split("\n").filter(Boolean)) {
1137
+ const match = line.match(/(\S+:\S+)/);
1138
+ if (match) loadedFingerprints.add(match[1]);
1139
+ }
1140
+ }
1141
+ const keys = [];
1142
+ let files;
1143
+ try {
1144
+ files = readdirSync2(sshDir);
1145
+ } catch (e) {
1146
+ return { status: "unreadable", dir: sshDir, keys: [], reason: e instanceof Error ? e.message : String(e) };
1147
+ }
1148
+ for (const file of files) {
1149
+ if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
1150
+ const filePath = join3(sshDir, file);
1151
+ try {
1152
+ const stat = statSync(filePath);
1153
+ if (!stat.isFile()) continue;
1154
+ const content = readFileSync3(filePath, "utf8");
1155
+ if (!content.includes("PRIVATE KEY")) continue;
1156
+ const type = detectKeyType(filePath, file);
1157
+ let fingerprint;
1158
+ const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
1159
+ if (fpOk) {
1160
+ const match = fpOut.match(/(\S+:\S+)/);
1161
+ fingerprint = match?.[1];
1162
+ }
1163
+ const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
1164
+ keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1165
+ } catch {
1166
+ }
946
1167
  }
1168
+ return { status: "ok", dir: sshDir, keys };
947
1169
  }
948
- async function writeFile(client, remotePath, content) {
949
- const sftp = await getSftp(client);
950
- try {
951
- await new Promise((resolve, reject) => {
952
- sftp.writeFile(remotePath, content, (err) => {
953
- if (err) return reject(err);
954
- resolve();
955
- });
956
- });
957
- } finally {
958
- sftp.end();
1170
+ function loadKey(keyPath) {
1171
+ const agent = ensureAgent();
1172
+ if (!agent.reachable) {
1173
+ return { status: "error", message: agent.message };
1174
+ }
1175
+ const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
1176
+ if (!existsSync2(resolved)) {
1177
+ return { status: "error", message: `Key not found: ${resolved}` };
959
1178
  }
1179
+ const { stdout, ok } = runArgs("ssh-add", [resolved]);
1180
+ if (ok) {
1181
+ return { status: "ok", message: `Key loaded: ${resolved}` };
1182
+ }
1183
+ if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
1184
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
1185
+ }
1186
+ if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
1187
+ return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
1188
+ }
1189
+ return { status: "error", message: `Failed to load key: ${stdout}` };
960
1190
  }
961
- async function uploadFile(client, localPath, remotePath) {
962
- const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
963
- const sftp = await getSftp(client);
964
- try {
965
- await new Promise((resolve, reject) => {
966
- sftp.fastPut(resolvedLocal, remotePath, (err) => {
967
- if (err) return reject(err);
968
- resolve();
969
- });
970
- });
971
- } finally {
972
- sftp.end();
1191
+ function configLookup(host) {
1192
+ if (!isValidHostname(host)) {
1193
+ return { error: `Invalid hostname: "${host}"` };
1194
+ }
1195
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
1196
+ if (!ok) {
1197
+ return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
973
1198
  }
1199
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
1200
+ return {
1201
+ hostname: all.hostname || host,
1202
+ user: all.user || "",
1203
+ port: all.port || "22",
1204
+ identityFile: identityFiles,
1205
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
1206
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
1207
+ all,
1208
+ raw: stdout
1209
+ };
974
1210
  }
975
- async function downloadFile(client, remotePath, localPath) {
976
- const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
977
- const sftp = await getSftp(client);
978
- try {
979
- await new Promise((resolve, reject) => {
980
- sftp.fastGet(remotePath, resolvedLocal, (err) => {
981
- if (err) return reject(err);
982
- resolve();
983
- });
984
- });
985
- } finally {
986
- sftp.end();
1211
+ var KEYGEN_REMOVED = [/(?:^|\s)found: line \d+/m, / updated\.$/m];
1212
+ var KEYGEN_NO_FILE = /Cannot stat .*No such file or directory/s;
1213
+ function removeKnownHostEntry(target) {
1214
+ const { stdout, ok } = runArgs("ssh-keygen", ["-R", target]);
1215
+ if (!ok) {
1216
+ return KEYGEN_NO_FILE.test(stdout) ? { result: "absent", output: stdout } : { result: "failed", output: stdout };
987
1217
  }
1218
+ if (KEYGEN_REMOVED.some((re) => re.test(stdout))) return { result: "removed", output: stdout };
1219
+ return { result: "absent", output: stdout };
988
1220
  }
989
- async function listDir(client, remotePath) {
990
- const sftp = await getSftp(client);
991
- try {
992
- return await new Promise((resolve, reject) => {
993
- sftp.readdir(remotePath, (err, list) => {
994
- if (err) return reject(err);
995
- resolve(list.map((item) => item.filename));
996
- });
997
- });
998
- } finally {
999
- sftp.end();
1221
+ function describeRemoval(target, removal) {
1222
+ switch (removal.result) {
1223
+ case "removed":
1224
+ return `Removed old host key for ${target}`;
1225
+ case "absent":
1226
+ return `No existing host key for ${target} (nothing to remove)`;
1227
+ default:
1228
+ return `Could not remove existing host key for ${target}: ${removal.output || "ssh-keygen failed"}`;
1000
1229
  }
1001
1230
  }
1002
- async function statFile(client, remotePath) {
1003
- const sftp = await getSftp(client);
1004
- try {
1005
- return await new Promise((resolve, reject) => {
1006
- sftp.stat(remotePath, (err, stats) => {
1007
- if (err) return reject(err);
1008
- resolve({
1009
- size: stats.size,
1010
- mode: stats.mode,
1011
- modeOctal: (stats.mode & 4095).toString(8).padStart(4, "0"),
1012
- uid: stats.uid,
1013
- gid: stats.gid,
1014
- mtime: stats.mtime,
1015
- atime: stats.atime,
1016
- isFile: stats.isFile(),
1017
- isDirectory: stats.isDirectory(),
1018
- isSymbolicLink: stats.isSymbolicLink()
1019
- });
1020
- });
1021
- });
1022
- } finally {
1023
- sftp.end();
1231
+ function fixKnownHosts(host, port = 22) {
1232
+ if (!isValidHostname(host)) {
1233
+ return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
1234
+ }
1235
+ const actions = [];
1236
+ for (const target of knownHostsTargets(host, port)) {
1237
+ actions.push(describeRemoval(target, removeKnownHostEntry(target)));
1238
+ }
1239
+ const scanHost = unbracketHost(host);
1240
+ const scanArgs = port !== 22 ? ["-H", "-p", String(port), scanHost] : ["-H", scanHost];
1241
+ const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
1242
+ if (scanOk && scanOut.trim()) {
1243
+ try {
1244
+ const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
1245
+ appendFileSync(knownHostsPath, `
1246
+ ${scanOut.trim()}
1247
+ `);
1248
+ actions.push(`Added new host key for ${host}`);
1249
+ return { status: "ok", message: `Host key refreshed for ${host}`, actions };
1250
+ } catch (e) {
1251
+ const msg = e instanceof Error ? e.message : String(e);
1252
+ return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
1253
+ }
1024
1254
  }
1255
+ return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
1025
1256
  }
1026
- async function deleteFile(client, remotePath) {
1027
- const sftp = await getSftp(client);
1028
- try {
1029
- const stats = await new Promise((resolve, reject) => {
1030
- sftp.stat(remotePath, (err, stats2) => {
1031
- if (err) return reject(err);
1032
- resolve(stats2);
1033
- });
1034
- });
1035
- await new Promise((resolve, reject) => {
1036
- const done = (err) => err ? reject(err) : resolve();
1037
- if (stats.isDirectory()) {
1038
- sftp.rmdir(remotePath, done);
1039
- } else {
1040
- sftp.unlink(remotePath, done);
1041
- }
1042
- });
1043
- } finally {
1044
- sftp.end();
1257
+ function checkGitSsh(host = "github.com", user = "git") {
1258
+ if (!isValidHostname(host)) {
1259
+ return { status: "error", message: `Invalid hostname: "${host}"` };
1260
+ }
1261
+ const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
1262
+ const text = stdout;
1263
+ if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
1264
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
1265
+ return {
1266
+ status: "ok",
1267
+ message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
1268
+ authenticatedAs: userMatch?.[1]
1269
+ };
1270
+ }
1271
+ if (text.includes("Permission denied")) {
1272
+ return {
1273
+ status: "error",
1274
+ message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
1275
+ };
1276
+ }
1277
+ if (text.includes("Connection refused")) {
1278
+ return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
1279
+ }
1280
+ if (text.includes("timed out") || text.includes("Connection timed out")) {
1281
+ return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
1282
+ }
1283
+ if (text.includes("Could not resolve")) {
1284
+ return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
1045
1285
  }
1286
+ return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
1046
1287
  }
1047
- async function makeDir(client, remotePath, recursive = false) {
1048
- const sftp = await getSftp(client);
1049
- try {
1050
- const mkOne = (path) => new Promise((resolve, reject) => {
1051
- sftp.mkdir(path, (err) => err ? reject(err) : resolve());
1052
- });
1053
- if (!recursive) {
1054
- await mkOne(remotePath);
1055
- return;
1056
- }
1057
- const isAbsolute = remotePath.startsWith("/");
1058
- const parts = remotePath.split("/").filter(Boolean);
1059
- let cur = isAbsolute ? "" : ".";
1060
- for (let i = 0; i < parts.length; i++) {
1061
- cur = isAbsolute ? `${cur}/${parts[i]}` : `${cur}/${parts[i]}`;
1062
- const isLeaf = i === parts.length - 1;
1063
- try {
1064
- await mkOne(cur);
1065
- } catch (e) {
1066
- if (isLeaf) throw e;
1067
- }
1068
- }
1069
- } finally {
1070
- sftp.end();
1288
+ function testConnection(host, port = 22) {
1289
+ if (!isValidHostname(host)) {
1290
+ return { status: "error", message: `Invalid hostname: "${host}"` };
1291
+ }
1292
+ const { outcome, output, elapsedMs } = probeSshConnection(host, port);
1293
+ switch (outcome) {
1294
+ case "ok":
1295
+ return { status: "ok", message: `Connected to ${host}:${port} in ${elapsedMs}ms` };
1296
+ case "permission-denied":
1297
+ return {
1298
+ status: "error",
1299
+ message: `Authentication failed to ${host}:${port} (${elapsedMs}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
1300
+ };
1301
+ case "connection-refused":
1302
+ return {
1303
+ status: "error",
1304
+ message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
1305
+ };
1306
+ case "timed-out":
1307
+ return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
1308
+ case "host-key-mismatch":
1309
+ return {
1310
+ status: "error",
1311
+ message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
1312
+ };
1313
+ case "dns-failure":
1314
+ return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
1315
+ default:
1316
+ return { status: "error", message: `Connection failed to ${host}:${port}: ${output}` };
1071
1317
  }
1072
1318
  }
1073
1319
 
@@ -1098,6 +1344,7 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1098
1344
  });
1099
1345
  }
1100
1346
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1347
+ var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1101
1348
  async function find(client, options, timeoutMs = 3e4) {
1102
1349
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1103
1350
  throw new Error(
@@ -1109,7 +1356,8 @@ async function find(client, options, timeoutMs = 3e4) {
1109
1356
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1110
1357
  );
1111
1358
  }
1112
- const args = ["--", shellQuote(options.path)];
1359
+ const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1360
+ const args = [shellQuote(pathOperand)];
1113
1361
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1114
1362
  if (options.type) args.push("-type", options.type);
1115
1363
  if (options.name) args.push("-name", shellQuote(options.name));
@@ -1156,28 +1404,38 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1156
1404
  }
1157
1405
 
1158
1406
  // src/policy.ts
1159
- function parsePatterns(raw) {
1407
+ function parsePatterns(raw, envVarName) {
1160
1408
  if (!raw) return [];
1161
1409
  const patterns = [];
1410
+ const malformed = [];
1162
1411
  for (const p of raw.split(",")) {
1163
1412
  const cleaned = p.replace(/^\s+/, "");
1164
1413
  if (!cleaned) continue;
1165
1414
  try {
1166
1415
  patterns.push(new RegExp(cleaned));
1167
1416
  } catch {
1417
+ malformed.push(cleaned);
1168
1418
  console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1169
1419
  }
1170
1420
  }
1421
+ if (patterns.length === 0 && raw.trim() !== "") {
1422
+ const detail = malformed.length > 0 ? `Malformed pattern(s): ${malformed.map((m) => `"${m}"`).join(", ")}.` : `The value ${JSON.stringify(raw)} contains only separators, so it declares no patterns.`;
1423
+ throw new Error(
1424
+ `Command blocked -- ssh-mcp command policy is MISCONFIGURED: ${envVarName} is set, but not one usable regex could be compiled from it, so ${envVarName} is NOT IN EFFECT. Every ssh_exec / ssh_multi_exec call is refused until it is fixed (failing closed: a policy that cannot be compiled must not be read as "no policy"). ${detail} Each comma-separated entry must be a valid JavaScript regex -- note that comma is the delimiter, so a pattern needing a literal comma must write it as \\x2c or [,]. Fix or unset the variable to restore service.`
1425
+ );
1426
+ }
1171
1427
  return patterns;
1172
1428
  }
1173
- function enforcePolicy(command) {
1174
- const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
1429
+ function enforcePolicy(command, context = {}) {
1430
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST, "SSH_MCP_COMMAND_WHITELIST");
1431
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST, "SSH_MCP_COMMAND_BLACKLIST");
1175
1432
  if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1176
- throw new Error(
1177
- `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
1178
- );
1433
+ let message = `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`;
1434
+ if (context.envPrefixApplied) {
1435
+ message += ". NOTE: an `env` prefix was applied, and policy is checked against the PREFIXED command -- the string starts with the first `KEY='value'` assignment, not the command verb, so a `^`-anchored pattern that matches without `env` stops matching with it. Either set the variables inside the command string instead of passing `env`, or add a pattern that tolerates the prefix (e.g. `^([A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|\\\\')+ )*ls( |$)`). Keep the trailing `( |$)`: without a tail anchor the suggestion is a PREFIX match, so `^(...)*ls` would also admit `lsof -i` and `ls; <anything>`. The value group is `(?:'[^']*'|\\\\')+`, not `'[^']*'`: an env VALUE containing an apostrophe is emitted by shellQuote in the close-escape-reopen form (`O'Brien` -> `'O'\\''Brien'`), which a single `'[^']*'` cannot match -- a suggestion built on it would block exactly the calls it claims to allow.";
1436
+ }
1437
+ throw new Error(message);
1179
1438
  }
1180
- const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
1181
1439
  for (const pattern of blacklist) {
1182
1440
  if (pattern.test(command)) {
1183
1441
  throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
@@ -1212,10 +1470,26 @@ function authFingerprint(cc) {
1212
1470
  }
1213
1471
  return h.digest("hex").slice(0, 16);
1214
1472
  }
1473
+ function resolveOrDiagnose(config) {
1474
+ try {
1475
+ return resolveConfig(config);
1476
+ } catch (err) {
1477
+ throw enhanceSshError(err, config.host);
1478
+ }
1479
+ }
1215
1480
  var ConnectionPool = class {
1216
1481
  entries = /* @__PURE__ */ new Map();
1217
1482
  // Coalesces concurrent connect attempts for the same key so we don't open N
1218
1483
  // duplicate TCP connections when N tool calls fire simultaneously.
1484
+ //
1485
+ // The ResolvedConfig is stored alongside the promise because the coalesced dial
1486
+ // runs with the FIRST caller's resolved: only that one's `hostVerifier` is ever
1487
+ // invoked, so only that one's `hostKeyRejection` side channel records why the
1488
+ // server's key was turned down. Waiters must report the failure from THAT resolved
1489
+ // rather than their own (whose verifier never ran and whose rejection is still
1490
+ // null), or one caller gets "the server offered an ed25519 key but known_hosts
1491
+ // has only ecdsa" while the other N-1 get generic environment diagnostics for the
1492
+ // very same failure.
1219
1493
  pending = /* @__PURE__ */ new Map();
1220
1494
  idleTtlMs;
1221
1495
  maxPoolSize;
@@ -1231,7 +1505,7 @@ var ConnectionPool = class {
1231
1505
  this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
1232
1506
  }
1233
1507
  async acquire(config) {
1234
- const resolved = resolveConfig(config);
1508
+ const resolved = resolveOrDiagnose(config);
1235
1509
  const cc = resolved.connectConfig;
1236
1510
  const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
1237
1511
  const MAX_ACQUIRE_ATTEMPTS = 3;
@@ -1252,8 +1526,8 @@ var ConnectionPool = class {
1252
1526
  if (existing?.dead) {
1253
1527
  this.entries.delete(key);
1254
1528
  }
1255
- let pending = this.pending.get(key);
1256
- if (!pending) {
1529
+ let inflight = this.pending.get(key);
1530
+ if (!inflight) {
1257
1531
  if (this.entries.size >= this.maxPoolSize) {
1258
1532
  let evicted = false;
1259
1533
  for (const [k, e] of this.entries) {
@@ -1272,7 +1546,7 @@ var ConnectionPool = class {
1272
1546
  throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1273
1547
  }
1274
1548
  }
1275
- pending = (async () => {
1549
+ const promise = (async () => {
1276
1550
  try {
1277
1551
  const client2 = await connectWithProxy(resolved);
1278
1552
  if (this.drained) {
@@ -1303,23 +1577,14 @@ var ConnectionPool = class {
1303
1577
  this.pending.delete(key);
1304
1578
  }
1305
1579
  })();
1306
- this.pending.set(key, pending);
1580
+ inflight = { promise, resolved };
1581
+ this.pending.set(key, inflight);
1307
1582
  }
1308
1583
  let client;
1309
1584
  try {
1310
- client = await pending;
1585
+ client = await inflight.promise;
1311
1586
  } catch (err) {
1312
- const diag = formatDiagnostics(config.host);
1313
- if (diag) {
1314
- const message = err instanceof Error ? err.message : String(err);
1315
- const enhanced = new Error(`${message}
1316
-
1317
- SSH Diagnostics:
1318
- ${diag}`);
1319
- enhanced.cause = err;
1320
- throw enhanced;
1321
- }
1322
- throw err;
1587
+ throw enhanceSshError(err, config.host, inflight.resolved);
1323
1588
  }
1324
1589
  const entry = this.entries.get(key);
1325
1590
  if (!entry || entry.dead || entry.client !== client) {
@@ -1411,6 +1676,9 @@ var PasswordSchema = z.string().optional().describe(
1411
1676
  "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."
1412
1677
  );
1413
1678
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1679
+ var EnvSchema = z.record(z.string(), z.string()).optional().describe(
1680
+ "Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. VALUES are POSIX-single-quoted, so any byte is safe in a value. KEYS cannot be quoted (a shell assignment prefix requires a bare name), so each key must match /^[A-Za-z_][A-Za-z0-9_]*$/ (the POSIX name grammar) \u2014 a key outside that grammar is rejected and the call fails before anything is sent to a host. Command policy is checked against the PREFIXED command, so a `^`-anchored whitelist pattern stops matching once this is set."
1681
+ );
1414
1682
  var connectionParams = {
1415
1683
  host: HostSchema,
1416
1684
  port: PortSchema,
@@ -1418,6 +1686,22 @@ var connectionParams = {
1418
1686
  privateKeyPath: KeyPathSchema,
1419
1687
  password: PasswordSchema
1420
1688
  };
1689
+ var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
1690
+ var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1691
+ function applyEnvPrefix(command, env) {
1692
+ if (!env || Object.keys(env).length === 0) {
1693
+ return { finalCommand: command, envPrefixApplied: false };
1694
+ }
1695
+ const prefix = Object.entries(env).map(([k, v]) => {
1696
+ if (!ENV_NAME_PATTERN.test(k)) {
1697
+ throw new Error(
1698
+ `Invalid environment variable name ${JSON.stringify(k)}: env keys must match ${ENV_NAME_PATTERN.source} (the POSIX name grammar). Values are single-quoted before they reach the remote shell, but a key is emitted as a bare \`KEY=\` assignment and cannot be quoted, so anything outside that grammar is rejected instead of escaped.`
1699
+ );
1700
+ }
1701
+ return `${k}=${shellQuote(v)}`;
1702
+ }).join(" ");
1703
+ return { finalCommand: `${prefix} ${command}`, envPrefixApplied: true };
1704
+ }
1421
1705
  function registerTools(server, pool) {
1422
1706
  const connectionPool = pool ?? new ConnectionPool();
1423
1707
  server.tool(
@@ -1426,18 +1710,12 @@ function registerTools(server, pool) {
1426
1710
  {
1427
1711
  ...connectionParams,
1428
1712
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1429
- env: z.record(z.string(), z.string()).optional().describe(
1430
- "Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. Values are POSIX-single-quoted, so any byte is safe."
1431
- ),
1713
+ env: EnvSchema,
1432
1714
  timeout: TimeoutSchema
1433
1715
  },
1434
1716
  async ({ command, env, timeout, ...conn }) => {
1435
- let finalCommand = command;
1436
- if (env && Object.keys(env).length > 0) {
1437
- const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
1438
- finalCommand = `${prefix} ${command}`;
1439
- }
1440
- enforcePolicy(finalCommand);
1717
+ const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
1718
+ enforcePolicy(finalCommand, { envPrefixApplied });
1441
1719
  return connectionPool.withConnection(conn, async (client) => {
1442
1720
  const result = await exec(client, finalCommand, timeout || 3e4);
1443
1721
  const parts = [];
@@ -1468,7 +1746,7 @@ ${result.stderr}`);
1468
1746
  );
1469
1747
  server.tool(
1470
1748
  "ssh_write_file",
1471
- "Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
1749
+ `Write content to a file on a remote host via SFTP. Creates or overwrites the file.${POLICY_EXEMPT_NOTE}`,
1472
1750
  {
1473
1751
  ...connectionParams,
1474
1752
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
@@ -1477,13 +1755,14 @@ ${result.stderr}`);
1477
1755
  async ({ path, content, ...conn }) => {
1478
1756
  return connectionPool.withConnection(conn, async (client) => {
1479
1757
  await writeFile(client, path, content);
1480
- return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1758
+ const bytes = Buffer.byteLength(content, "utf8");
1759
+ return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
1481
1760
  });
1482
1761
  }
1483
1762
  );
1484
1763
  server.tool(
1485
1764
  "ssh_upload",
1486
- "Upload a local file to a remote host via SFTP.",
1765
+ `Upload a local file to a remote host via SFTP.${POLICY_EXEMPT_NOTE}`,
1487
1766
  {
1488
1767
  ...connectionParams,
1489
1768
  localPath: z.string().describe("Path to the local file to upload"),
@@ -1521,13 +1800,16 @@ ${result.stderr}`);
1521
1800
  async ({ path, ...conn }) => {
1522
1801
  return connectionPool.withConnection(conn, async (client) => {
1523
1802
  const files = await listDir(client, path);
1803
+ if (files.length === 0) {
1804
+ return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
1805
+ }
1524
1806
  return { content: [{ type: "text", text: files.join("\n") }] };
1525
1807
  });
1526
1808
  }
1527
1809
  );
1528
1810
  server.tool(
1529
1811
  "ssh_stat",
1530
- "Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and type flags (isFile, isDirectory, isSymbolicLink). Use this instead of parsing `ls -la` output.",
1812
+ "Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and the path type. Symlinks are reported as `symlink -> <target kind>`: the type describes the link itself while size/mode/mtime describe its TARGET, and a dangling symlink is reported rather than erroring. Use this instead of parsing `ls -la` output.",
1531
1813
  {
1532
1814
  ...connectionParams,
1533
1815
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
@@ -1536,7 +1818,8 @@ ${result.stderr}`);
1536
1818
  return connectionPool.withConnection(conn, async (client) => {
1537
1819
  const stats = await statFile(client, path);
1538
1820
  const lines = [];
1539
- const kind = stats.isDirectory ? "directory" : stats.isSymbolicLink ? "symlink" : stats.isFile ? "file" : "other";
1821
+ const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
1822
+ const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
1540
1823
  lines.push(`${path}: ${kind}`);
1541
1824
  lines.push(` Size: ${stats.size} bytes`);
1542
1825
  lines.push(` Mode: ${stats.modeOctal}`);
@@ -1549,10 +1832,12 @@ ${result.stderr}`);
1549
1832
  );
1550
1833
  server.tool(
1551
1834
  "ssh_mkdir",
1552
- "Create a directory on a remote host via SFTP. Set `recursive: true` to create parent directories as needed (like `mkdir -p`). Existing intermediate dirs are tolerated; an existing leaf path is still an error.",
1835
+ `Create a directory on a remote host via SFTP. Set \`recursive: true\` to create parent directories as needed (like \`mkdir -p\`). Existing intermediate dirs are tolerated; an existing leaf path is still an error. Unlike the other SFTP tools, the path may be relative.${POLICY_EXEMPT_NOTE}`,
1553
1836
  {
1554
1837
  ...connectionParams,
1555
- path: z.string().describe("Absolute path of the directory to create"),
1838
+ path: z.string().describe(
1839
+ "Path of the directory to create. Absolute (starting with /) is recommended and unambiguous. A relative path is also accepted and resolves against the SFTP working directory, which is normally the remote user's home. ~ is NOT expanded \u2014 SFTP has no shell to expand it."
1840
+ ),
1556
1841
  recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
1557
1842
  },
1558
1843
  async ({ path, recursive, ...conn }) => {
@@ -1564,7 +1849,7 @@ ${result.stderr}`);
1564
1849
  );
1565
1850
  server.tool(
1566
1851
  "ssh_delete",
1567
- "Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with `rm -rf` explicitly so the destructive intent is visible in the tool trace.",
1852
+ `Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with \`rm -rf\` explicitly so the destructive intent is visible in the tool trace.${POLICY_EXEMPT_NOTE}`,
1568
1853
  {
1569
1854
  ...connectionParams,
1570
1855
  path: AbsoluteRemotePathSchema.describe(
@@ -1629,10 +1914,33 @@ ${result.stderr}`);
1629
1914
  );
1630
1915
  server.tool(
1631
1916
  "ssh_key_list",
1632
- "List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded.",
1917
+ "List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
1633
1918
  {},
1634
1919
  async () => {
1635
- const keys = listSshKeys();
1920
+ const listing = listSshKeysDetailed();
1921
+ if (listing.status === "unreadable") {
1922
+ return {
1923
+ content: [
1924
+ {
1925
+ type: "text",
1926
+ text: `Could not read ${listing.dir}: ${listing.reason}. This is NOT "no keys" -- the directory exists but could not be listed. Check that it is a directory and that you own it: ls -ld ${listing.dir}, then chmod 700 ${listing.dir}.`
1927
+ }
1928
+ ],
1929
+ isError: true
1930
+ };
1931
+ }
1932
+ if (listing.status === "no-dir") {
1933
+ return {
1934
+ content: [
1935
+ {
1936
+ type: "text",
1937
+ text: `No ~/.ssh directory yet (${listing.dir} does not exist). Generate a key to create it: ssh-keygen -t ed25519 -C "your@email.com"`
1938
+ }
1939
+ ],
1940
+ isError: false
1941
+ };
1942
+ }
1943
+ const keys = listing.keys;
1636
1944
  if (keys.length === 0) {
1637
1945
  return {
1638
1946
  content: [
@@ -1640,7 +1948,8 @@ ${result.stderr}`);
1640
1948
  type: "text",
1641
1949
  text: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
1642
1950
  }
1643
- ]
1951
+ ],
1952
+ isError: false
1644
1953
  };
1645
1954
  }
1646
1955
  const lines = [`Found ${keys.length} SSH key(s):`, ""];
@@ -1651,7 +1960,7 @@ ${result.stderr}`);
1651
1960
  if (key.fingerprint) lines.push(` Fingerprint: ${key.fingerprint}`);
1652
1961
  lines.push("");
1653
1962
  }
1654
- return { content: [{ type: "text", text: lines.join("\n") }] };
1963
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
1655
1964
  }
1656
1965
  );
1657
1966
  server.tool(
@@ -1722,8 +2031,13 @@ ${result.stderr}`);
1722
2031
  "ssh_git_check",
1723
2032
  "Test Git-over-SSH authentication to a hosting provider (GitHub, GitLab, Bitbucket, etc). Verifies your SSH key is registered and working. Use this when git clone/pull/push fails with SSH errors.",
1724
2033
  {
1725
- host: z.string().optional().describe('Git hosting hostname (default: "github.com")'),
1726
- user: z.string().optional().describe('SSH user for the git host (default: "git")')
2034
+ // .min(1), not a bare optional string: the handler defaults with `host || "github.com"`,
2035
+ // so an explicitly-empty host would silently probe github.com and report on a host the
2036
+ // caller never named -- and, because "" never reaches checkGitSsh, it would skip the
2037
+ // isValidHostname check every other host-taking tool routes its input through. Omitting
2038
+ // the field is the way to ask for the default; "" is rejected at the schema boundary.
2039
+ host: z.string().min(1, "host must not be empty. Omit it to use the default (github.com).").optional().describe('Git hosting hostname (default: "github.com"). Omit for the default; an empty string is rejected.'),
2040
+ user: z.string().min(1, "user must not be empty. Omit it to use the default (git).").optional().describe('SSH user for the git host (default: "git"). Omit for the default; an empty string is rejected.')
1727
2041
  },
1728
2042
  async ({ host, user }) => {
1729
2043
  const result = checkGitSsh(host || "github.com", user || "git");
@@ -1736,7 +2050,7 @@ ${result.stderr}`);
1736
2050
  );
1737
2051
  server.tool(
1738
2052
  "ssh_multi_exec",
1739
- "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
2053
+ "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
1740
2054
  {
1741
2055
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1742
2056
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -1744,12 +2058,14 @@ ${result.stderr}`);
1744
2058
  username: UsernameSchema,
1745
2059
  privateKeyPath: KeyPathSchema,
1746
2060
  password: PasswordSchema,
2061
+ env: EnvSchema,
1747
2062
  timeout: TimeoutSchema
1748
2063
  },
1749
- async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1750
- enforcePolicy(command);
2064
+ async ({ hosts, command, port, username, privateKeyPath, password, env, timeout }) => {
2065
+ const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
2066
+ enforcePolicy(finalCommand, { envPrefixApplied });
1751
2067
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1752
- const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
2068
+ const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || 3e4);
1753
2069
  const lines = [];
1754
2070
  for (const r of results) {
1755
2071
  lines.push(`--- ${r.host} ---`);
@@ -1758,6 +2074,7 @@ ${result.stderr}`);
1758
2074
  } else {
1759
2075
  if (r.stdout) lines.push(r.stdout);
1760
2076
  if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
2077
+ if (r.signal) lines.push(`[signal: ${r.signal}]`);
1761
2078
  lines.push(`[exit code: ${r.code}]`);
1762
2079
  }
1763
2080
  lines.push("");
@@ -1809,7 +2126,7 @@ ${files.join("\n")}` }] };
1809
2126
  content: [
1810
2127
  {
1811
2128
  type: "text",
1812
- text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
2129
+ text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty (no content in the last lines read; whitespace-only counts as empty here)."
1813
2130
  }
1814
2131
  ]
1815
2132
  };