@yawlabs/ssh-mcp 0.1.0 → 0.3.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
@@ -5,16 +5,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { z } from "zod";
6
6
 
7
7
  // src/diagnose.ts
8
- import { execSync } from "child_process";
9
- import { existsSync, readFileSync } from "fs";
8
+ import { execFileSync } from "child_process";
9
+ import { existsSync, readFileSync, readdirSync } from "fs";
10
10
  import { homedir } from "os";
11
11
  import { join } from "path";
12
- function run(cmd) {
12
+ function isValidHostname(host) {
13
+ return /^[a-zA-Z0-9._\-:[\]]+$/.test(host) && host.length <= 253;
14
+ }
15
+ function runArgs(cmd, args) {
13
16
  try {
14
- const stdout = execSync(cmd, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
17
+ const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
15
18
  return { stdout: stdout.trim(), ok: true };
16
19
  } catch (e) {
17
- return { stdout: e.stdout?.toString().trim() || e.message || "", ok: false };
20
+ const stdout = e.stdout?.toString().trim() || "";
21
+ const stderr = e.stderr?.toString().trim() || "";
22
+ const output = [stdout, stderr].filter(Boolean).join("\n") || e.message || "";
23
+ return { stdout: output, ok: false };
18
24
  }
19
25
  }
20
26
  function checkSshAgent() {
@@ -25,7 +31,7 @@ function checkSshAgent() {
25
31
  message: "SSH_AUTH_SOCK is not set. ssh-agent is not running or not exported to this shell."
26
32
  };
27
33
  }
28
- const { stdout, ok } = run("ssh-add -l");
34
+ const { stdout, ok } = runArgs("ssh-add", ["-l"]);
29
35
  if (!ok && stdout.includes("Could not open a connection")) {
30
36
  return {
31
37
  status: "error",
@@ -56,8 +62,9 @@ function checkSshKeys() {
56
62
  }
57
63
  }
58
64
  try {
59
- const { stdout } = run(`ls "${sshDir}"`);
60
- const allFiles = stdout.split("\n").filter((f) => !f.endsWith(".pub") && !["known_hosts", "config", "authorized_keys"].includes(f));
65
+ const allFiles = readdirSync(sshDir).filter(
66
+ (f) => !f.endsWith(".pub") && !["known_hosts", "known_hosts.old", "config", "authorized_keys"].includes(f)
67
+ );
61
68
  for (const f of allFiles) {
62
69
  if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
63
70
  try {
@@ -87,19 +94,35 @@ function checkKnownHosts(host) {
87
94
  message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
88
95
  };
89
96
  }
90
- const { stdout, ok } = run(`ssh-keygen -F "${host}"`);
97
+ if (!isValidHostname(host)) {
98
+ return { status: "error", message: `Invalid hostname: "${host}"` };
99
+ }
100
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
91
101
  if (!ok || !stdout.trim()) {
92
102
  return {
93
103
  status: "warning",
94
- message: `Host "${host}" is not in known_hosts. First connection will prompt for host key verification. To add it: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
104
+ message: `Host "${host}" is not in known_hosts. First connection will prompt for host key verification. To add it: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`
95
105
  };
96
106
  }
97
107
  return { status: "ok", message: `Host "${host}" found in known_hosts` };
98
108
  }
99
109
  function checkConnectivity(host, port = 22) {
100
- const { ok, stdout } = run(
101
- `ssh -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=no -p ${port} ${host} echo SSH_OK 2>&1`
102
- );
110
+ if (!isValidHostname(host)) {
111
+ return { status: "error", message: `Invalid hostname: "${host}"` };
112
+ }
113
+ const { ok, stdout } = runArgs("ssh", [
114
+ "-o",
115
+ "ConnectTimeout=5",
116
+ "-o",
117
+ "BatchMode=yes",
118
+ "-o",
119
+ "StrictHostKeyChecking=no",
120
+ "-p",
121
+ String(port),
122
+ host,
123
+ "echo",
124
+ "SSH_OK"
125
+ ]);
103
126
  if (ok && stdout.includes("SSH_OK")) {
104
127
  return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
105
128
  }
@@ -124,7 +147,7 @@ function checkConnectivity(host, port = 22) {
124
147
  if (stdout.includes("Host key verification failed")) {
125
148
  return {
126
149
  status: "error",
127
- 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`
150
+ 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`
128
151
  };
129
152
  }
130
153
  if (stdout.includes("Could not resolve hostname")) {
@@ -148,8 +171,16 @@ function checkSshConfig(host) {
148
171
  for (const line of lines) {
149
172
  const trimmed = line.trim();
150
173
  if (/^Host\s+/i.test(trimmed)) {
151
- const pattern = trimmed.replace(/^Host\s+/i, "").trim();
152
- inHostBlock = pattern === host || pattern === "*";
174
+ const patterns = trimmed.replace(/^Host\s+/i, "").trim().split(/\s+/);
175
+ inHostBlock = patterns.some((p) => {
176
+ if (p === "*") return true;
177
+ if (p === host) return true;
178
+ if (p.includes("*")) {
179
+ const regex = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
180
+ return regex.test(host);
181
+ }
182
+ return false;
183
+ });
153
184
  if (inHostBlock) hostConfig.push(trimmed);
154
185
  } else if (inHostBlock && trimmed) {
155
186
  hostConfig.push(trimmed);
@@ -169,6 +200,13 @@ ${hostConfig.join("\n")}` };
169
200
  function diagnose(host, port = 22) {
170
201
  const checks = [];
171
202
  const suggestions = [];
203
+ if (!isValidHostname(host)) {
204
+ return {
205
+ overall: "error",
206
+ checks: [{ name: "Input Validation", status: "error", message: `Invalid hostname: "${host}"` }],
207
+ suggestions: ["Provide a valid hostname (alphanumeric, dots, hyphens, colons, brackets only)"]
208
+ };
209
+ }
172
210
  const agent = checkSshAgent();
173
211
  checks.push({ name: "SSH Agent", ...agent });
174
212
  if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
@@ -180,12 +218,12 @@ function diagnose(host, port = 22) {
180
218
  checks.push({ name: "SSH Config", ...config });
181
219
  const known = checkKnownHosts(host);
182
220
  checks.push({ name: "Known Hosts", ...known });
183
- if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
221
+ if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
184
222
  const conn = checkConnectivity(host, port);
185
223
  checks.push({ name: "Connectivity", ...conn });
186
224
  if (conn.status === "error" && conn.message.includes("Host key verification")) {
187
- suggestions.push(`Remove stale host key: ssh-keygen -R ${host}`);
188
- suggestions.push(`Re-add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
225
+ suggestions.push(`Remove stale host key: ssh-keygen -R "${host}"`);
226
+ suggestions.push(`Re-add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
189
227
  }
190
228
  if (conn.status === "error" && conn.message.includes("Permission denied")) {
191
229
  suggestions.push("Check loaded keys: ssh-add -l");
@@ -195,10 +233,287 @@ function diagnose(host, port = 22) {
195
233
  return { overall, checks, suggestions };
196
234
  }
197
235
 
198
- // src/ssh.ts
199
- import { readFileSync as readFileSync2 } from "fs";
236
+ // src/env.ts
237
+ import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
200
238
  import { homedir as homedir2 } from "os";
201
239
  import { join as join2 } from "path";
240
+ function ensureAgent() {
241
+ const sock = process.env.SSH_AUTH_SOCK;
242
+ if (sock) {
243
+ const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
244
+ const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
245
+ if (ok2 || noIdentities) {
246
+ const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
247
+ return {
248
+ running: true,
249
+ reachable: true,
250
+ socket: sock,
251
+ keys,
252
+ started: false,
253
+ message: keys.length > 0 ? `ssh-agent running with ${keys.length} key(s) loaded` : "ssh-agent running but no keys loaded. Use ssh_key_load to add one."
254
+ };
255
+ }
256
+ }
257
+ const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
258
+ if (ok) {
259
+ const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
260
+ const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
261
+ if (sockMatch) {
262
+ process.env.SSH_AUTH_SOCK = sockMatch[1];
263
+ if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
264
+ return {
265
+ running: true,
266
+ reachable: true,
267
+ socket: sockMatch[1],
268
+ keys: [],
269
+ started: true,
270
+ env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
271
+ message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
272
+ };
273
+ }
274
+ }
275
+ return {
276
+ running: false,
277
+ reachable: false,
278
+ keys: [],
279
+ started: false,
280
+ message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
281
+ };
282
+ }
283
+ function detectKeyType(filePath, fileName) {
284
+ const pubPath = `${filePath}.pub`;
285
+ if (existsSync2(pubPath)) {
286
+ try {
287
+ const pub = readFileSync2(pubPath, "utf8");
288
+ if (pub.includes("ssh-ed25519")) return "ed25519";
289
+ if (pub.includes("ssh-rsa")) return "rsa";
290
+ if (pub.includes("ecdsa")) return "ecdsa";
291
+ if (pub.includes("ssh-dss")) return "dsa";
292
+ } catch {
293
+ }
294
+ }
295
+ if (fileName.includes("ed25519")) return "ed25519";
296
+ if (fileName.includes("rsa")) return "rsa";
297
+ if (fileName.includes("ecdsa")) return "ecdsa";
298
+ if (fileName.includes("dsa")) return "dsa";
299
+ try {
300
+ const content = readFileSync2(filePath, "utf8");
301
+ if (content.includes("RSA PRIVATE KEY")) return "rsa";
302
+ if (content.includes("EC PRIVATE KEY")) return "ecdsa";
303
+ if (content.includes("DSA PRIVATE KEY")) return "dsa";
304
+ } catch {
305
+ }
306
+ return "unknown";
307
+ }
308
+ function listSshKeys() {
309
+ const sshDir = join2(homedir2(), ".ssh");
310
+ if (!existsSync2(sshDir)) return [];
311
+ const loadedFingerprints = /* @__PURE__ */ new Set();
312
+ const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
313
+ if (agentOk && !agentOut.includes("no identities")) {
314
+ for (const line of agentOut.split("\n").filter(Boolean)) {
315
+ const match = line.match(/(\S+:\S+)/);
316
+ if (match) loadedFingerprints.add(match[1]);
317
+ }
318
+ }
319
+ const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
320
+ const keys = [];
321
+ let files;
322
+ try {
323
+ files = readdirSync2(sshDir);
324
+ } catch {
325
+ return [];
326
+ }
327
+ for (const file of files) {
328
+ if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
329
+ const filePath = join2(sshDir, file);
330
+ try {
331
+ const stat = statSync(filePath);
332
+ if (!stat.isFile()) continue;
333
+ const content = readFileSync2(filePath, "utf8");
334
+ if (!content.includes("PRIVATE KEY")) continue;
335
+ const type = detectKeyType(filePath, file);
336
+ let fingerprint;
337
+ const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
338
+ if (fpOk) {
339
+ const match = fpOut.match(/(\S+:\S+)/);
340
+ fingerprint = match?.[1];
341
+ }
342
+ const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
343
+ keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
344
+ } catch {
345
+ }
346
+ }
347
+ return keys;
348
+ }
349
+ function loadKey(keyPath) {
350
+ const agent = ensureAgent();
351
+ if (!agent.reachable) {
352
+ return { status: "error", message: agent.message };
353
+ }
354
+ const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
355
+ if (!existsSync2(resolved)) {
356
+ return { status: "error", message: `Key not found: ${resolved}` };
357
+ }
358
+ const { stdout, ok } = runArgs("ssh-add", [resolved]);
359
+ if (ok) {
360
+ return { status: "ok", message: `Key loaded: ${resolved}` };
361
+ }
362
+ if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
363
+ if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
364
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
365
+ }
366
+ return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
367
+ }
368
+ return { status: "error", message: `Failed to load key: ${stdout}` };
369
+ }
370
+ function configLookup(host) {
371
+ if (!isValidHostname(host)) {
372
+ return { error: `Invalid hostname: "${host}"` };
373
+ }
374
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
375
+ if (!ok) {
376
+ return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
377
+ }
378
+ const all = {};
379
+ const identityFiles = [];
380
+ for (const line of stdout.split("\n")) {
381
+ const spaceIdx = line.indexOf(" ");
382
+ if (spaceIdx > 0) {
383
+ const key = line.substring(0, spaceIdx);
384
+ const value = line.substring(spaceIdx + 1);
385
+ if (key === "identityfile") {
386
+ identityFiles.push(value);
387
+ } else {
388
+ all[key] = value;
389
+ }
390
+ }
391
+ }
392
+ return {
393
+ hostname: all.hostname || host,
394
+ user: all.user || "",
395
+ port: all.port || "22",
396
+ identityFile: identityFiles,
397
+ proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
398
+ proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
399
+ all,
400
+ raw: stdout
401
+ };
402
+ }
403
+ function fixKnownHosts(host, port = 22) {
404
+ if (!isValidHostname(host)) {
405
+ return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
406
+ }
407
+ const actions = [];
408
+ const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
409
+ if (removeOk) {
410
+ actions.push(`Removed old host key for ${host}`);
411
+ }
412
+ if (port !== 22) {
413
+ const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
414
+ if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
415
+ }
416
+ const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
417
+ const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
418
+ if (scanOk && scanOut.trim()) {
419
+ try {
420
+ const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
421
+ appendFileSync(knownHostsPath, `
422
+ ${scanOut.trim()}
423
+ `);
424
+ actions.push(`Added new host key for ${host}`);
425
+ return { status: "ok", message: `Host key refreshed for ${host}`, actions };
426
+ } catch (e) {
427
+ const msg = e instanceof Error ? e.message : String(e);
428
+ return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
429
+ }
430
+ }
431
+ return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
432
+ }
433
+ function checkGitSsh(host = "github.com", user = "git") {
434
+ if (!isValidHostname(host)) {
435
+ return { status: "error", message: `Invalid hostname: "${host}"` };
436
+ }
437
+ const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
438
+ const text = stdout;
439
+ if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
440
+ const userMatch = text.match(/Hi (\S+?)!/) || text.match(/@(\S+?)!/) || text.match(/logged in as (\S+)/);
441
+ return {
442
+ status: "ok",
443
+ message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
444
+ authenticatedAs: userMatch?.[1]
445
+ };
446
+ }
447
+ if (text.includes("Permission denied")) {
448
+ return {
449
+ status: "error",
450
+ 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.`
451
+ };
452
+ }
453
+ if (text.includes("Connection refused")) {
454
+ return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
455
+ }
456
+ if (text.includes("timed out") || text.includes("Connection timed out")) {
457
+ return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
458
+ }
459
+ if (text.includes("Could not resolve")) {
460
+ return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
461
+ }
462
+ return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
463
+ }
464
+ function testConnection(host, port = 22) {
465
+ if (!isValidHostname(host)) {
466
+ return { status: "error", message: `Invalid hostname: "${host}"` };
467
+ }
468
+ const start = Date.now();
469
+ const { ok, stdout } = runArgs("ssh", [
470
+ "-o",
471
+ "ConnectTimeout=5",
472
+ "-o",
473
+ "BatchMode=yes",
474
+ "-o",
475
+ "StrictHostKeyChecking=no",
476
+ "-p",
477
+ String(port),
478
+ host,
479
+ "echo",
480
+ "SSH_OK"
481
+ ]);
482
+ const elapsed = Date.now() - start;
483
+ if (ok && stdout.includes("SSH_OK")) {
484
+ return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
485
+ }
486
+ if (stdout.includes("Permission denied")) {
487
+ return {
488
+ status: "error",
489
+ 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.`
490
+ };
491
+ }
492
+ if (stdout.includes("Connection refused")) {
493
+ return {
494
+ status: "error",
495
+ message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
496
+ };
497
+ }
498
+ if (stdout.includes("timed out")) {
499
+ return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
500
+ }
501
+ if (stdout.includes("Host key verification failed")) {
502
+ return {
503
+ status: "error",
504
+ message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
505
+ };
506
+ }
507
+ if (stdout.includes("Could not resolve")) {
508
+ return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
509
+ }
510
+ return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
511
+ }
512
+
513
+ // src/ssh.ts
514
+ import { readFileSync as readFileSync3 } from "fs";
515
+ import { homedir as homedir3 } from "os";
516
+ import { join as join3 } from "path";
202
517
  import { Client } from "ssh2";
203
518
  function resolveConfig(config) {
204
519
  const connectConfig = {
@@ -207,18 +522,18 @@ function resolveConfig(config) {
207
522
  username: config.username || process.env.USER || process.env.USERNAME || "root"
208
523
  };
209
524
  if (config.privateKeyPath) {
210
- connectConfig.privateKey = readFileSync2(config.privateKeyPath);
525
+ connectConfig.privateKey = readFileSync3(config.privateKeyPath);
211
526
  } else if (config.password) {
212
527
  connectConfig.password = config.password;
213
528
  } else if (config.agent || process.env.SSH_AUTH_SOCK) {
214
529
  connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
215
530
  } else {
216
- const home = homedir2();
531
+ const home = homedir3();
217
532
  const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
218
533
  for (const keyName of defaultKeys) {
219
- const keyPath = join2(home, ".ssh", keyName);
534
+ const keyPath = join3(home, ".ssh", keyName);
220
535
  try {
221
- connectConfig.privateKey = readFileSync2(keyPath);
536
+ connectConfig.privateKey = readFileSync3(keyPath);
222
537
  break;
223
538
  } catch {
224
539
  }
@@ -226,90 +541,161 @@ function resolveConfig(config) {
226
541
  }
227
542
  return connectConfig;
228
543
  }
544
+ function formatDiagnostics(host) {
545
+ try {
546
+ const checks = [
547
+ { name: "SSH Agent", ...checkSshAgent() },
548
+ { name: "SSH Keys", ...checkSshKeys() },
549
+ { name: "SSH Config", ...checkSshConfig(host) },
550
+ { name: "Known Hosts", ...checkKnownHosts(host) }
551
+ ];
552
+ const parts = [];
553
+ const suggestions = [];
554
+ for (const check of checks) {
555
+ if (check.status !== "ok") {
556
+ parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
557
+ }
558
+ }
559
+ const agent = checks[0];
560
+ if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
561
+ if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
562
+ const keys = checks[1];
563
+ if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
564
+ const known = checks[3];
565
+ if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
566
+ if (suggestions.length > 0) {
567
+ parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
568
+ }
569
+ return parts.length > 0 ? parts.join("\n") : "";
570
+ } catch {
571
+ return "";
572
+ }
573
+ }
229
574
  function connect(config) {
230
575
  return new Promise((resolve, reject) => {
231
576
  const client = new Client();
232
577
  const connectConfig = resolveConfig(config);
233
- client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
578
+ client.on("ready", () => resolve(client)).on("error", (err) => {
579
+ const diag = formatDiagnostics(config.host);
580
+ if (diag) {
581
+ const enhanced = new Error(`${err.message}
582
+
583
+ SSH Diagnostics:
584
+ ${diag}`);
585
+ enhanced.cause = err;
586
+ reject(enhanced);
587
+ } else {
588
+ reject(err);
589
+ }
590
+ }).connect(connectConfig);
234
591
  });
235
592
  }
236
593
  function exec(client, command, timeoutMs = 3e4) {
237
594
  return new Promise((resolve, reject) => {
595
+ let settled = false;
596
+ const settle = (fn) => {
597
+ if (settled) return;
598
+ settled = true;
599
+ clearTimeout(timer);
600
+ fn();
601
+ };
238
602
  const timer = setTimeout(() => {
239
- reject(new Error(`Command timed out after ${timeoutMs}ms`));
603
+ settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
240
604
  }, timeoutMs);
241
605
  client.exec(command, (err, stream) => {
242
606
  if (err) {
243
- clearTimeout(timer);
244
- return reject(err);
607
+ settle(() => reject(err));
608
+ return;
245
609
  }
246
610
  let stdout = "";
247
611
  let stderr = "";
248
612
  stream.on("close", (code) => {
249
- clearTimeout(timer);
250
- resolve({ stdout, stderr, code: code ?? 0 });
613
+ settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
251
614
  }).on("data", (data) => {
252
615
  stdout += data.toString();
253
- }).stderr.on("data", (data) => {
616
+ }).on("error", (err2) => {
617
+ settle(() => reject(err2));
618
+ });
619
+ stream.stderr.on("data", (data) => {
254
620
  stderr += data.toString();
621
+ }).on("error", (err2) => {
622
+ settle(() => reject(err2));
255
623
  });
256
624
  });
257
625
  });
258
626
  }
259
- function readFile(client, remotePath) {
627
+ function getSftp(client) {
260
628
  return new Promise((resolve, reject) => {
261
629
  client.sftp((err, sftp) => {
262
630
  if (err) return reject(err);
263
- sftp.readFile(remotePath, (err2, data) => {
264
- if (err2) return reject(err2);
631
+ resolve(sftp);
632
+ });
633
+ });
634
+ }
635
+ async function readFile(client, remotePath) {
636
+ const sftp = await getSftp(client);
637
+ try {
638
+ return await new Promise((resolve, reject) => {
639
+ sftp.readFile(remotePath, (err, data) => {
640
+ if (err) return reject(err);
265
641
  resolve(data.toString("utf8"));
266
642
  });
267
643
  });
268
- });
644
+ } finally {
645
+ sftp.end();
646
+ }
269
647
  }
270
- function writeFile(client, remotePath, content) {
271
- return new Promise((resolve, reject) => {
272
- client.sftp((err, sftp) => {
273
- if (err) return reject(err);
274
- sftp.writeFile(remotePath, content, (err2) => {
275
- if (err2) return reject(err2);
648
+ async function writeFile(client, remotePath, content) {
649
+ const sftp = await getSftp(client);
650
+ try {
651
+ await new Promise((resolve, reject) => {
652
+ sftp.writeFile(remotePath, content, (err) => {
653
+ if (err) return reject(err);
276
654
  resolve();
277
655
  });
278
656
  });
279
- });
657
+ } finally {
658
+ sftp.end();
659
+ }
280
660
  }
281
- function uploadFile(client, localPath, remotePath) {
282
- return new Promise((resolve, reject) => {
283
- client.sftp((err, sftp) => {
284
- if (err) return reject(err);
285
- sftp.fastPut(localPath, remotePath, (err2) => {
286
- if (err2) return reject(err2);
661
+ async function uploadFile(client, localPath, remotePath) {
662
+ const sftp = await getSftp(client);
663
+ try {
664
+ await new Promise((resolve, reject) => {
665
+ sftp.fastPut(localPath, remotePath, (err) => {
666
+ if (err) return reject(err);
287
667
  resolve();
288
668
  });
289
669
  });
290
- });
670
+ } finally {
671
+ sftp.end();
672
+ }
291
673
  }
292
- function downloadFile(client, remotePath, localPath) {
293
- return new Promise((resolve, reject) => {
294
- client.sftp((err, sftp) => {
295
- if (err) return reject(err);
296
- sftp.fastGet(remotePath, localPath, (err2) => {
297
- if (err2) return reject(err2);
674
+ async function downloadFile(client, remotePath, localPath) {
675
+ const sftp = await getSftp(client);
676
+ try {
677
+ await new Promise((resolve, reject) => {
678
+ sftp.fastGet(remotePath, localPath, (err) => {
679
+ if (err) return reject(err);
298
680
  resolve();
299
681
  });
300
682
  });
301
- });
683
+ } finally {
684
+ sftp.end();
685
+ }
302
686
  }
303
- function listDir(client, remotePath) {
304
- return new Promise((resolve, reject) => {
305
- client.sftp((err, sftp) => {
306
- if (err) return reject(err);
307
- sftp.readdir(remotePath, (err2, list) => {
308
- if (err2) return reject(err2);
687
+ async function listDir(client, remotePath) {
688
+ const sftp = await getSftp(client);
689
+ try {
690
+ return await new Promise((resolve, reject) => {
691
+ sftp.readdir(remotePath, (err, list) => {
692
+ if (err) return reject(err);
309
693
  resolve(list.map((item) => item.filename));
310
694
  });
311
695
  });
312
- });
696
+ } finally {
697
+ sftp.end();
698
+ }
313
699
  }
314
700
 
315
701
  // src/tools.ts
@@ -466,31 +852,166 @@ ${result.stderr}`);
466
852
  return { content: [{ type: "text", text: lines.join("\n") }] };
467
853
  }
468
854
  );
855
+ server.tool(
856
+ "ssh_agent_ensure",
857
+ "Ensure ssh-agent is running and reachable. Starts a new agent if needed and sets environment variables so subsequent SSH operations work. Use this FIRST when SSH operations fail with agent-related errors.",
858
+ {},
859
+ async () => {
860
+ const result = ensureAgent();
861
+ const lines = [];
862
+ lines.push(result.message);
863
+ if (result.socket) lines.push(`Socket: ${result.socket}`);
864
+ if (result.keys.length > 0) {
865
+ lines.push("Loaded keys:");
866
+ for (const k of result.keys) lines.push(` ${k}`);
867
+ }
868
+ if (result.env) {
869
+ lines.push("Environment variables set in this session:");
870
+ if (result.env.SSH_AUTH_SOCK) lines.push(` SSH_AUTH_SOCK=${result.env.SSH_AUTH_SOCK}`);
871
+ if (result.env.SSH_AGENT_PID) lines.push(` SSH_AGENT_PID=${result.env.SSH_AGENT_PID}`);
872
+ }
873
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: !result.reachable };
874
+ }
875
+ );
876
+ server.tool(
877
+ "ssh_key_list",
878
+ "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.",
879
+ {},
880
+ async () => {
881
+ const keys = listSshKeys();
882
+ if (keys.length === 0) {
883
+ return {
884
+ content: [
885
+ {
886
+ type: "text",
887
+ text: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
888
+ }
889
+ ]
890
+ };
891
+ }
892
+ const lines = [`Found ${keys.length} SSH key(s):`, ""];
893
+ for (const key of keys) {
894
+ const status = key.loadedInAgent ? "LOADED" : "not loaded";
895
+ lines.push(`${key.name} (${key.type}) [${status}]`);
896
+ lines.push(` Path: ${key.path}`);
897
+ if (key.fingerprint) lines.push(` Fingerprint: ${key.fingerprint}`);
898
+ lines.push("");
899
+ }
900
+ return { content: [{ type: "text", text: lines.join("\n") }] };
901
+ }
902
+ );
903
+ server.tool(
904
+ "ssh_key_load",
905
+ "Load an SSH private key into the running agent. Ensures the agent is running first. Use this after ssh_key_list shows a key that is not loaded.",
906
+ {
907
+ keyPath: z.string().describe("Path to the SSH private key to load (e.g. ~/.ssh/id_ed25519)")
908
+ },
909
+ async ({ keyPath }) => {
910
+ const result = loadKey(keyPath);
911
+ return { content: [{ type: "text", text: result.message }], isError: result.status === "error" };
912
+ }
913
+ );
914
+ server.tool(
915
+ "ssh_config_lookup",
916
+ "Resolve the effective SSH configuration for a host. Shows hostname, user, port, identity files, proxy settings, and all other options from ~/.ssh/config. Use this to understand how SSH will connect to a host.",
917
+ {
918
+ host: HostSchema
919
+ },
920
+ async ({ host }) => {
921
+ const result = configLookup(host);
922
+ if ("error" in result) {
923
+ return { content: [{ type: "text", text: result.error }], isError: true };
924
+ }
925
+ const lines = [`SSH config for "${host}":`, ""];
926
+ lines.push(` Hostname: ${result.hostname}`);
927
+ lines.push(` User: ${result.user}`);
928
+ lines.push(` Port: ${result.port}`);
929
+ if (result.identityFile.length > 0) {
930
+ lines.push(` Identity files: ${result.identityFile.join(", ")}`);
931
+ }
932
+ if (result.proxyJump) lines.push(` ProxyJump: ${result.proxyJump}`);
933
+ if (result.proxyCommand) lines.push(` ProxyCommand: ${result.proxyCommand}`);
934
+ return { content: [{ type: "text", text: lines.join("\n") }] };
935
+ }
936
+ );
937
+ server.tool(
938
+ "ssh_known_hosts_fix",
939
+ "Remove a stale host key from known_hosts and re-scan the host to add the current key. Use this when you see 'Host key verification failed' errors, typically after a server has been recreated or reprovisioned.",
940
+ {
941
+ host: HostSchema,
942
+ port: PortSchema
943
+ },
944
+ async ({ host, port }) => {
945
+ const result = fixKnownHosts(host, port || 22);
946
+ const lines = [result.message];
947
+ if (result.actions.length > 0) {
948
+ lines.push("");
949
+ lines.push("Actions taken:");
950
+ for (const a of result.actions) lines.push(` - ${a}`);
951
+ }
952
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
953
+ }
954
+ );
955
+ server.tool(
956
+ "ssh_test",
957
+ "Quick connectivity test to an SSH host. Reports success/failure with timing and actionable error details. Lighter and faster than ssh_diagnose \u2014 use this for a quick check before running operations.",
958
+ {
959
+ host: HostSchema,
960
+ port: PortSchema
961
+ },
962
+ async ({ host, port }) => {
963
+ const result = testConnection(host, port || 22);
964
+ return { content: [{ type: "text", text: result.message }], isError: result.status === "error" };
965
+ }
966
+ );
967
+ server.tool(
968
+ "ssh_git_check",
969
+ "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.",
970
+ {
971
+ host: z.string().optional().describe('Git hosting hostname (default: "github.com")'),
972
+ user: z.string().optional().describe('SSH user for the git host (default: "git")')
973
+ },
974
+ async ({ host, user }) => {
975
+ const result = checkGitSsh(host || "github.com", user || "git");
976
+ const lines = [result.message];
977
+ if (result.authenticatedAs) {
978
+ lines.push(`Authenticated as: ${result.authenticatedAs}`);
979
+ }
980
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
981
+ }
982
+ );
469
983
  }
470
984
 
471
985
  // src/server.ts
472
986
  function createServer() {
473
987
  const server = new McpServer({
474
988
  name: "ssh-mcp",
475
- version: "0.1.0"
989
+ version: "0.3.0"
476
990
  });
477
991
  registerTools(server);
478
992
  return server;
479
993
  }
480
994
  export {
481
995
  checkConnectivity,
996
+ checkGitSsh,
482
997
  checkKnownHosts,
483
998
  checkSshAgent,
484
999
  checkSshConfig,
485
1000
  checkSshKeys,
1001
+ configLookup,
486
1002
  connect,
487
1003
  createServer,
488
1004
  diagnose,
489
1005
  downloadFile,
1006
+ ensureAgent,
490
1007
  exec,
1008
+ fixKnownHosts,
491
1009
  listDir,
1010
+ listSshKeys,
1011
+ loadKey,
492
1012
  readFile,
493
1013
  registerTools,
1014
+ testConnection,
494
1015
  uploadFile,
495
1016
  writeFile
496
1017
  };