@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/index.js CHANGED
@@ -1,19 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
2
8
 
3
9
  // src/index.ts
4
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
11
 
6
12
  // src/env.ts
7
13
  import { execFileSync as execFileSync2 } from "child_process";
8
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
9
- import { homedir as homedir2 } from "os";
10
- import { join as join2 } from "path";
14
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
15
+ import { homedir as homedir3 } from "os";
16
+ import { join as join3 } from "path";
11
17
 
12
18
  // src/diagnose.ts
13
19
  import { execFileSync } from "child_process";
14
20
  import { existsSync, readdirSync, readFileSync } from "fs";
15
21
  import { homedir } from "os";
16
22
  import { join } from "path";
23
+ var SSH_NON_KEY_FILES = /* @__PURE__ */ new Set([
24
+ "known_hosts",
25
+ "known_hosts.old",
26
+ "config",
27
+ "authorized_keys",
28
+ "environment"
29
+ ]);
17
30
  function isValidHostname(host) {
18
31
  if (host.length === 0 || host.length > 253) return false;
19
32
  if (host.startsWith("[")) {
@@ -60,16 +73,16 @@ ${stdout2}` };
60
73
  };
61
74
  }
62
75
  const { stdout, ok } = runArgs("ssh-add", ["-l"]);
63
- if (!ok && stdout.includes("Could not open a connection")) {
76
+ if (stdout.includes("The agent has no identities") || stdout.includes("no identities")) {
64
77
  return {
65
- status: "error",
66
- 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)"`
78
+ status: "warning",
79
+ message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
67
80
  };
68
81
  }
69
- if (stdout.includes("The agent has no identities")) {
82
+ if (!ok) {
70
83
  return {
71
- status: "warning",
72
- message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
84
+ status: "error",
85
+ 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)"`
73
86
  };
74
87
  }
75
88
  return { status: "ok", message: `ssh-agent running with keys:
@@ -90,9 +103,7 @@ function checkSshKeys() {
90
103
  }
91
104
  }
92
105
  try {
93
- const allFiles = readdirSync(sshDir).filter(
94
- (f) => !f.endsWith(".pub") && !["known_hosts", "known_hosts.old", "config", "authorized_keys"].includes(f)
95
- );
106
+ const allFiles = readdirSync(sshDir).filter((f) => !f.endsWith(".pub") && !SSH_NON_KEY_FILES.has(f));
96
107
  for (const f of allFiles) {
97
108
  if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
98
109
  try {
@@ -134,10 +145,17 @@ function checkKnownHosts(host) {
134
145
  }
135
146
  return { status: "ok", message: `Host "${host}" found in known_hosts` };
136
147
  }
137
- function checkConnectivity(host, port = 22) {
138
- if (!isValidHostname(host)) {
139
- return { status: "error", message: `Invalid hostname: "${host}"` };
140
- }
148
+ function classifySshProbe(ok, output) {
149
+ if (ok && output.includes("SSH_OK")) return "ok";
150
+ if (output.includes("Permission denied")) return "permission-denied";
151
+ if (output.includes("Connection refused")) return "connection-refused";
152
+ if (output.includes("timed out")) return "timed-out";
153
+ if (output.includes("Host key verification failed")) return "host-key-mismatch";
154
+ if (output.includes("Could not resolve")) return "dns-failure";
155
+ return "unknown";
156
+ }
157
+ function probeSshConnection(host, port) {
158
+ const start = Date.now();
141
159
  const { ok, stdout } = runArgs("ssh", [
142
160
  "-o",
143
161
  "ConnectTimeout=5",
@@ -152,40 +170,66 @@ function checkConnectivity(host, port = 22) {
152
170
  "echo",
153
171
  "SSH_OK"
154
172
  ]);
155
- if (ok && stdout.includes("SSH_OK")) {
156
- return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
157
- }
158
- if (stdout.includes("Permission denied")) {
159
- return {
160
- status: "error",
161
- 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.`
162
- };
163
- }
164
- if (stdout.includes("Connection refused")) {
165
- return {
166
- status: "error",
167
- message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
168
- };
169
- }
170
- if (stdout.includes("Connection timed out") || stdout.includes("timed out")) {
171
- return {
172
- status: "error",
173
- message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
174
- };
173
+ return { outcome: classifySshProbe(ok, stdout), output: stdout, elapsedMs: Date.now() - start };
174
+ }
175
+ function checkConnectivity(host, port = 22) {
176
+ if (!isValidHostname(host)) {
177
+ return { status: "error", message: `Invalid hostname: "${host}"` };
175
178
  }
176
- if (stdout.includes("Host key verification failed")) {
177
- return {
178
- status: "error",
179
- 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`
180
- };
179
+ const { outcome, output } = probeSshConnection(host, port);
180
+ switch (outcome) {
181
+ case "ok":
182
+ return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
183
+ case "permission-denied":
184
+ return {
185
+ status: "error",
186
+ 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.`
187
+ };
188
+ case "connection-refused":
189
+ return {
190
+ status: "error",
191
+ message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
192
+ };
193
+ case "timed-out":
194
+ return {
195
+ status: "error",
196
+ message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
197
+ };
198
+ case "host-key-mismatch":
199
+ return {
200
+ status: "error",
201
+ 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`
202
+ };
203
+ case "dns-failure":
204
+ return {
205
+ status: "error",
206
+ message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
207
+ };
208
+ default:
209
+ return { status: "error", message: `SSH connection failed: ${output}` };
181
210
  }
182
- if (stdout.includes("Could not resolve hostname")) {
183
- return {
184
- status: "error",
185
- message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
186
- };
211
+ }
212
+ function matchesHostPattern(pattern, host) {
213
+ if (pattern === "*") return true;
214
+ if (pattern === host) return true;
215
+ if (pattern.includes("*") || pattern.includes("?")) {
216
+ const escaped = pattern.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
217
+ return new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$").test(host);
187
218
  }
188
- return { status: "error", message: `SSH connection failed: ${stdout}` };
219
+ return false;
220
+ }
221
+ function hostLineSelects(patternList, host) {
222
+ let positive = false;
223
+ for (const raw of patternList.split(/\s+/)) {
224
+ if (!raw) continue;
225
+ const negated = raw.startsWith("!");
226
+ const pattern = negated ? raw.slice(1) : raw;
227
+ if (!pattern) continue;
228
+ if (!matchesHostPattern(pattern, host)) continue;
229
+ if (negated) return false;
230
+ positive = true;
231
+ }
232
+ return positive;
189
233
  }
190
234
  function checkSshConfig(host) {
191
235
  const configPath = join(homedir(), ".ssh", "config");
@@ -194,24 +238,17 @@ function checkSshConfig(host) {
194
238
  }
195
239
  try {
196
240
  const content = readFileSync(configPath, "utf8");
197
- const lines = content.split("\n");
241
+ const lines = content.split(/\r?\n/);
198
242
  let inHostBlock = false;
199
243
  const hostConfig = [];
200
- for (const line of lines) {
201
- const trimmed = line.trim();
202
- if (/^Host\s+/i.test(trimmed)) {
203
- const patterns = trimmed.replace(/^Host\s+/i, "").trim().split(/\s+/);
204
- inHostBlock = patterns.some((p) => {
205
- if (p === "*") return true;
206
- if (p === host) return true;
207
- if (p.includes("*") || p.includes("?")) {
208
- const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
209
- const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
210
- return regex.test(host);
211
- }
212
- return false;
213
- });
244
+ for (const rawLine of lines) {
245
+ const trimmed = rawLine.replace(/(^|\s)#.*/, "$1").trim();
246
+ if (/^Host[\s=]/i.test(trimmed)) {
247
+ const patternList = trimmed.replace(/^Host[\s=]+/i, "").trim();
248
+ inHostBlock = hostLineSelects(patternList, host);
214
249
  if (inHostBlock) hostConfig.push(trimmed);
250
+ } else if (/^Match[\s=]/i.test(trimmed)) {
251
+ inHostBlock = false;
215
252
  } else if (inHostBlock && trimmed) {
216
253
  hostConfig.push(trimmed);
217
254
  }
@@ -261,6 +298,15 @@ function diagnose(host, port = 22) {
261
298
  return { overall, checks, suggestions };
262
299
  }
263
300
 
301
+ // src/ssh.ts
302
+ import { readFileSync as readFileSync2 } from "fs";
303
+ import { createRequire } from "module";
304
+ import { homedir as homedir2 } from "os";
305
+ import { join as join2 } from "path";
306
+ import {
307
+ Client
308
+ } from "ssh2";
309
+
264
310
  // src/ssh-config.ts
265
311
  function parseSshConfigOutput(stdout) {
266
312
  const all = {};
@@ -280,450 +326,220 @@ function parseSshConfigOutput(stdout) {
280
326
  return { all, identityFiles };
281
327
  }
282
328
 
283
- // src/env.ts
284
- function runArgsWithEnv(cmd, args, extraEnv) {
285
- const env = {};
286
- for (const [k, v] of Object.entries(process.env)) {
287
- if (typeof v === "string") env[k] = v;
329
+ // src/ssh.ts
330
+ var sshConfigCache = /* @__PURE__ */ new Map();
331
+ function resolveFromSshConfig(host) {
332
+ const cached = sshConfigCache.get(host);
333
+ if (cached !== void 0) return cached;
334
+ const result = resolveFromSshConfigUncached(host);
335
+ sshConfigCache.set(host, result);
336
+ return result;
337
+ }
338
+ function resolveFromSshConfigUncached(host) {
339
+ try {
340
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
341
+ if (!ok) return null;
342
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
343
+ return {
344
+ hostname: all.hostname || host,
345
+ user: all.user || "",
346
+ port: all.port || "22",
347
+ identityFiles,
348
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
349
+ };
350
+ } catch {
351
+ return null;
288
352
  }
289
- for (const [k, v] of Object.entries(extraEnv)) {
290
- if (v === void 0) {
291
- delete env[k];
292
- } else {
293
- env[k] = v;
353
+ }
354
+ function unbracketHost(host) {
355
+ return host.length > 2 && host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
356
+ }
357
+ function knownHostsTargets(host, port) {
358
+ const bare = unbracketHost(host);
359
+ const isIpv6 = bare.includes(":");
360
+ if (!isValidHostname(isIpv6 ? `[${bare}]` : bare)) return [];
361
+ return port && port !== 22 ? [`[${bare}]:${port}`, bare] : [bare];
362
+ }
363
+ function readKnownHostsEntries(host, port) {
364
+ const entries = [];
365
+ for (const target of knownHostsTargets(host, port)) {
366
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
367
+ if (!ok || !stdout.trim()) continue;
368
+ for (const line of stdout.split("\n")) {
369
+ const trimmed = line.trim();
370
+ if (!trimmed || trimmed.startsWith("#")) continue;
371
+ const parts = trimmed.split(/\s+/);
372
+ if (parts[0].startsWith("@")) continue;
373
+ if (parts.length < 3) continue;
374
+ try {
375
+ entries.push({ type: parts[1], key: Buffer.from(parts[2], "base64") });
376
+ } catch {
377
+ }
294
378
  }
295
379
  }
296
- try {
297
- const stdout = execFileSync2(cmd, args, {
298
- env,
299
- encoding: "utf8",
300
- timeout: 1e4,
301
- stdio: ["pipe", "pipe", "pipe"]
302
- });
303
- return { stdout: stdout.trim(), ok: true };
304
- } catch (e) {
305
- const err = e;
306
- const so = err.stdout?.toString().trim() || "";
307
- const se = err.stderr?.toString().trim() || "";
308
- const output = [so, se].filter(Boolean).join("\n") || err.message || "";
309
- return { stdout: output, ok: false };
310
- }
380
+ return entries;
311
381
  }
312
- function probeAgent(socket, agentLabel) {
313
- const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
314
- const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
315
- const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
316
- const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
317
- if (!ok && !noIdentities) return null;
318
- const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
319
- return {
320
- running: true,
321
- reachable: true,
322
- socket,
323
- keys,
324
- started: false,
325
- 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.`
326
- };
382
+ function hostKeyBlobType(key) {
383
+ if (key.length < 4) return null;
384
+ const len = key.readUInt32BE(0);
385
+ if (len === 0 || len > 64 || key.length < 4 + len) return null;
386
+ const type = key.toString("utf8", 4, 4 + len);
387
+ return /^[a-zA-Z0-9@._-]+$/.test(type) ? type : null;
327
388
  }
328
- var startedAgentPid = null;
329
- function killStartedAgent() {
330
- if (startedAgentPid === null) return;
389
+ var HOST_KEY_TYPE_TO_ALGORITHMS = {
390
+ "ssh-rsa": ["rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"]
391
+ };
392
+ var SSH2_CONSTANTS_MODULE = "ssh2/lib/protocol/constants.js";
393
+ function loadSsh2Constants() {
331
394
  try {
332
- process.kill(startedAgentPid);
395
+ return __require("ssh2/lib/protocol/constants.js");
333
396
  } catch {
334
397
  }
335
- startedAgentPid = null;
336
- }
337
- function ensureAgent() {
338
- const sock = process.env.SSH_AUTH_SOCK;
339
- if (sock) {
340
- const result = probeAgent(sock, "ssh-agent");
341
- if (result) return result;
342
- }
343
- if (process.platform === "win32") {
344
- const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
345
- if (result) return result;
346
- return {
347
- running: false,
348
- reachable: false,
349
- keys: [],
350
- started: false,
351
- message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
352
- };
398
+ try {
399
+ return createRequire(import.meta.url)(SSH2_CONSTANTS_MODULE);
400
+ } catch {
401
+ return null;
353
402
  }
354
- const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
355
- if (ok) {
356
- const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
357
- const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
358
- if (sockMatch) {
359
- process.env.SSH_AUTH_SOCK = sockMatch[1];
360
- if (pidMatch) {
361
- process.env.SSH_AGENT_PID = pidMatch[1];
362
- startedAgentPid = Number.parseInt(pidMatch[1], 10);
403
+ }
404
+ var ssh2DefaultHostKeyAlgos;
405
+ function defaultServerHostKeyAlgorithms() {
406
+ if (ssh2DefaultHostKeyAlgos !== void 0) return ssh2DefaultHostKeyAlgos;
407
+ const DEFAULT_SERVER_HOST_KEY = loadSsh2Constants()?.DEFAULT_SERVER_HOST_KEY;
408
+ 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;
409
+ return ssh2DefaultHostKeyAlgos;
410
+ }
411
+ function hostKeyAlgorithmOrder(knownHostTypes) {
412
+ if (knownHostTypes.length === 0) return null;
413
+ const defaults = defaultServerHostKeyAlgorithms();
414
+ if (!defaults) return null;
415
+ const preferred = /* @__PURE__ */ new Set();
416
+ for (const type of knownHostTypes) {
417
+ for (const algo of HOST_KEY_TYPE_TO_ALGORITHMS[type] ?? [type]) preferred.add(algo);
418
+ }
419
+ const front = defaults.filter((a) => preferred.has(a));
420
+ const back = defaults.filter((a) => !preferred.has(a));
421
+ if (front.length === 0 || back.length === 0) return null;
422
+ return [...front, ...back];
423
+ }
424
+ var KNOWN_HOST_TYPE_TTL_MS = 5e3;
425
+ var knownHostTypeCache = /* @__PURE__ */ new Map();
426
+ function cachedKnownHostTypes(hosts, port) {
427
+ const cacheKey = `${hosts.join(" ")}:${port ?? ""}`;
428
+ const now = Date.now();
429
+ const hit = knownHostTypeCache.get(cacheKey);
430
+ if (hit && now - hit.at < KNOWN_HOST_TYPE_TTL_MS) return hit.types;
431
+ const types = [...new Set(hosts.flatMap((h) => readKnownHostsEntries(h, port).map((e) => e.type)))];
432
+ knownHostTypeCache.set(cacheKey, { at: now, types });
433
+ return types;
434
+ }
435
+ function buildHostVerifier(hosts, port, rejection) {
436
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
437
+ const label = hosts.join(" / ");
438
+ const remediationHost = unbracketHost(hosts[0]);
439
+ return (key) => {
440
+ rejection.current = null;
441
+ const known = hosts.flatMap((h) => readKnownHostsEntries(h, port));
442
+ if (known.length === 0) {
443
+ if (strict) {
444
+ rejection.current = {
445
+ reason: "unknown-host-strict",
446
+ 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`
447
+ };
363
448
  }
364
- return {
365
- running: true,
366
- reachable: true,
367
- socket: sockMatch[1],
368
- keys: [],
369
- started: true,
370
- env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
371
- 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."
372
- };
449
+ return !strict;
373
450
  }
374
- }
375
- return {
376
- running: false,
377
- reachable: false,
378
- keys: [],
379
- started: false,
380
- message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
451
+ if (known.some((e) => e.key.equals(key))) return true;
452
+ const offered = hostKeyBlobType(key);
453
+ const knownTypes = [...new Set(known.map((e) => e.type))];
454
+ rejection.current = offered && !knownTypes.includes(offered) ? {
455
+ reason: "algorithm-not-in-known-hosts",
456
+ 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`
457
+ } : {
458
+ reason: "key-mismatch",
459
+ 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}"`
460
+ };
461
+ return false;
381
462
  };
382
463
  }
383
- function detectKeyType(filePath, fileName) {
384
- const pubPath = `${filePath}.pub`;
385
- if (existsSync2(pubPath)) {
464
+ var PRIVATE_KEY_MARKER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
465
+ function looksLikePrivateKey(content) {
466
+ const text = content.toString("utf8");
467
+ return PRIVATE_KEY_MARKER.test(text) || text.trimStart().startsWith("PuTTY-User-Key-File-");
468
+ }
469
+ function isEncryptedKey(content) {
470
+ const text = content.toString("utf8");
471
+ if (text.includes("ENCRYPTED")) return true;
472
+ const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
473
+ if (m) {
386
474
  try {
387
- const pub = readFileSync2(pubPath, "utf8");
388
- if (pub.includes("ssh-ed25519")) return "ed25519";
389
- if (pub.includes("ssh-rsa")) return "rsa";
390
- if (pub.includes("ecdsa")) return "ecdsa";
391
- if (pub.includes("ssh-dss")) return "dsa";
475
+ const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
476
+ const magic = "openssh-key-v1\0";
477
+ if (raw.toString("latin1", 0, magic.length) !== magic) return true;
478
+ const cipherLen = raw.readUInt32BE(magic.length);
479
+ const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
480
+ return cipher !== "none";
392
481
  } catch {
482
+ return true;
393
483
  }
394
484
  }
395
- if (fileName.includes("ed25519")) return "ed25519";
396
- if (fileName.includes("rsa")) return "rsa";
397
- if (fileName.includes("ecdsa")) return "ecdsa";
398
- if (fileName.includes("dsa")) return "dsa";
399
- try {
400
- const content = readFileSync2(filePath, "utf8");
401
- if (content.includes("RSA PRIVATE KEY")) return "rsa";
402
- if (content.includes("EC PRIVATE KEY")) return "ecdsa";
403
- if (content.includes("DSA PRIVATE KEY")) return "dsa";
404
- if (content.includes("OPENSSH PRIVATE KEY")) {
405
- const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
406
- if (ok) {
407
- const match = stdout.match(/\(([^)]+)\)\s*$/);
408
- if (match) return match[1].toLowerCase();
485
+ return false;
486
+ }
487
+ function resolveConfig(config) {
488
+ const sshConfig = resolveFromSshConfig(config.host);
489
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
490
+ const verifierHosts = [];
491
+ const seenHosts = /* @__PURE__ */ new Set();
492
+ for (const candidate of [config.host, sshConfig?.hostname]) {
493
+ if (!candidate) continue;
494
+ const canonical = unbracketHost(candidate);
495
+ if (seenHosts.has(canonical)) continue;
496
+ seenHosts.add(canonical);
497
+ verifierHosts.push(candidate);
498
+ }
499
+ const hostKeyRejection = { current: null };
500
+ const connectConfig = {
501
+ host: sshConfig?.hostname || config.host,
502
+ port,
503
+ username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
504
+ keepaliveInterval: 15e3,
505
+ keepaliveCountMax: 3,
506
+ hostVerifier: buildHostVerifier(verifierHosts, port, hostKeyRejection)
507
+ };
508
+ let algorithmsApplied = false;
509
+ const applyHostKeyAlgorithms = () => {
510
+ if (algorithmsApplied) return;
511
+ algorithmsApplied = true;
512
+ const algorithmOrder = hostKeyAlgorithmOrder(cachedKnownHostTypes(verifierHosts, port));
513
+ if (algorithmOrder) {
514
+ connectConfig.algorithms = { serverHostKey: algorithmOrder };
515
+ }
516
+ };
517
+ const home = homedir2();
518
+ if (config.privateKeyPath) {
519
+ const keyPath = config.privateKeyPath.startsWith("~") ? join2(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
520
+ connectConfig.privateKey = readFileSync2(keyPath);
521
+ } else if (config.password) {
522
+ connectConfig.password = config.password;
523
+ } else {
524
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
525
+ if (agentSock) {
526
+ connectConfig.agent = agentSock;
527
+ }
528
+ 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")];
529
+ for (const keyPath of keyPaths) {
530
+ let keyData;
531
+ try {
532
+ keyData = readFileSync2(keyPath);
533
+ } catch {
534
+ continue;
409
535
  }
536
+ if (!looksLikePrivateKey(keyData)) continue;
537
+ if (agentSock && isEncryptedKey(keyData)) continue;
538
+ connectConfig.privateKey = keyData;
539
+ break;
410
540
  }
411
- } catch {
412
- }
413
- return "unknown";
414
- }
415
- function listSshKeys() {
416
- const sshDir = join2(homedir2(), ".ssh");
417
- if (!existsSync2(sshDir)) return [];
418
- const loadedFingerprints = /* @__PURE__ */ new Set();
419
- const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
420
- if (agentOk && !agentOut.includes("no identities")) {
421
- for (const line of agentOut.split("\n").filter(Boolean)) {
422
- const match = line.match(/(\S+:\S+)/);
423
- if (match) loadedFingerprints.add(match[1]);
424
- }
425
- }
426
- const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
427
- const keys = [];
428
- let files;
429
- try {
430
- files = readdirSync2(sshDir);
431
- } catch {
432
- return [];
433
- }
434
- for (const file of files) {
435
- if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
436
- const filePath = join2(sshDir, file);
437
- try {
438
- const stat = statSync(filePath);
439
- if (!stat.isFile()) continue;
440
- const content = readFileSync2(filePath, "utf8");
441
- if (!content.includes("PRIVATE KEY")) continue;
442
- const type = detectKeyType(filePath, file);
443
- let fingerprint;
444
- const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
445
- if (fpOk) {
446
- const match = fpOut.match(/(\S+:\S+)/);
447
- fingerprint = match?.[1];
448
- }
449
- const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
450
- keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
451
- } catch {
452
- }
453
- }
454
- return keys;
455
- }
456
- function loadKey(keyPath) {
457
- const agent = ensureAgent();
458
- if (!agent.reachable) {
459
- return { status: "error", message: agent.message };
460
- }
461
- const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
462
- if (!existsSync2(resolved)) {
463
- return { status: "error", message: `Key not found: ${resolved}` };
464
- }
465
- const { stdout, ok } = runArgs("ssh-add", [resolved]);
466
- if (ok) {
467
- return { status: "ok", message: `Key loaded: ${resolved}` };
468
- }
469
- if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
470
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
471
- }
472
- if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
473
- return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
474
- }
475
- return { status: "error", message: `Failed to load key: ${stdout}` };
476
- }
477
- function configLookup(host) {
478
- if (!isValidHostname(host)) {
479
- return { error: `Invalid hostname: "${host}"` };
480
- }
481
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
482
- if (!ok) {
483
- return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
484
- }
485
- const { all, identityFiles } = parseSshConfigOutput(stdout);
486
- return {
487
- hostname: all.hostname || host,
488
- user: all.user || "",
489
- port: all.port || "22",
490
- identityFile: identityFiles,
491
- proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
492
- proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
493
- all,
494
- raw: stdout
495
- };
496
- }
497
- function fixKnownHosts(host, port = 22) {
498
- if (!isValidHostname(host)) {
499
- return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
500
- }
501
- const actions = [];
502
- const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
503
- if (removeOk) {
504
- actions.push(`Removed old host key for ${host}`);
505
- }
506
- if (port !== 22) {
507
- const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
508
- if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
509
- }
510
- const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
511
- const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
512
- if (scanOk && scanOut.trim()) {
513
- try {
514
- const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
515
- appendFileSync(knownHostsPath, `
516
- ${scanOut.trim()}
517
- `);
518
- actions.push(`Added new host key for ${host}`);
519
- return { status: "ok", message: `Host key refreshed for ${host}`, actions };
520
- } catch (e) {
521
- const msg = e instanceof Error ? e.message : String(e);
522
- return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
523
- }
524
- }
525
- return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
526
- }
527
- function checkGitSsh(host = "github.com", user = "git") {
528
- if (!isValidHostname(host)) {
529
- return { status: "error", message: `Invalid hostname: "${host}"` };
530
- }
531
- const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
532
- const text = stdout;
533
- if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
534
- const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
535
- return {
536
- status: "ok",
537
- message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
538
- authenticatedAs: userMatch?.[1]
539
- };
540
- }
541
- if (text.includes("Permission denied")) {
542
- return {
543
- status: "error",
544
- 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.`
545
- };
546
- }
547
- if (text.includes("Connection refused")) {
548
- return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
549
- }
550
- if (text.includes("timed out") || text.includes("Connection timed out")) {
551
- return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
552
- }
553
- if (text.includes("Could not resolve")) {
554
- return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
555
- }
556
- return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
557
- }
558
- function testConnection(host, port = 22) {
559
- if (!isValidHostname(host)) {
560
- return { status: "error", message: `Invalid hostname: "${host}"` };
561
- }
562
- const start = Date.now();
563
- const { ok, stdout } = runArgs("ssh", [
564
- "-o",
565
- "ConnectTimeout=5",
566
- "-o",
567
- "BatchMode=yes",
568
- "-o",
569
- "StrictHostKeyChecking=no",
570
- "-p",
571
- String(port),
572
- "--",
573
- host,
574
- "echo",
575
- "SSH_OK"
576
- ]);
577
- const elapsed = Date.now() - start;
578
- if (ok && stdout.includes("SSH_OK")) {
579
- return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
580
- }
581
- if (stdout.includes("Permission denied")) {
582
- return {
583
- status: "error",
584
- 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.`
585
- };
586
- }
587
- if (stdout.includes("Connection refused")) {
588
- return {
589
- status: "error",
590
- message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
591
- };
592
- }
593
- if (stdout.includes("timed out")) {
594
- return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
595
- }
596
- if (stdout.includes("Host key verification failed")) {
597
- return {
598
- status: "error",
599
- message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
600
- };
601
- }
602
- if (stdout.includes("Could not resolve")) {
603
- return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
604
- }
605
- return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
606
- }
607
-
608
- // src/pool.ts
609
- import { createHash } from "crypto";
610
-
611
- // src/ssh.ts
612
- import { readFileSync as readFileSync3 } from "fs";
613
- import { homedir as homedir3 } from "os";
614
- import { join as join3 } from "path";
615
- import { Client } from "ssh2";
616
- var sshConfigCache = /* @__PURE__ */ new Map();
617
- function resolveFromSshConfig(host) {
618
- const cached = sshConfigCache.get(host);
619
- if (cached !== void 0) return cached;
620
- const result = resolveFromSshConfigUncached(host);
621
- sshConfigCache.set(host, result);
622
- return result;
623
- }
624
- function resolveFromSshConfigUncached(host) {
625
- try {
626
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
627
- if (!ok) return null;
628
- const { all, identityFiles } = parseSshConfigOutput(stdout);
629
- return {
630
- hostname: all.hostname || host,
631
- user: all.user || "",
632
- port: all.port || "22",
633
- identityFiles,
634
- proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
635
- };
636
- } catch {
637
- return null;
638
541
  }
639
- }
640
- function readKnownHostsKeys(host, port) {
641
- if (!isValidHostname(host)) return [];
642
- const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
643
- const keys = [];
644
- for (const target of targets) {
645
- const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
646
- if (!ok || !stdout.trim()) continue;
647
- for (const line of stdout.split("\n")) {
648
- const trimmed = line.trim();
649
- if (!trimmed || trimmed.startsWith("#")) continue;
650
- const parts = trimmed.split(/\s+/);
651
- if (parts.length < 3) continue;
652
- try {
653
- keys.push(Buffer.from(parts[2], "base64"));
654
- } catch {
655
- }
656
- }
657
- }
658
- return keys;
659
- }
660
- function buildHostVerifier(hosts, port) {
661
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
662
- return (key) => {
663
- const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
664
- if (known.length === 0) {
665
- return !strict;
666
- }
667
- return known.some((k) => k.equals(key));
668
- };
669
- }
670
- function isEncryptedKey(content) {
671
- const text = content.toString("utf8");
672
- if (text.includes("ENCRYPTED")) return true;
673
- const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
674
- if (m) {
675
- try {
676
- const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
677
- const magic = "openssh-key-v1\0";
678
- if (raw.toString("latin1", 0, magic.length) === magic) {
679
- const cipherLen = raw.readUInt32BE(magic.length);
680
- const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
681
- return cipher !== "none";
682
- }
683
- } catch {
684
- return true;
685
- }
686
- }
687
- return false;
688
- }
689
- function resolveConfig(config) {
690
- const sshConfig = resolveFromSshConfig(config.host);
691
- const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
692
- const verifierHosts = [config.host];
693
- if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
694
- verifierHosts.push(sshConfig.hostname);
695
- }
696
- const connectConfig = {
697
- host: sshConfig?.hostname || config.host,
698
- port,
699
- username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
700
- keepaliveInterval: 15e3,
701
- keepaliveCountMax: 3,
702
- hostVerifier: buildHostVerifier(verifierHosts, port)
703
- };
704
- const home = homedir3();
705
- if (config.privateKeyPath) {
706
- const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
707
- connectConfig.privateKey = readFileSync3(keyPath);
708
- } else if (config.password) {
709
- connectConfig.password = config.password;
710
- } else {
711
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
712
- if (agentSock) {
713
- connectConfig.agent = agentSock;
714
- }
715
- 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")];
716
- for (const keyPath of keyPaths) {
717
- try {
718
- const keyData = readFileSync3(keyPath);
719
- if (agentSock && isEncryptedKey(keyData)) continue;
720
- connectConfig.privateKey = keyData;
721
- break;
722
- } catch {
723
- }
724
- }
725
- }
726
- return { connectConfig, proxyJump: sshConfig?.proxyJump };
542
+ return { connectConfig, proxyJump: sshConfig?.proxyJump, hostKeyRejection, applyHostKeyAlgorithms };
727
543
  }
728
544
  var DIAG_CACHE_TTL_MS = 2e3;
729
545
  var diagAgentCache = null;
@@ -782,12 +598,82 @@ function connectRaw(connectConfig) {
782
598
  client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
783
599
  });
784
600
  }
785
- async function connectWithProxy(resolved) {
786
- if (!resolved.proxyJump) {
787
- return connectRaw(resolved.connectConfig);
601
+ function parsePort(text) {
602
+ if (!/^\d{1,5}$/.test(text)) return void 0;
603
+ const port = Number.parseInt(text, 10);
604
+ return port >= 1 && port <= 65535 ? port : void 0;
605
+ }
606
+ function parseJumpSpec(spec) {
607
+ const hops = [];
608
+ for (const piece of spec.split(",")) {
609
+ const hop = parseJumpHop(piece.trim());
610
+ if (hop) hops.push(hop);
611
+ }
612
+ return hops;
613
+ }
614
+ function parseJumpHop(piece) {
615
+ if (!piece) return null;
616
+ let rest = piece.startsWith("ssh://") ? piece.slice("ssh://".length) : piece;
617
+ if (!rest) return null;
618
+ let username;
619
+ const at = rest.lastIndexOf("@");
620
+ if (at !== -1) {
621
+ username = rest.slice(0, at) || void 0;
622
+ rest = rest.slice(at + 1);
623
+ }
624
+ let host = rest;
625
+ let port;
626
+ if (host.startsWith("[")) {
627
+ const close = host.indexOf("]");
628
+ if (close !== -1) {
629
+ const after = host.slice(close + 1);
630
+ host = host.slice(1, close);
631
+ if (after.startsWith(":")) port = parsePort(after.slice(1));
632
+ }
633
+ } else if (host.indexOf(":") !== -1 && host.indexOf(":") === host.lastIndexOf(":")) {
634
+ const colon = host.lastIndexOf(":");
635
+ const parsed = parsePort(host.slice(colon + 1));
636
+ if (parsed !== void 0) {
637
+ port = parsed;
638
+ host = host.slice(0, colon);
639
+ }
640
+ }
641
+ if (!host) return null;
642
+ const hop = { host };
643
+ if (port !== void 0) hop.port = port;
644
+ if (username !== void 0) hop.username = username;
645
+ return hop;
646
+ }
647
+ function formatJumpHop(hop) {
648
+ const host = hop.host.includes(":") ? `[${hop.host}]` : hop.host;
649
+ const withPort = hop.port ? `${host}:${hop.port}` : host;
650
+ return hop.username ? `${hop.username}@${withPort}` : withPort;
651
+ }
652
+ async function connectWithProxy(resolved) {
653
+ resolved.applyHostKeyAlgorithms?.();
654
+ const hops = resolved.proxyJump ? parseJumpSpec(resolved.proxyJump) : [];
655
+ if (hops.length === 0) {
656
+ return connectRaw(resolved.connectConfig);
657
+ }
658
+ const jumpHop = hops[hops.length - 1];
659
+ const jumpLabel = formatJumpHop(jumpHop);
660
+ const jumpResolved = resolveConfig({ host: jumpHop.host, port: jumpHop.port, username: jumpHop.username });
661
+ if (hops.length > 1) {
662
+ jumpResolved.proxyJump = hops.slice(0, -1).map(formatJumpHop).join(",");
663
+ }
664
+ let jumpClient;
665
+ try {
666
+ jumpClient = await connectWithProxy(jumpResolved);
667
+ } catch (err) {
668
+ const jumpRejection = jumpResolved.hostKeyRejection?.current;
669
+ if (jumpRejection && resolved.hostKeyRejection) {
670
+ resolved.hostKeyRejection.current = {
671
+ reason: jumpRejection.reason,
672
+ message: `on jump host ${jumpLabel} -- ${jumpRejection.message}`
673
+ };
674
+ }
675
+ throw err;
788
676
  }
789
- const jumpResolved = resolveConfig({ host: resolved.proxyJump });
790
- const jumpClient = await connectWithProxy(jumpResolved);
791
677
  const targetHost = resolved.connectConfig.host;
792
678
  const targetPort = resolved.connectConfig.port;
793
679
  const endJump = () => {
@@ -815,28 +701,44 @@ async function connectWithProxy(resolved) {
815
701
  }).connect({ ...resolved.connectConfig, sock: stream });
816
702
  });
817
703
  }
704
+ function enhanceSshError(err, host, resolved) {
705
+ const extra = [];
706
+ const rejection = resolved?.hostKeyRejection?.current;
707
+ if (rejection) extra.push(`Host key check failed -- ${rejection.message}`);
708
+ const diag = formatDiagnostics(host);
709
+ if (diag) extra.push(`SSH Diagnostics:
710
+ ${diag}`);
711
+ if (extra.length === 0) return err;
712
+ const message = err instanceof Error ? err.message : String(err);
713
+ const enhanced = new Error([message, ...extra].join("\n\n"));
714
+ enhanced.cause = err;
715
+ return enhanced;
716
+ }
818
717
  var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
819
718
  function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
820
719
  return new Promise((resolve, reject) => {
821
720
  let settled = false;
822
721
  let activeStream = null;
722
+ let releaseCapture = null;
823
723
  const settle = (fn) => {
824
724
  if (settled) return;
825
725
  settled = true;
826
726
  clearTimeout(timer);
827
727
  fn();
828
728
  };
829
- const timer = setTimeout(() => {
830
- if (activeStream) {
831
- try {
832
- activeStream.signal("TERM");
833
- } catch {
834
- }
835
- try {
836
- activeStream.close();
837
- } catch {
838
- }
729
+ const teardownStream = (stream) => {
730
+ try {
731
+ stream.signal("TERM");
732
+ } catch {
733
+ }
734
+ try {
735
+ stream.close();
736
+ } catch {
839
737
  }
738
+ };
739
+ const timer = setTimeout(() => {
740
+ releaseCapture?.();
741
+ if (activeStream) teardownStream(activeStream);
840
742
  settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
841
743
  }, timeoutMs);
842
744
  client.exec(command, (err, stream) => {
@@ -898,6 +800,18 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
898
800
  stream.stderr.on("data", appendStderr).on("error", (err2) => {
899
801
  settle(() => reject(err2));
900
802
  });
803
+ releaseCapture = () => {
804
+ stream.removeListener("data", appendStdout);
805
+ stream.stderr.removeListener("data", appendStderr);
806
+ stdoutChunks.length = 0;
807
+ stderrChunks.length = 0;
808
+ stdoutBytes = 0;
809
+ stderrBytes = 0;
810
+ };
811
+ if (settled) {
812
+ releaseCapture();
813
+ teardownStream(stream);
814
+ }
901
815
  });
902
816
  });
903
817
  }
@@ -924,143 +838,475 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
924
838
  `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.`
925
839
  );
926
840
  }
927
- return await new Promise((resolve, reject) => {
928
- sftp.readFile(remotePath, (err, data) => {
929
- if (err) return reject(err);
930
- resolve(data.toString("utf8"));
931
- });
932
- });
933
- } finally {
934
- sftp.end();
841
+ return await new Promise((resolve, reject) => {
842
+ sftp.readFile(remotePath, (err, data) => {
843
+ if (err) return reject(err);
844
+ resolve(data.toString("utf8"));
845
+ });
846
+ });
847
+ } finally {
848
+ sftp.end();
849
+ }
850
+ }
851
+ async function writeFile(client, remotePath, content) {
852
+ const sftp = await getSftp(client);
853
+ try {
854
+ await new Promise((resolve, reject) => {
855
+ sftp.writeFile(remotePath, content, (err) => {
856
+ if (err) return reject(err);
857
+ resolve();
858
+ });
859
+ });
860
+ } finally {
861
+ sftp.end();
862
+ }
863
+ }
864
+ async function uploadFile(client, localPath, remotePath) {
865
+ const resolvedLocal = localPath.startsWith("~") ? join2(homedir2(), localPath.slice(1)) : localPath;
866
+ const sftp = await getSftp(client);
867
+ try {
868
+ await new Promise((resolve, reject) => {
869
+ sftp.fastPut(resolvedLocal, remotePath, (err) => {
870
+ if (err) return reject(err);
871
+ resolve();
872
+ });
873
+ });
874
+ } finally {
875
+ sftp.end();
876
+ }
877
+ }
878
+ async function downloadFile(client, remotePath, localPath) {
879
+ const resolvedLocal = localPath.startsWith("~") ? join2(homedir2(), localPath.slice(1)) : localPath;
880
+ const sftp = await getSftp(client);
881
+ try {
882
+ await new Promise((resolve, reject) => {
883
+ sftp.fastGet(remotePath, resolvedLocal, (err) => {
884
+ if (err) return reject(err);
885
+ resolve();
886
+ });
887
+ });
888
+ } finally {
889
+ sftp.end();
890
+ }
891
+ }
892
+ async function listDir(client, remotePath) {
893
+ const sftp = await getSftp(client);
894
+ try {
895
+ return await new Promise((resolve, reject) => {
896
+ sftp.readdir(remotePath, (err, list) => {
897
+ if (err) return reject(err);
898
+ resolve(list.map((item) => item.filename));
899
+ });
900
+ });
901
+ } finally {
902
+ sftp.end();
903
+ }
904
+ }
905
+ async function statFile(client, remotePath) {
906
+ const sftp = await getSftp(client);
907
+ const call = (fn) => new Promise((resolve, reject) => {
908
+ sftp[fn](remotePath, (err, stats) => err ? reject(err) : resolve(stats));
909
+ });
910
+ try {
911
+ const link = await call("lstat");
912
+ const isSymbolicLink = link.isSymbolicLink();
913
+ let meta = link;
914
+ if (isSymbolicLink) {
915
+ try {
916
+ meta = await call("stat");
917
+ } catch {
918
+ }
919
+ }
920
+ return {
921
+ size: meta.size,
922
+ mode: meta.mode,
923
+ modeOctal: (meta.mode & 4095).toString(8).padStart(4, "0"),
924
+ uid: meta.uid,
925
+ gid: meta.gid,
926
+ mtime: meta.mtime,
927
+ atime: meta.atime,
928
+ // isFile / isDirectory describe the TARGET (they pair with the metadata above);
929
+ // isSymbolicLink describes the PATH. A symlink to a directory is therefore both
930
+ // a directory and a symlink, and the caller decides which matters.
931
+ isFile: meta.isFile(),
932
+ isDirectory: meta.isDirectory(),
933
+ isSymbolicLink
934
+ };
935
+ } finally {
936
+ sftp.end();
937
+ }
938
+ }
939
+ async function deleteFile(client, remotePath) {
940
+ const sftp = await getSftp(client);
941
+ try {
942
+ const stats = await new Promise((resolve, reject) => {
943
+ sftp.lstat(remotePath, (err, stats2) => {
944
+ if (err) return reject(err);
945
+ resolve(stats2);
946
+ });
947
+ });
948
+ await new Promise((resolve, reject) => {
949
+ const done = (err) => err ? reject(err) : resolve();
950
+ if (stats.isDirectory()) {
951
+ sftp.rmdir(remotePath, done);
952
+ } else {
953
+ sftp.unlink(remotePath, done);
954
+ }
955
+ });
956
+ } finally {
957
+ sftp.end();
958
+ }
959
+ }
960
+ async function makeDir(client, remotePath, recursive = false) {
961
+ const sftp = await getSftp(client);
962
+ try {
963
+ const mkOne = (path) => new Promise((resolve, reject) => {
964
+ sftp.mkdir(path, (err) => err ? reject(err) : resolve());
965
+ });
966
+ if (!recursive) {
967
+ await mkOne(remotePath);
968
+ return;
969
+ }
970
+ const parts = remotePath.split("/").filter(Boolean);
971
+ let cur = remotePath.startsWith("/") ? "" : ".";
972
+ for (let i = 0; i < parts.length; i++) {
973
+ cur = `${cur}/${parts[i]}`;
974
+ const isLeaf = i === parts.length - 1;
975
+ try {
976
+ await mkOne(cur);
977
+ } catch (e) {
978
+ if (isLeaf) throw e;
979
+ }
980
+ }
981
+ } finally {
982
+ sftp.end();
983
+ }
984
+ }
985
+
986
+ // src/env.ts
987
+ function runArgsWithEnv(cmd, args, extraEnv) {
988
+ const env = {};
989
+ for (const [k, v] of Object.entries(process.env)) {
990
+ if (typeof v === "string") env[k] = v;
991
+ }
992
+ for (const [k, v] of Object.entries(extraEnv)) {
993
+ if (v === void 0) {
994
+ delete env[k];
995
+ } else {
996
+ env[k] = v;
997
+ }
998
+ }
999
+ try {
1000
+ const stdout = execFileSync2(cmd, args, {
1001
+ env,
1002
+ encoding: "utf8",
1003
+ timeout: 1e4,
1004
+ stdio: ["pipe", "pipe", "pipe"]
1005
+ });
1006
+ return { stdout: stdout.trim(), ok: true };
1007
+ } catch (e) {
1008
+ const err = e;
1009
+ const so = err.stdout?.toString().trim() || "";
1010
+ const se = err.stderr?.toString().trim() || "";
1011
+ const output = [so, se].filter(Boolean).join("\n") || err.message || "";
1012
+ return { stdout: output, ok: false };
1013
+ }
1014
+ }
1015
+ function probeAgent(socket, agentLabel) {
1016
+ const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
1017
+ const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
1018
+ const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
1019
+ const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
1020
+ if (!ok && !noIdentities) return null;
1021
+ const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
1022
+ return {
1023
+ running: true,
1024
+ reachable: true,
1025
+ socket,
1026
+ keys,
1027
+ started: false,
1028
+ 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.`
1029
+ };
1030
+ }
1031
+ var startedAgentPid = null;
1032
+ function killStartedAgent() {
1033
+ if (startedAgentPid === null) return;
1034
+ try {
1035
+ process.kill(startedAgentPid);
1036
+ } catch {
1037
+ }
1038
+ startedAgentPid = null;
1039
+ }
1040
+ function ensureAgent() {
1041
+ const sock = process.env.SSH_AUTH_SOCK;
1042
+ if (sock) {
1043
+ const result = probeAgent(sock, "ssh-agent");
1044
+ if (result) return result;
1045
+ }
1046
+ if (process.platform === "win32") {
1047
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
1048
+ if (result) return result;
1049
+ return {
1050
+ running: false,
1051
+ reachable: false,
1052
+ keys: [],
1053
+ started: false,
1054
+ message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
1055
+ };
1056
+ }
1057
+ const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
1058
+ if (ok) {
1059
+ const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
1060
+ const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
1061
+ if (sockMatch) {
1062
+ process.env.SSH_AUTH_SOCK = sockMatch[1];
1063
+ if (pidMatch) {
1064
+ process.env.SSH_AGENT_PID = pidMatch[1];
1065
+ startedAgentPid = Number.parseInt(pidMatch[1], 10);
1066
+ }
1067
+ return {
1068
+ running: true,
1069
+ reachable: true,
1070
+ socket: sockMatch[1],
1071
+ keys: [],
1072
+ started: true,
1073
+ env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
1074
+ 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."
1075
+ };
1076
+ }
1077
+ }
1078
+ return {
1079
+ running: false,
1080
+ reachable: false,
1081
+ keys: [],
1082
+ started: false,
1083
+ message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
1084
+ };
1085
+ }
1086
+ function detectKeyType(filePath, fileName) {
1087
+ const pubPath = `${filePath}.pub`;
1088
+ if (existsSync2(pubPath)) {
1089
+ try {
1090
+ const pub = readFileSync3(pubPath, "utf8");
1091
+ if (pub.includes("ssh-ed25519")) return "ed25519";
1092
+ if (pub.includes("ssh-rsa")) return "rsa";
1093
+ if (pub.includes("ecdsa")) return "ecdsa";
1094
+ if (pub.includes("ssh-dss")) return "dsa";
1095
+ } catch {
1096
+ }
1097
+ }
1098
+ if (fileName.includes("ed25519")) return "ed25519";
1099
+ if (fileName.includes("rsa")) return "rsa";
1100
+ if (fileName.includes("ecdsa")) return "ecdsa";
1101
+ if (fileName.includes("dsa")) return "dsa";
1102
+ try {
1103
+ const content = readFileSync3(filePath, "utf8");
1104
+ if (content.includes("RSA PRIVATE KEY")) return "rsa";
1105
+ if (content.includes("EC PRIVATE KEY")) return "ecdsa";
1106
+ if (content.includes("DSA PRIVATE KEY")) return "dsa";
1107
+ if (content.includes("OPENSSH PRIVATE KEY")) {
1108
+ const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
1109
+ if (ok) {
1110
+ const match = stdout.match(/\(([^)]+)\)\s*$/);
1111
+ if (match) return match[1].toLowerCase();
1112
+ }
1113
+ }
1114
+ } catch {
1115
+ }
1116
+ return "unknown";
1117
+ }
1118
+ function listSshKeysDetailed() {
1119
+ const sshDir = join3(homedir3(), ".ssh");
1120
+ if (!existsSync2(sshDir)) return { status: "no-dir", dir: sshDir, keys: [] };
1121
+ const loadedFingerprints = /* @__PURE__ */ new Set();
1122
+ const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
1123
+ if (agentOk && !agentOut.includes("no identities")) {
1124
+ for (const line of agentOut.split("\n").filter(Boolean)) {
1125
+ const match = line.match(/(\S+:\S+)/);
1126
+ if (match) loadedFingerprints.add(match[1]);
1127
+ }
1128
+ }
1129
+ const keys = [];
1130
+ let files;
1131
+ try {
1132
+ files = readdirSync2(sshDir);
1133
+ } catch (e) {
1134
+ return { status: "unreadable", dir: sshDir, keys: [], reason: e instanceof Error ? e.message : String(e) };
1135
+ }
1136
+ for (const file of files) {
1137
+ if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
1138
+ const filePath = join3(sshDir, file);
1139
+ try {
1140
+ const stat = statSync(filePath);
1141
+ if (!stat.isFile()) continue;
1142
+ const content = readFileSync3(filePath, "utf8");
1143
+ if (!content.includes("PRIVATE KEY")) continue;
1144
+ const type = detectKeyType(filePath, file);
1145
+ let fingerprint;
1146
+ const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
1147
+ if (fpOk) {
1148
+ const match = fpOut.match(/(\S+:\S+)/);
1149
+ fingerprint = match?.[1];
1150
+ }
1151
+ const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
1152
+ keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1153
+ } catch {
1154
+ }
935
1155
  }
1156
+ return { status: "ok", dir: sshDir, keys };
936
1157
  }
937
- async function writeFile(client, remotePath, content) {
938
- const sftp = await getSftp(client);
939
- try {
940
- await new Promise((resolve, reject) => {
941
- sftp.writeFile(remotePath, content, (err) => {
942
- if (err) return reject(err);
943
- resolve();
944
- });
945
- });
946
- } finally {
947
- sftp.end();
1158
+ function loadKey(keyPath) {
1159
+ const agent = ensureAgent();
1160
+ if (!agent.reachable) {
1161
+ return { status: "error", message: agent.message };
1162
+ }
1163
+ const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
1164
+ if (!existsSync2(resolved)) {
1165
+ return { status: "error", message: `Key not found: ${resolved}` };
948
1166
  }
1167
+ const { stdout, ok } = runArgs("ssh-add", [resolved]);
1168
+ if (ok) {
1169
+ return { status: "ok", message: `Key loaded: ${resolved}` };
1170
+ }
1171
+ if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
1172
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
1173
+ }
1174
+ if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
1175
+ return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
1176
+ }
1177
+ return { status: "error", message: `Failed to load key: ${stdout}` };
949
1178
  }
950
- async function uploadFile(client, localPath, remotePath) {
951
- const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
952
- const sftp = await getSftp(client);
953
- try {
954
- await new Promise((resolve, reject) => {
955
- sftp.fastPut(resolvedLocal, remotePath, (err) => {
956
- if (err) return reject(err);
957
- resolve();
958
- });
959
- });
960
- } finally {
961
- sftp.end();
1179
+ function configLookup(host) {
1180
+ if (!isValidHostname(host)) {
1181
+ return { error: `Invalid hostname: "${host}"` };
1182
+ }
1183
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
1184
+ if (!ok) {
1185
+ return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
962
1186
  }
1187
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
1188
+ return {
1189
+ hostname: all.hostname || host,
1190
+ user: all.user || "",
1191
+ port: all.port || "22",
1192
+ identityFile: identityFiles,
1193
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
1194
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
1195
+ all,
1196
+ raw: stdout
1197
+ };
963
1198
  }
964
- async function downloadFile(client, remotePath, localPath) {
965
- const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
966
- const sftp = await getSftp(client);
967
- try {
968
- await new Promise((resolve, reject) => {
969
- sftp.fastGet(remotePath, resolvedLocal, (err) => {
970
- if (err) return reject(err);
971
- resolve();
972
- });
973
- });
974
- } finally {
975
- sftp.end();
1199
+ var KEYGEN_REMOVED = [/(?:^|\s)found: line \d+/m, / updated\.$/m];
1200
+ var KEYGEN_NO_FILE = /Cannot stat .*No such file or directory/s;
1201
+ function removeKnownHostEntry(target) {
1202
+ const { stdout, ok } = runArgs("ssh-keygen", ["-R", target]);
1203
+ if (!ok) {
1204
+ return KEYGEN_NO_FILE.test(stdout) ? { result: "absent", output: stdout } : { result: "failed", output: stdout };
976
1205
  }
1206
+ if (KEYGEN_REMOVED.some((re) => re.test(stdout))) return { result: "removed", output: stdout };
1207
+ return { result: "absent", output: stdout };
977
1208
  }
978
- async function listDir(client, remotePath) {
979
- const sftp = await getSftp(client);
980
- try {
981
- return await new Promise((resolve, reject) => {
982
- sftp.readdir(remotePath, (err, list) => {
983
- if (err) return reject(err);
984
- resolve(list.map((item) => item.filename));
985
- });
986
- });
987
- } finally {
988
- sftp.end();
1209
+ function describeRemoval(target, removal) {
1210
+ switch (removal.result) {
1211
+ case "removed":
1212
+ return `Removed old host key for ${target}`;
1213
+ case "absent":
1214
+ return `No existing host key for ${target} (nothing to remove)`;
1215
+ default:
1216
+ return `Could not remove existing host key for ${target}: ${removal.output || "ssh-keygen failed"}`;
989
1217
  }
990
1218
  }
991
- async function statFile(client, remotePath) {
992
- const sftp = await getSftp(client);
993
- try {
994
- return await new Promise((resolve, reject) => {
995
- sftp.stat(remotePath, (err, stats) => {
996
- if (err) return reject(err);
997
- resolve({
998
- size: stats.size,
999
- mode: stats.mode,
1000
- modeOctal: (stats.mode & 4095).toString(8).padStart(4, "0"),
1001
- uid: stats.uid,
1002
- gid: stats.gid,
1003
- mtime: stats.mtime,
1004
- atime: stats.atime,
1005
- isFile: stats.isFile(),
1006
- isDirectory: stats.isDirectory(),
1007
- isSymbolicLink: stats.isSymbolicLink()
1008
- });
1009
- });
1010
- });
1011
- } finally {
1012
- sftp.end();
1219
+ function fixKnownHosts(host, port = 22) {
1220
+ if (!isValidHostname(host)) {
1221
+ return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
1222
+ }
1223
+ const actions = [];
1224
+ for (const target of knownHostsTargets(host, port)) {
1225
+ actions.push(describeRemoval(target, removeKnownHostEntry(target)));
1226
+ }
1227
+ const scanHost = unbracketHost(host);
1228
+ const scanArgs = port !== 22 ? ["-H", "-p", String(port), scanHost] : ["-H", scanHost];
1229
+ const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
1230
+ if (scanOk && scanOut.trim()) {
1231
+ try {
1232
+ const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
1233
+ appendFileSync(knownHostsPath, `
1234
+ ${scanOut.trim()}
1235
+ `);
1236
+ actions.push(`Added new host key for ${host}`);
1237
+ return { status: "ok", message: `Host key refreshed for ${host}`, actions };
1238
+ } catch (e) {
1239
+ const msg = e instanceof Error ? e.message : String(e);
1240
+ return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
1241
+ }
1013
1242
  }
1243
+ return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
1014
1244
  }
1015
- async function deleteFile(client, remotePath) {
1016
- const sftp = await getSftp(client);
1017
- try {
1018
- const stats = await new Promise((resolve, reject) => {
1019
- sftp.stat(remotePath, (err, stats2) => {
1020
- if (err) return reject(err);
1021
- resolve(stats2);
1022
- });
1023
- });
1024
- await new Promise((resolve, reject) => {
1025
- const done = (err) => err ? reject(err) : resolve();
1026
- if (stats.isDirectory()) {
1027
- sftp.rmdir(remotePath, done);
1028
- } else {
1029
- sftp.unlink(remotePath, done);
1030
- }
1031
- });
1032
- } finally {
1033
- sftp.end();
1245
+ function checkGitSsh(host = "github.com", user = "git") {
1246
+ if (!isValidHostname(host)) {
1247
+ return { status: "error", message: `Invalid hostname: "${host}"` };
1248
+ }
1249
+ const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
1250
+ const text = stdout;
1251
+ if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
1252
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
1253
+ return {
1254
+ status: "ok",
1255
+ message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
1256
+ authenticatedAs: userMatch?.[1]
1257
+ };
1258
+ }
1259
+ if (text.includes("Permission denied")) {
1260
+ return {
1261
+ status: "error",
1262
+ 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.`
1263
+ };
1264
+ }
1265
+ if (text.includes("Connection refused")) {
1266
+ return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
1267
+ }
1268
+ if (text.includes("timed out") || text.includes("Connection timed out")) {
1269
+ return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
1270
+ }
1271
+ if (text.includes("Could not resolve")) {
1272
+ return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
1034
1273
  }
1274
+ return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
1035
1275
  }
1036
- async function makeDir(client, remotePath, recursive = false) {
1037
- const sftp = await getSftp(client);
1038
- try {
1039
- const mkOne = (path) => new Promise((resolve, reject) => {
1040
- sftp.mkdir(path, (err) => err ? reject(err) : resolve());
1041
- });
1042
- if (!recursive) {
1043
- await mkOne(remotePath);
1044
- return;
1045
- }
1046
- const isAbsolute = remotePath.startsWith("/");
1047
- const parts = remotePath.split("/").filter(Boolean);
1048
- let cur = isAbsolute ? "" : ".";
1049
- for (let i = 0; i < parts.length; i++) {
1050
- cur = isAbsolute ? `${cur}/${parts[i]}` : `${cur}/${parts[i]}`;
1051
- const isLeaf = i === parts.length - 1;
1052
- try {
1053
- await mkOne(cur);
1054
- } catch (e) {
1055
- if (isLeaf) throw e;
1056
- }
1057
- }
1058
- } finally {
1059
- sftp.end();
1276
+ function testConnection(host, port = 22) {
1277
+ if (!isValidHostname(host)) {
1278
+ return { status: "error", message: `Invalid hostname: "${host}"` };
1279
+ }
1280
+ const { outcome, output, elapsedMs } = probeSshConnection(host, port);
1281
+ switch (outcome) {
1282
+ case "ok":
1283
+ return { status: "ok", message: `Connected to ${host}:${port} in ${elapsedMs}ms` };
1284
+ case "permission-denied":
1285
+ return {
1286
+ status: "error",
1287
+ 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.`
1288
+ };
1289
+ case "connection-refused":
1290
+ return {
1291
+ status: "error",
1292
+ message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
1293
+ };
1294
+ case "timed-out":
1295
+ return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
1296
+ case "host-key-mismatch":
1297
+ return {
1298
+ status: "error",
1299
+ message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
1300
+ };
1301
+ case "dns-failure":
1302
+ return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
1303
+ default:
1304
+ return { status: "error", message: `Connection failed to ${host}:${port}: ${output}` };
1060
1305
  }
1061
1306
  }
1062
1307
 
1063
1308
  // src/pool.ts
1309
+ import { createHash } from "crypto";
1064
1310
  function defaultMaxPoolSize() {
1065
1311
  const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1066
1312
  if (!raw) return 100;
@@ -1083,10 +1329,26 @@ function authFingerprint(cc) {
1083
1329
  }
1084
1330
  return h.digest("hex").slice(0, 16);
1085
1331
  }
1332
+ function resolveOrDiagnose(config) {
1333
+ try {
1334
+ return resolveConfig(config);
1335
+ } catch (err) {
1336
+ throw enhanceSshError(err, config.host);
1337
+ }
1338
+ }
1086
1339
  var ConnectionPool = class {
1087
1340
  entries = /* @__PURE__ */ new Map();
1088
1341
  // Coalesces concurrent connect attempts for the same key so we don't open N
1089
1342
  // duplicate TCP connections when N tool calls fire simultaneously.
1343
+ //
1344
+ // The ResolvedConfig is stored alongside the promise because the coalesced dial
1345
+ // runs with the FIRST caller's resolved: only that one's `hostVerifier` is ever
1346
+ // invoked, so only that one's `hostKeyRejection` side channel records why the
1347
+ // server's key was turned down. Waiters must report the failure from THAT resolved
1348
+ // rather than their own (whose verifier never ran and whose rejection is still
1349
+ // null), or one caller gets "the server offered an ed25519 key but known_hosts
1350
+ // has only ecdsa" while the other N-1 get generic environment diagnostics for the
1351
+ // very same failure.
1090
1352
  pending = /* @__PURE__ */ new Map();
1091
1353
  idleTtlMs;
1092
1354
  maxPoolSize;
@@ -1102,7 +1364,7 @@ var ConnectionPool = class {
1102
1364
  this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
1103
1365
  }
1104
1366
  async acquire(config) {
1105
- const resolved = resolveConfig(config);
1367
+ const resolved = resolveOrDiagnose(config);
1106
1368
  const cc = resolved.connectConfig;
1107
1369
  const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
1108
1370
  const MAX_ACQUIRE_ATTEMPTS = 3;
@@ -1123,8 +1385,8 @@ var ConnectionPool = class {
1123
1385
  if (existing?.dead) {
1124
1386
  this.entries.delete(key);
1125
1387
  }
1126
- let pending = this.pending.get(key);
1127
- if (!pending) {
1388
+ let inflight = this.pending.get(key);
1389
+ if (!inflight) {
1128
1390
  if (this.entries.size >= this.maxPoolSize) {
1129
1391
  let evicted = false;
1130
1392
  for (const [k, e] of this.entries) {
@@ -1143,7 +1405,7 @@ var ConnectionPool = class {
1143
1405
  throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
1144
1406
  }
1145
1407
  }
1146
- pending = (async () => {
1408
+ const promise = (async () => {
1147
1409
  try {
1148
1410
  const client2 = await connectWithProxy(resolved);
1149
1411
  if (this.drained) {
@@ -1174,23 +1436,14 @@ var ConnectionPool = class {
1174
1436
  this.pending.delete(key);
1175
1437
  }
1176
1438
  })();
1177
- this.pending.set(key, pending);
1439
+ inflight = { promise, resolved };
1440
+ this.pending.set(key, inflight);
1178
1441
  }
1179
1442
  let client;
1180
1443
  try {
1181
- client = await pending;
1444
+ client = await inflight.promise;
1182
1445
  } catch (err) {
1183
- const diag = formatDiagnostics(config.host);
1184
- if (diag) {
1185
- const message = err instanceof Error ? err.message : String(err);
1186
- const enhanced = new Error(`${message}
1187
-
1188
- SSH Diagnostics:
1189
- ${diag}`);
1190
- enhanced.cause = err;
1191
- throw enhanced;
1192
- }
1193
- throw err;
1446
+ throw enhanceSshError(err, config.host, inflight.resolved);
1194
1447
  }
1195
1448
  const entry = this.entries.get(key);
1196
1449
  if (!entry || entry.dead || entry.client !== client) {
@@ -1306,6 +1559,7 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1306
1559
  });
1307
1560
  }
1308
1561
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1562
+ var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1309
1563
  async function find(client, options, timeoutMs = 3e4) {
1310
1564
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1311
1565
  throw new Error(
@@ -1317,7 +1571,8 @@ async function find(client, options, timeoutMs = 3e4) {
1317
1571
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1318
1572
  );
1319
1573
  }
1320
- const args = ["--", shellQuote(options.path)];
1574
+ const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1575
+ const args = [shellQuote(pathOperand)];
1321
1576
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1322
1577
  if (options.type) args.push("-type", options.type);
1323
1578
  if (options.name) args.push("-name", shellQuote(options.name));
@@ -1364,28 +1619,38 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1364
1619
  }
1365
1620
 
1366
1621
  // src/policy.ts
1367
- function parsePatterns(raw) {
1622
+ function parsePatterns(raw, envVarName) {
1368
1623
  if (!raw) return [];
1369
1624
  const patterns = [];
1625
+ const malformed = [];
1370
1626
  for (const p of raw.split(",")) {
1371
1627
  const cleaned = p.replace(/^\s+/, "");
1372
1628
  if (!cleaned) continue;
1373
1629
  try {
1374
1630
  patterns.push(new RegExp(cleaned));
1375
1631
  } catch {
1632
+ malformed.push(cleaned);
1376
1633
  console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1377
1634
  }
1378
1635
  }
1636
+ if (patterns.length === 0 && raw.trim() !== "") {
1637
+ 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.`;
1638
+ throw new Error(
1639
+ `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.`
1640
+ );
1641
+ }
1379
1642
  return patterns;
1380
1643
  }
1381
- function enforcePolicy(command) {
1382
- const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
1644
+ function enforcePolicy(command, context = {}) {
1645
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST, "SSH_MCP_COMMAND_WHITELIST");
1646
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST, "SSH_MCP_COMMAND_BLACKLIST");
1383
1647
  if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1384
- throw new Error(
1385
- `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
1386
- );
1648
+ let message = `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`;
1649
+ if (context.envPrefixApplied) {
1650
+ 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.";
1651
+ }
1652
+ throw new Error(message);
1387
1653
  }
1388
- const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
1389
1654
  for (const pattern of blacklist) {
1390
1655
  if (pattern.test(command)) {
1391
1656
  throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
@@ -1405,6 +1670,9 @@ var PasswordSchema = z.string().optional().describe(
1405
1670
  "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."
1406
1671
  );
1407
1672
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1673
+ var EnvSchema = z.record(z.string(), z.string()).optional().describe(
1674
+ "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."
1675
+ );
1408
1676
  var connectionParams = {
1409
1677
  host: HostSchema,
1410
1678
  port: PortSchema,
@@ -1412,6 +1680,22 @@ var connectionParams = {
1412
1680
  privateKeyPath: KeyPathSchema,
1413
1681
  password: PasswordSchema
1414
1682
  };
1683
+ 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.";
1684
+ var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
1685
+ function applyEnvPrefix(command, env) {
1686
+ if (!env || Object.keys(env).length === 0) {
1687
+ return { finalCommand: command, envPrefixApplied: false };
1688
+ }
1689
+ const prefix = Object.entries(env).map(([k, v]) => {
1690
+ if (!ENV_NAME_PATTERN.test(k)) {
1691
+ throw new Error(
1692
+ `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.`
1693
+ );
1694
+ }
1695
+ return `${k}=${shellQuote(v)}`;
1696
+ }).join(" ");
1697
+ return { finalCommand: `${prefix} ${command}`, envPrefixApplied: true };
1698
+ }
1415
1699
  function registerTools(server, pool) {
1416
1700
  const connectionPool = pool ?? new ConnectionPool();
1417
1701
  server.tool(
@@ -1420,18 +1704,12 @@ function registerTools(server, pool) {
1420
1704
  {
1421
1705
  ...connectionParams,
1422
1706
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1423
- env: z.record(z.string(), z.string()).optional().describe(
1424
- "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."
1425
- ),
1707
+ env: EnvSchema,
1426
1708
  timeout: TimeoutSchema
1427
1709
  },
1428
1710
  async ({ command, env, timeout, ...conn }) => {
1429
- let finalCommand = command;
1430
- if (env && Object.keys(env).length > 0) {
1431
- const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
1432
- finalCommand = `${prefix} ${command}`;
1433
- }
1434
- enforcePolicy(finalCommand);
1711
+ const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
1712
+ enforcePolicy(finalCommand, { envPrefixApplied });
1435
1713
  return connectionPool.withConnection(conn, async (client) => {
1436
1714
  const result = await exec(client, finalCommand, timeout || 3e4);
1437
1715
  const parts = [];
@@ -1462,7 +1740,7 @@ ${result.stderr}`);
1462
1740
  );
1463
1741
  server.tool(
1464
1742
  "ssh_write_file",
1465
- "Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
1743
+ `Write content to a file on a remote host via SFTP. Creates or overwrites the file.${POLICY_EXEMPT_NOTE}`,
1466
1744
  {
1467
1745
  ...connectionParams,
1468
1746
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
@@ -1471,13 +1749,14 @@ ${result.stderr}`);
1471
1749
  async ({ path, content, ...conn }) => {
1472
1750
  return connectionPool.withConnection(conn, async (client) => {
1473
1751
  await writeFile(client, path, content);
1474
- return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1752
+ const bytes = Buffer.byteLength(content, "utf8");
1753
+ return { content: [{ type: "text", text: `Wrote ${bytes} bytes to ${path}` }] };
1475
1754
  });
1476
1755
  }
1477
1756
  );
1478
1757
  server.tool(
1479
1758
  "ssh_upload",
1480
- "Upload a local file to a remote host via SFTP.",
1759
+ `Upload a local file to a remote host via SFTP.${POLICY_EXEMPT_NOTE}`,
1481
1760
  {
1482
1761
  ...connectionParams,
1483
1762
  localPath: z.string().describe("Path to the local file to upload"),
@@ -1515,13 +1794,16 @@ ${result.stderr}`);
1515
1794
  async ({ path, ...conn }) => {
1516
1795
  return connectionPool.withConnection(conn, async (client) => {
1517
1796
  const files = await listDir(client, path);
1797
+ if (files.length === 0) {
1798
+ return { content: [{ type: "text", text: `Directory is empty: ${path}` }] };
1799
+ }
1518
1800
  return { content: [{ type: "text", text: files.join("\n") }] };
1519
1801
  });
1520
1802
  }
1521
1803
  );
1522
1804
  server.tool(
1523
1805
  "ssh_stat",
1524
- "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.",
1806
+ "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.",
1525
1807
  {
1526
1808
  ...connectionParams,
1527
1809
  path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
@@ -1530,7 +1812,8 @@ ${result.stderr}`);
1530
1812
  return connectionPool.withConnection(conn, async (client) => {
1531
1813
  const stats = await statFile(client, path);
1532
1814
  const lines = [];
1533
- const kind = stats.isDirectory ? "directory" : stats.isSymbolicLink ? "symlink" : stats.isFile ? "file" : "other";
1815
+ const targetKind = stats.isDirectory ? "directory" : stats.isFile ? "file" : "other";
1816
+ const kind = stats.isSymbolicLink ? `symlink -> ${targetKind}` : targetKind;
1534
1817
  lines.push(`${path}: ${kind}`);
1535
1818
  lines.push(` Size: ${stats.size} bytes`);
1536
1819
  lines.push(` Mode: ${stats.modeOctal}`);
@@ -1543,10 +1826,12 @@ ${result.stderr}`);
1543
1826
  );
1544
1827
  server.tool(
1545
1828
  "ssh_mkdir",
1546
- "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.",
1829
+ `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}`,
1547
1830
  {
1548
1831
  ...connectionParams,
1549
- path: z.string().describe("Absolute path of the directory to create"),
1832
+ path: z.string().describe(
1833
+ "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."
1834
+ ),
1550
1835
  recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
1551
1836
  },
1552
1837
  async ({ path, recursive, ...conn }) => {
@@ -1558,7 +1843,7 @@ ${result.stderr}`);
1558
1843
  );
1559
1844
  server.tool(
1560
1845
  "ssh_delete",
1561
- "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.",
1846
+ `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}`,
1562
1847
  {
1563
1848
  ...connectionParams,
1564
1849
  path: AbsoluteRemotePathSchema.describe(
@@ -1623,10 +1908,33 @@ ${result.stderr}`);
1623
1908
  );
1624
1909
  server.tool(
1625
1910
  "ssh_key_list",
1626
- "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.",
1911
+ "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.",
1627
1912
  {},
1628
1913
  async () => {
1629
- const keys = listSshKeys();
1914
+ const listing = listSshKeysDetailed();
1915
+ if (listing.status === "unreadable") {
1916
+ return {
1917
+ content: [
1918
+ {
1919
+ type: "text",
1920
+ 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}.`
1921
+ }
1922
+ ],
1923
+ isError: true
1924
+ };
1925
+ }
1926
+ if (listing.status === "no-dir") {
1927
+ return {
1928
+ content: [
1929
+ {
1930
+ type: "text",
1931
+ text: `No ~/.ssh directory yet (${listing.dir} does not exist). Generate a key to create it: ssh-keygen -t ed25519 -C "your@email.com"`
1932
+ }
1933
+ ],
1934
+ isError: false
1935
+ };
1936
+ }
1937
+ const keys = listing.keys;
1630
1938
  if (keys.length === 0) {
1631
1939
  return {
1632
1940
  content: [
@@ -1634,7 +1942,8 @@ ${result.stderr}`);
1634
1942
  type: "text",
1635
1943
  text: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
1636
1944
  }
1637
- ]
1945
+ ],
1946
+ isError: false
1638
1947
  };
1639
1948
  }
1640
1949
  const lines = [`Found ${keys.length} SSH key(s):`, ""];
@@ -1645,7 +1954,7 @@ ${result.stderr}`);
1645
1954
  if (key.fingerprint) lines.push(` Fingerprint: ${key.fingerprint}`);
1646
1955
  lines.push("");
1647
1956
  }
1648
- return { content: [{ type: "text", text: lines.join("\n") }] };
1957
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
1649
1958
  }
1650
1959
  );
1651
1960
  server.tool(
@@ -1716,8 +2025,13 @@ ${result.stderr}`);
1716
2025
  "ssh_git_check",
1717
2026
  "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.",
1718
2027
  {
1719
- host: z.string().optional().describe('Git hosting hostname (default: "github.com")'),
1720
- user: z.string().optional().describe('SSH user for the git host (default: "git")')
2028
+ // .min(1), not a bare optional string: the handler defaults with `host || "github.com"`,
2029
+ // so an explicitly-empty host would silently probe github.com and report on a host the
2030
+ // caller never named -- and, because "" never reaches checkGitSsh, it would skip the
2031
+ // isValidHostname check every other host-taking tool routes its input through. Omitting
2032
+ // the field is the way to ask for the default; "" is rejected at the schema boundary.
2033
+ 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.'),
2034
+ 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.')
1721
2035
  },
1722
2036
  async ({ host, user }) => {
1723
2037
  const result = checkGitSsh(host || "github.com", user || "git");
@@ -1730,7 +2044,7 @@ ${result.stderr}`);
1730
2044
  );
1731
2045
  server.tool(
1732
2046
  "ssh_multi_exec",
1733
- "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).",
2047
+ "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).",
1734
2048
  {
1735
2049
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1736
2050
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -1738,12 +2052,14 @@ ${result.stderr}`);
1738
2052
  username: UsernameSchema,
1739
2053
  privateKeyPath: KeyPathSchema,
1740
2054
  password: PasswordSchema,
2055
+ env: EnvSchema,
1741
2056
  timeout: TimeoutSchema
1742
2057
  },
1743
- async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1744
- enforcePolicy(command);
2058
+ async ({ hosts, command, port, username, privateKeyPath, password, env, timeout }) => {
2059
+ const { finalCommand, envPrefixApplied } = applyEnvPrefix(command, env);
2060
+ enforcePolicy(finalCommand, { envPrefixApplied });
1745
2061
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1746
- const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
2062
+ const results = await multiExec(connectionPool, hostConfigs, finalCommand, timeout || 3e4);
1747
2063
  const lines = [];
1748
2064
  for (const r of results) {
1749
2065
  lines.push(`--- ${r.host} ---`);
@@ -1752,6 +2068,7 @@ ${result.stderr}`);
1752
2068
  } else {
1753
2069
  if (r.stdout) lines.push(r.stdout);
1754
2070
  if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
2071
+ if (r.signal) lines.push(`[signal: ${r.signal}]`);
1755
2072
  lines.push(`[exit code: ${r.code}]`);
1756
2073
  }
1757
2074
  lines.push("");
@@ -1803,7 +2120,7 @@ ${files.join("\n")}` }] };
1803
2120
  content: [
1804
2121
  {
1805
2122
  type: "text",
1806
- text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
2123
+ 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)."
1807
2124
  }
1808
2125
  ]
1809
2126
  };