@yawlabs/ssh-mcp 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,15 +3,14 @@
3
3
  // src/index.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
- // src/ssh.ts
7
- import { readFileSync as readFileSync2 } from "fs";
6
+ // src/env.ts
7
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
8
8
  import { homedir as homedir2 } from "os";
9
9
  import { join as join2 } from "path";
10
- import { Client } from "ssh2";
11
10
 
12
11
  // src/diagnose.ts
13
12
  import { execFileSync } from "child_process";
14
- import { existsSync, readFileSync, readdirSync } from "fs";
13
+ import { existsSync, readdirSync, readFileSync } from "fs";
15
14
  import { homedir } from "os";
16
15
  import { join } from "path";
17
16
  function isValidHostname(host) {
@@ -19,7 +18,7 @@ function isValidHostname(host) {
19
18
  if (host.startsWith("[")) {
20
19
  return /^\[[0-9a-fA-F:]+\]$/.test(host);
21
20
  }
22
- return /^[a-zA-Z0-9._\-]+$/.test(host);
21
+ return /^[a-zA-Z0-9._-]+$/.test(host);
23
22
  }
24
23
  function runArgs(cmd, args) {
25
24
  try {
@@ -260,165 +259,462 @@ function diagnose(host, port = 22) {
260
259
  return { overall, checks, suggestions };
261
260
  }
262
261
 
263
- // src/ssh.ts
264
- function resolveFromSshConfig(host) {
262
+ // src/env.ts
263
+ function probeAgent(socket, agentLabel) {
264
+ const { stdout, ok } = runArgs("ssh-add", ["-l"]);
265
+ const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
266
+ if (!ok && !noIdentities) return null;
267
+ const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
268
+ return {
269
+ running: true,
270
+ reachable: true,
271
+ socket,
272
+ keys,
273
+ started: false,
274
+ 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.`
275
+ };
276
+ }
277
+ var startedAgentPid = null;
278
+ function killStartedAgent() {
279
+ if (startedAgentPid === null) return;
265
280
  try {
266
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
267
- if (!ok) return null;
268
- const config = {};
269
- const identityFiles = [];
270
- for (const line of stdout.split("\n")) {
271
- const spaceIdx = line.indexOf(" ");
272
- if (spaceIdx > 0) {
273
- const key = line.substring(0, spaceIdx);
274
- const value = line.substring(spaceIdx + 1);
275
- if (key === "identityfile") {
276
- identityFiles.push(value);
277
- } else {
278
- config[key] = value;
279
- }
280
- }
281
- }
282
- return {
283
- hostname: config.hostname || host,
284
- user: config.user || "",
285
- port: config.port || "22",
286
- identityFiles,
287
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
288
- };
281
+ process.kill(startedAgentPid);
289
282
  } catch {
290
- return null;
291
283
  }
284
+ startedAgentPid = null;
292
285
  }
293
- function readKnownHostsKeys(host, port) {
294
- if (!isValidHostname(host)) return [];
295
- const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
296
- const keys = [];
297
- for (const target of targets) {
298
- const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
299
- if (!ok || !stdout.trim()) continue;
300
- for (const line of stdout.split("\n")) {
301
- const trimmed = line.trim();
302
- if (!trimmed || trimmed.startsWith("#")) continue;
303
- const parts = trimmed.split(/\s+/);
304
- if (parts.length < 3) continue;
305
- try {
306
- keys.push(Buffer.from(parts[2], "base64"));
307
- } catch {
286
+ function ensureAgent() {
287
+ const sock = process.env.SSH_AUTH_SOCK;
288
+ if (sock) {
289
+ const result = probeAgent(sock, "ssh-agent");
290
+ if (result) return result;
291
+ }
292
+ if (!sock && process.platform === "win32") {
293
+ const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
294
+ if (result) return result;
295
+ }
296
+ const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
297
+ if (ok) {
298
+ const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
299
+ const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
300
+ if (sockMatch) {
301
+ process.env.SSH_AUTH_SOCK = sockMatch[1];
302
+ if (pidMatch) {
303
+ process.env.SSH_AGENT_PID = pidMatch[1];
304
+ startedAgentPid = Number.parseInt(pidMatch[1], 10);
308
305
  }
306
+ return {
307
+ running: true,
308
+ reachable: true,
309
+ socket: sockMatch[1],
310
+ keys: [],
311
+ started: true,
312
+ env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
313
+ 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."
314
+ };
309
315
  }
310
316
  }
311
- return keys;
312
- }
313
- function buildHostVerifier(hosts, port) {
314
- const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
315
- return (key) => {
316
- const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
317
- if (known.length === 0) {
318
- return !strict;
319
- }
320
- return known.some((k) => k.equals(key));
317
+ return {
318
+ running: false,
319
+ reachable: false,
320
+ keys: [],
321
+ started: false,
322
+ message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
321
323
  };
322
324
  }
323
- function resolveConfig(config) {
324
- const sshConfig = resolveFromSshConfig(config.host);
325
- const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
326
- const verifierHosts = [config.host];
327
- if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
328
- verifierHosts.push(sshConfig.hostname);
329
- }
330
- const connectConfig = {
331
- host: sshConfig?.hostname || config.host,
332
- port,
333
- username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
334
- keepaliveInterval: 15e3,
335
- keepaliveCountMax: 3,
336
- hostVerifier: buildHostVerifier(verifierHosts, port)
337
- };
338
- if (config.privateKeyPath) {
339
- connectConfig.privateKey = readFileSync2(config.privateKeyPath);
340
- } else if (config.password) {
341
- connectConfig.password = config.password;
342
- } else {
343
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
344
- if (agentSock) {
345
- connectConfig.agent = agentSock;
346
- } else {
347
- const home = homedir2();
348
- 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")];
349
- for (const keyPath of keyPaths) {
350
- try {
351
- connectConfig.privateKey = readFileSync2(keyPath);
352
- break;
353
- } catch {
354
- }
355
- }
325
+ function detectKeyType(filePath, fileName) {
326
+ const pubPath = `${filePath}.pub`;
327
+ if (existsSync2(pubPath)) {
328
+ try {
329
+ const pub = readFileSync2(pubPath, "utf8");
330
+ if (pub.includes("ssh-ed25519")) return "ed25519";
331
+ if (pub.includes("ssh-rsa")) return "rsa";
332
+ if (pub.includes("ecdsa")) return "ecdsa";
333
+ if (pub.includes("ssh-dss")) return "dsa";
334
+ } catch {
356
335
  }
357
336
  }
358
- return { connectConfig, proxyJump: sshConfig?.proxyJump };
359
- }
360
- var DIAG_CACHE_TTL_MS = 2e3;
361
- var diagAgentCache = null;
362
- var diagKeysCache = null;
363
- function cachedAgentCheck() {
364
- const now = Date.now();
365
- if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
366
- return diagAgentCache.result;
337
+ if (fileName.includes("ed25519")) return "ed25519";
338
+ if (fileName.includes("rsa")) return "rsa";
339
+ if (fileName.includes("ecdsa")) return "ecdsa";
340
+ if (fileName.includes("dsa")) return "dsa";
341
+ try {
342
+ const content = readFileSync2(filePath, "utf8");
343
+ if (content.includes("RSA PRIVATE KEY")) return "rsa";
344
+ if (content.includes("EC PRIVATE KEY")) return "ecdsa";
345
+ if (content.includes("DSA PRIVATE KEY")) return "dsa";
346
+ } catch {
367
347
  }
368
- const result = checkSshAgent();
369
- diagAgentCache = { at: now, result };
370
- return result;
348
+ return "unknown";
371
349
  }
372
- function cachedKeysCheck() {
373
- const now = Date.now();
374
- if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
375
- return diagKeysCache.result;
350
+ function listSshKeys() {
351
+ const sshDir = join2(homedir2(), ".ssh");
352
+ if (!existsSync2(sshDir)) return [];
353
+ const loadedFingerprints = /* @__PURE__ */ new Set();
354
+ const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
355
+ if (agentOk && !agentOut.includes("no identities")) {
356
+ for (const line of agentOut.split("\n").filter(Boolean)) {
357
+ const match = line.match(/(\S+:\S+)/);
358
+ if (match) loadedFingerprints.add(match[1]);
359
+ }
376
360
  }
377
- const result = checkSshKeys();
378
- diagKeysCache = { at: now, result };
379
- return result;
380
- }
381
- function formatDiagnostics(host) {
361
+ const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
362
+ const keys = [];
363
+ let files;
382
364
  try {
383
- const checks = [
384
- { name: "SSH Agent", ...cachedAgentCheck() },
385
- { name: "SSH Keys", ...cachedKeysCheck() },
386
- { name: "SSH Config", ...checkSshConfig(host) },
387
- { name: "Known Hosts", ...checkKnownHosts(host) }
388
- ];
389
- const parts = [];
390
- const suggestions = [];
391
- for (const check of checks) {
392
- if (check.status !== "ok") {
393
- parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
365
+ files = readdirSync2(sshDir);
366
+ } catch {
367
+ return [];
368
+ }
369
+ for (const file of files) {
370
+ if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
371
+ const filePath = join2(sshDir, file);
372
+ try {
373
+ const stat = statSync(filePath);
374
+ if (!stat.isFile()) continue;
375
+ const content = readFileSync2(filePath, "utf8");
376
+ if (!content.includes("PRIVATE KEY")) continue;
377
+ const type = detectKeyType(filePath, file);
378
+ let fingerprint;
379
+ const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
380
+ if (fpOk) {
381
+ const match = fpOut.match(/(\S+:\S+)/);
382
+ fingerprint = match?.[1];
394
383
  }
384
+ const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
385
+ keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
386
+ } catch {
395
387
  }
396
- const agent = checks[0];
397
- if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
398
- if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
399
- const keys = checks[1];
400
- if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
401
- const known = checks[3];
402
- if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
403
- if (suggestions.length > 0) {
404
- parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
405
- }
406
- return parts.length > 0 ? parts.join("\n") : "";
407
- } catch {
408
- return "";
409
388
  }
389
+ return keys;
410
390
  }
411
- function connectRaw(connectConfig) {
412
- return new Promise((resolve, reject) => {
413
- const client = new Client();
414
- client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
415
- });
391
+ function loadKey(keyPath) {
392
+ const agent = ensureAgent();
393
+ if (!agent.reachable) {
394
+ return { status: "error", message: agent.message };
395
+ }
396
+ const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
397
+ if (!existsSync2(resolved)) {
398
+ return { status: "error", message: `Key not found: ${resolved}` };
399
+ }
400
+ const { stdout, ok } = runArgs("ssh-add", [resolved]);
401
+ if (ok) {
402
+ return { status: "ok", message: `Key loaded: ${resolved}` };
403
+ }
404
+ if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
405
+ if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
406
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
407
+ }
408
+ return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
409
+ }
410
+ return { status: "error", message: `Failed to load key: ${stdout}` };
416
411
  }
417
- async function connectWithProxy(resolved) {
418
- if (!resolved.proxyJump) {
419
- return connectRaw(resolved.connectConfig);
412
+ function configLookup(host) {
413
+ if (!isValidHostname(host)) {
414
+ return { error: `Invalid hostname: "${host}"` };
420
415
  }
421
- const jumpResolved = resolveConfig({ host: resolved.proxyJump });
416
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
417
+ if (!ok) {
418
+ return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
419
+ }
420
+ const all = {};
421
+ const identityFiles = [];
422
+ for (const line of stdout.split("\n")) {
423
+ const spaceIdx = line.indexOf(" ");
424
+ if (spaceIdx > 0) {
425
+ const key = line.substring(0, spaceIdx);
426
+ const value = line.substring(spaceIdx + 1);
427
+ if (key === "identityfile") {
428
+ identityFiles.push(value);
429
+ } else {
430
+ all[key] = value;
431
+ }
432
+ }
433
+ }
434
+ return {
435
+ hostname: all.hostname || host,
436
+ user: all.user || "",
437
+ port: all.port || "22",
438
+ identityFile: identityFiles,
439
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
440
+ proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
441
+ all,
442
+ raw: stdout
443
+ };
444
+ }
445
+ function fixKnownHosts(host, port = 22) {
446
+ if (!isValidHostname(host)) {
447
+ return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
448
+ }
449
+ const actions = [];
450
+ const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
451
+ if (removeOk) {
452
+ actions.push(`Removed old host key for ${host}`);
453
+ }
454
+ if (port !== 22) {
455
+ const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
456
+ if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
457
+ }
458
+ const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
459
+ const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
460
+ if (scanOk && scanOut.trim()) {
461
+ try {
462
+ const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
463
+ appendFileSync(knownHostsPath, `
464
+ ${scanOut.trim()}
465
+ `);
466
+ actions.push(`Added new host key for ${host}`);
467
+ return { status: "ok", message: `Host key refreshed for ${host}`, actions };
468
+ } catch (e) {
469
+ const msg = e instanceof Error ? e.message : String(e);
470
+ return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
471
+ }
472
+ }
473
+ return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
474
+ }
475
+ function checkGitSsh(host = "github.com", user = "git") {
476
+ if (!isValidHostname(host)) {
477
+ return { status: "error", message: `Invalid hostname: "${host}"` };
478
+ }
479
+ const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
480
+ const text = stdout;
481
+ if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
482
+ const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
483
+ return {
484
+ status: "ok",
485
+ message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
486
+ authenticatedAs: userMatch?.[1]
487
+ };
488
+ }
489
+ if (text.includes("Permission denied")) {
490
+ return {
491
+ status: "error",
492
+ 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.`
493
+ };
494
+ }
495
+ if (text.includes("Connection refused")) {
496
+ return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
497
+ }
498
+ if (text.includes("timed out") || text.includes("Connection timed out")) {
499
+ return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
500
+ }
501
+ if (text.includes("Could not resolve")) {
502
+ return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
503
+ }
504
+ return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
505
+ }
506
+ function testConnection(host, port = 22) {
507
+ if (!isValidHostname(host)) {
508
+ return { status: "error", message: `Invalid hostname: "${host}"` };
509
+ }
510
+ const start = Date.now();
511
+ const { ok, stdout } = runArgs("ssh", [
512
+ "-o",
513
+ "ConnectTimeout=5",
514
+ "-o",
515
+ "BatchMode=yes",
516
+ "-o",
517
+ "StrictHostKeyChecking=no",
518
+ "-p",
519
+ String(port),
520
+ host,
521
+ "echo",
522
+ "SSH_OK"
523
+ ]);
524
+ const elapsed = Date.now() - start;
525
+ if (ok && stdout.includes("SSH_OK")) {
526
+ return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
527
+ }
528
+ if (stdout.includes("Permission denied")) {
529
+ return {
530
+ status: "error",
531
+ 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.`
532
+ };
533
+ }
534
+ if (stdout.includes("Connection refused")) {
535
+ return {
536
+ status: "error",
537
+ message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
538
+ };
539
+ }
540
+ if (stdout.includes("timed out")) {
541
+ return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
542
+ }
543
+ if (stdout.includes("Host key verification failed")) {
544
+ return {
545
+ status: "error",
546
+ message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
547
+ };
548
+ }
549
+ if (stdout.includes("Could not resolve")) {
550
+ return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
551
+ }
552
+ return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
553
+ }
554
+
555
+ // src/ssh.ts
556
+ import { readFileSync as readFileSync3 } from "fs";
557
+ import { homedir as homedir3 } from "os";
558
+ import { join as join3 } from "path";
559
+ import { Client } from "ssh2";
560
+ function resolveFromSshConfig(host) {
561
+ try {
562
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
563
+ if (!ok) return null;
564
+ const config = {};
565
+ const identityFiles = [];
566
+ for (const line of stdout.split("\n")) {
567
+ const spaceIdx = line.indexOf(" ");
568
+ if (spaceIdx > 0) {
569
+ const key = line.substring(0, spaceIdx);
570
+ const value = line.substring(spaceIdx + 1);
571
+ if (key === "identityfile") {
572
+ identityFiles.push(value);
573
+ } else {
574
+ config[key] = value;
575
+ }
576
+ }
577
+ }
578
+ return {
579
+ hostname: config.hostname || host,
580
+ user: config.user || "",
581
+ port: config.port || "22",
582
+ identityFiles,
583
+ proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
584
+ };
585
+ } catch {
586
+ return null;
587
+ }
588
+ }
589
+ function readKnownHostsKeys(host, port) {
590
+ if (!isValidHostname(host)) return [];
591
+ const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
592
+ const keys = [];
593
+ for (const target of targets) {
594
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
595
+ if (!ok || !stdout.trim()) continue;
596
+ for (const line of stdout.split("\n")) {
597
+ const trimmed = line.trim();
598
+ if (!trimmed || trimmed.startsWith("#")) continue;
599
+ const parts = trimmed.split(/\s+/);
600
+ if (parts.length < 3) continue;
601
+ try {
602
+ keys.push(Buffer.from(parts[2], "base64"));
603
+ } catch {
604
+ }
605
+ }
606
+ }
607
+ return keys;
608
+ }
609
+ function buildHostVerifier(hosts, port) {
610
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
611
+ return (key) => {
612
+ const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
613
+ if (known.length === 0) {
614
+ return !strict;
615
+ }
616
+ return known.some((k) => k.equals(key));
617
+ };
618
+ }
619
+ function resolveConfig(config) {
620
+ const sshConfig = resolveFromSshConfig(config.host);
621
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
622
+ const verifierHosts = [config.host];
623
+ if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
624
+ verifierHosts.push(sshConfig.hostname);
625
+ }
626
+ const connectConfig = {
627
+ host: sshConfig?.hostname || config.host,
628
+ port,
629
+ username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
630
+ keepaliveInterval: 15e3,
631
+ keepaliveCountMax: 3,
632
+ hostVerifier: buildHostVerifier(verifierHosts, port)
633
+ };
634
+ if (config.privateKeyPath) {
635
+ connectConfig.privateKey = readFileSync3(config.privateKeyPath);
636
+ } else if (config.password) {
637
+ connectConfig.password = config.password;
638
+ } else {
639
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
640
+ if (agentSock) {
641
+ connectConfig.agent = agentSock;
642
+ } else {
643
+ const home = homedir3();
644
+ 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")];
645
+ for (const keyPath of keyPaths) {
646
+ try {
647
+ connectConfig.privateKey = readFileSync3(keyPath);
648
+ break;
649
+ } catch {
650
+ }
651
+ }
652
+ }
653
+ }
654
+ return { connectConfig, proxyJump: sshConfig?.proxyJump };
655
+ }
656
+ var DIAG_CACHE_TTL_MS = 2e3;
657
+ var diagAgentCache = null;
658
+ var diagKeysCache = null;
659
+ function cachedAgentCheck() {
660
+ const now = Date.now();
661
+ if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
662
+ return diagAgentCache.result;
663
+ }
664
+ const result = checkSshAgent();
665
+ diagAgentCache = { at: now, result };
666
+ return result;
667
+ }
668
+ function cachedKeysCheck() {
669
+ const now = Date.now();
670
+ if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
671
+ return diagKeysCache.result;
672
+ }
673
+ const result = checkSshKeys();
674
+ diagKeysCache = { at: now, result };
675
+ return result;
676
+ }
677
+ function formatDiagnostics(host) {
678
+ try {
679
+ const checks = [
680
+ { name: "SSH Agent", ...cachedAgentCheck() },
681
+ { name: "SSH Keys", ...cachedKeysCheck() },
682
+ { name: "SSH Config", ...checkSshConfig(host) },
683
+ { name: "Known Hosts", ...checkKnownHosts(host) }
684
+ ];
685
+ const parts = [];
686
+ const suggestions = [];
687
+ for (const check of checks) {
688
+ if (check.status !== "ok") {
689
+ parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
690
+ }
691
+ }
692
+ const agent = checks[0];
693
+ if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
694
+ if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
695
+ const keys = checks[1];
696
+ if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
697
+ const known = checks[3];
698
+ if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
699
+ if (suggestions.length > 0) {
700
+ parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
701
+ }
702
+ return parts.length > 0 ? parts.join("\n") : "";
703
+ } catch {
704
+ return "";
705
+ }
706
+ }
707
+ function connectRaw(connectConfig) {
708
+ return new Promise((resolve, reject) => {
709
+ const client = new Client();
710
+ client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
711
+ });
712
+ }
713
+ async function connectWithProxy(resolved) {
714
+ if (!resolved.proxyJump) {
715
+ return connectRaw(resolved.connectConfig);
716
+ }
717
+ const jumpResolved = resolveConfig({ host: resolved.proxyJump });
422
718
  const jumpClient = await connectWithProxy(jumpResolved);
423
719
  const targetHost = resolved.connectConfig.host;
424
720
  const targetPort = resolved.connectConfig.port;
@@ -445,6 +741,7 @@ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
445
741
  function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
446
742
  return new Promise((resolve, reject) => {
447
743
  let settled = false;
744
+ let activeStream = null;
448
745
  const settle = (fn) => {
449
746
  if (settled) return;
450
747
  settled = true;
@@ -452,6 +749,16 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
452
749
  fn();
453
750
  };
454
751
  const timer = setTimeout(() => {
752
+ if (activeStream) {
753
+ try {
754
+ activeStream.signal("TERM");
755
+ } catch {
756
+ }
757
+ try {
758
+ activeStream.close();
759
+ } catch {
760
+ }
761
+ }
455
762
  settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
456
763
  }, timeoutMs);
457
764
  client.exec(command, (err, stream) => {
@@ -459,6 +766,7 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
459
766
  settle(() => reject(err));
460
767
  return;
461
768
  }
769
+ activeStream = stream;
462
770
  const stdoutChunks = [];
463
771
  const stderrChunks = [];
464
772
  let stdoutBytes = 0;
@@ -698,365 +1006,84 @@ ${diag}`);
698
1006
  continue;
699
1007
  }
700
1008
  entry.refCount++;
701
- if (entry.idleTimer) {
702
- clearTimeout(entry.idleTimer);
703
- entry.idleTimer = null;
704
- }
705
- return client;
706
- }
707
- throw new Error(
708
- `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
709
- );
710
- }
711
- release(client) {
712
- for (const entry of this.entries.values()) {
713
- if (entry.client === client) {
714
- entry.refCount = Math.max(0, entry.refCount - 1);
715
- if (entry.refCount === 0 && !entry.dead) {
716
- entry.idleTimer = setTimeout(() => {
717
- try {
718
- entry.client.end();
719
- } catch {
720
- }
721
- this.entries.delete(entry.key);
722
- }, this.idleTtlMs);
723
- entry.idleTimer.unref();
724
- }
725
- return;
726
- }
727
- }
728
- try {
729
- client.end();
730
- } catch {
731
- }
732
- }
733
- async withConnection(config, fn) {
734
- const client = await this.acquire(config);
735
- try {
736
- return await fn(client);
737
- } finally {
738
- this.release(client);
739
- }
740
- }
741
- drain() {
742
- for (const entry of this.entries.values()) {
743
- if (entry.idleTimer) {
744
- clearTimeout(entry.idleTimer);
745
- }
746
- try {
747
- entry.client.end();
748
- } catch {
749
- }
750
- }
751
- this.entries.clear();
752
- }
753
- get size() {
754
- return this.entries.size;
755
- }
756
- get stats() {
757
- let active = 0;
758
- let idle = 0;
759
- for (const entry of this.entries.values()) {
760
- if (entry.refCount > 0) active++;
761
- else idle++;
762
- }
763
- return { active, idle };
764
- }
765
- /** Total number of successful SSH connects made by this pool since construction. */
766
- get connectCount() {
767
- return this._connectCount;
768
- }
769
- };
770
-
771
- // src/server.ts
772
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
773
-
774
- // src/tools.ts
775
- import { z } from "zod";
776
-
777
- // src/env.ts
778
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
779
- import { homedir as homedir3 } from "os";
780
- import { join as join3 } from "path";
781
- function probeAgent(socket, agentLabel) {
782
- const { stdout, ok } = runArgs("ssh-add", ["-l"]);
783
- const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
784
- if (!ok && !noIdentities) return null;
785
- const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
786
- return {
787
- running: true,
788
- reachable: true,
789
- socket,
790
- keys,
791
- started: false,
792
- 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.`
793
- };
794
- }
795
- function ensureAgent() {
796
- const sock = process.env.SSH_AUTH_SOCK;
797
- if (sock) {
798
- const result = probeAgent(sock, "ssh-agent");
799
- if (result) return result;
800
- }
801
- if (!sock && process.platform === "win32") {
802
- const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
803
- if (result) return result;
804
- }
805
- const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
806
- if (ok) {
807
- const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
808
- const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
809
- if (sockMatch) {
810
- process.env.SSH_AUTH_SOCK = sockMatch[1];
811
- if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
812
- return {
813
- running: true,
814
- reachable: true,
815
- socket: sockMatch[1],
816
- keys: [],
817
- started: true,
818
- env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
819
- 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."
820
- };
821
- }
822
- }
823
- return {
824
- running: false,
825
- reachable: false,
826
- keys: [],
827
- started: false,
828
- message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
829
- };
830
- }
831
- function detectKeyType(filePath, fileName) {
832
- const pubPath = `${filePath}.pub`;
833
- if (existsSync2(pubPath)) {
834
- try {
835
- const pub = readFileSync3(pubPath, "utf8");
836
- if (pub.includes("ssh-ed25519")) return "ed25519";
837
- if (pub.includes("ssh-rsa")) return "rsa";
838
- if (pub.includes("ecdsa")) return "ecdsa";
839
- if (pub.includes("ssh-dss")) return "dsa";
840
- } catch {
1009
+ if (entry.idleTimer) {
1010
+ clearTimeout(entry.idleTimer);
1011
+ entry.idleTimer = null;
1012
+ }
1013
+ return client;
841
1014
  }
1015
+ throw new Error(
1016
+ `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1017
+ );
842
1018
  }
843
- if (fileName.includes("ed25519")) return "ed25519";
844
- if (fileName.includes("rsa")) return "rsa";
845
- if (fileName.includes("ecdsa")) return "ecdsa";
846
- if (fileName.includes("dsa")) return "dsa";
847
- try {
848
- const content = readFileSync3(filePath, "utf8");
849
- if (content.includes("RSA PRIVATE KEY")) return "rsa";
850
- if (content.includes("EC PRIVATE KEY")) return "ecdsa";
851
- if (content.includes("DSA PRIVATE KEY")) return "dsa";
852
- } catch {
853
- }
854
- return "unknown";
855
- }
856
- function listSshKeys() {
857
- const sshDir = join3(homedir3(), ".ssh");
858
- if (!existsSync2(sshDir)) return [];
859
- const loadedFingerprints = /* @__PURE__ */ new Set();
860
- const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
861
- if (agentOk && !agentOut.includes("no identities")) {
862
- for (const line of agentOut.split("\n").filter(Boolean)) {
863
- const match = line.match(/(\S+:\S+)/);
864
- if (match) loadedFingerprints.add(match[1]);
1019
+ release(client) {
1020
+ for (const entry of this.entries.values()) {
1021
+ if (entry.client === client) {
1022
+ entry.refCount = Math.max(0, entry.refCount - 1);
1023
+ if (entry.refCount === 0 && !entry.dead) {
1024
+ entry.idleTimer = setTimeout(() => {
1025
+ try {
1026
+ entry.client.end();
1027
+ } catch {
1028
+ }
1029
+ this.entries.delete(entry.key);
1030
+ }, this.idleTtlMs);
1031
+ entry.idleTimer.unref();
1032
+ }
1033
+ return;
1034
+ }
865
1035
  }
866
- }
867
- const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
868
- const keys = [];
869
- let files;
870
- try {
871
- files = readdirSync2(sshDir);
872
- } catch {
873
- return [];
874
- }
875
- for (const file of files) {
876
- if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
877
- const filePath = join3(sshDir, file);
878
1036
  try {
879
- const stat = statSync(filePath);
880
- if (!stat.isFile()) continue;
881
- const content = readFileSync3(filePath, "utf8");
882
- if (!content.includes("PRIVATE KEY")) continue;
883
- const type = detectKeyType(filePath, file);
884
- let fingerprint;
885
- const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
886
- if (fpOk) {
887
- const match = fpOut.match(/(\S+:\S+)/);
888
- fingerprint = match?.[1];
889
- }
890
- const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
891
- keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1037
+ client.end();
892
1038
  } catch {
893
1039
  }
894
1040
  }
895
- return keys;
896
- }
897
- function loadKey(keyPath) {
898
- const agent = ensureAgent();
899
- if (!agent.reachable) {
900
- return { status: "error", message: agent.message };
901
- }
902
- const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
903
- if (!existsSync2(resolved)) {
904
- return { status: "error", message: `Key not found: ${resolved}` };
905
- }
906
- const { stdout, ok } = runArgs("ssh-add", [resolved]);
907
- if (ok) {
908
- return { status: "ok", message: `Key loaded: ${resolved}` };
909
- }
910
- if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
911
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
912
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
1041
+ async withConnection(config, fn) {
1042
+ const client = await this.acquire(config);
1043
+ try {
1044
+ return await fn(client);
1045
+ } finally {
1046
+ this.release(client);
913
1047
  }
914
- return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
915
- }
916
- return { status: "error", message: `Failed to load key: ${stdout}` };
917
- }
918
- function configLookup(host) {
919
- if (!isValidHostname(host)) {
920
- return { error: `Invalid hostname: "${host}"` };
921
- }
922
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
923
- if (!ok) {
924
- return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
925
1048
  }
926
- const all = {};
927
- const identityFiles = [];
928
- for (const line of stdout.split("\n")) {
929
- const spaceIdx = line.indexOf(" ");
930
- if (spaceIdx > 0) {
931
- const key = line.substring(0, spaceIdx);
932
- const value = line.substring(spaceIdx + 1);
933
- if (key === "identityfile") {
934
- identityFiles.push(value);
935
- } else {
936
- all[key] = value;
1049
+ drain() {
1050
+ for (const entry of this.entries.values()) {
1051
+ if (entry.idleTimer) {
1052
+ clearTimeout(entry.idleTimer);
1053
+ }
1054
+ try {
1055
+ entry.client.end();
1056
+ } catch {
937
1057
  }
938
1058
  }
1059
+ this.entries.clear();
939
1060
  }
940
- return {
941
- hostname: all.hostname || host,
942
- user: all.user || "",
943
- port: all.port || "22",
944
- identityFile: identityFiles,
945
- proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
946
- proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
947
- all,
948
- raw: stdout
949
- };
950
- }
951
- function fixKnownHosts(host, port = 22) {
952
- if (!isValidHostname(host)) {
953
- return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
954
- }
955
- const actions = [];
956
- const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
957
- if (removeOk) {
958
- actions.push(`Removed old host key for ${host}`);
959
- }
960
- if (port !== 22) {
961
- const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
962
- if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
1061
+ get size() {
1062
+ return this.entries.size;
963
1063
  }
964
- const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
965
- const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
966
- if (scanOk && scanOut.trim()) {
967
- try {
968
- const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
969
- appendFileSync(knownHostsPath, `
970
- ${scanOut.trim()}
971
- `);
972
- actions.push(`Added new host key for ${host}`);
973
- return { status: "ok", message: `Host key refreshed for ${host}`, actions };
974
- } catch (e) {
975
- const msg = e instanceof Error ? e.message : String(e);
976
- return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
1064
+ get stats() {
1065
+ let active = 0;
1066
+ let idle = 0;
1067
+ for (const entry of this.entries.values()) {
1068
+ if (entry.refCount > 0) active++;
1069
+ else idle++;
977
1070
  }
1071
+ return { active, idle };
978
1072
  }
979
- return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
980
- }
981
- function checkGitSsh(host = "github.com", user = "git") {
982
- if (!isValidHostname(host)) {
983
- return { status: "error", message: `Invalid hostname: "${host}"` };
984
- }
985
- const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
986
- const text = stdout;
987
- if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
988
- const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
989
- return {
990
- status: "ok",
991
- message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
992
- authenticatedAs: userMatch?.[1]
993
- };
994
- }
995
- if (text.includes("Permission denied")) {
996
- return {
997
- status: "error",
998
- 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.`
999
- };
1000
- }
1001
- if (text.includes("Connection refused")) {
1002
- return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
1003
- }
1004
- if (text.includes("timed out") || text.includes("Connection timed out")) {
1005
- return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
1006
- }
1007
- if (text.includes("Could not resolve")) {
1008
- return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
1009
- }
1010
- return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
1011
- }
1012
- function testConnection(host, port = 22) {
1013
- if (!isValidHostname(host)) {
1014
- return { status: "error", message: `Invalid hostname: "${host}"` };
1015
- }
1016
- const start = Date.now();
1017
- const { ok, stdout } = runArgs("ssh", [
1018
- "-o",
1019
- "ConnectTimeout=5",
1020
- "-o",
1021
- "BatchMode=yes",
1022
- "-o",
1023
- "StrictHostKeyChecking=no",
1024
- "-p",
1025
- String(port),
1026
- host,
1027
- "echo",
1028
- "SSH_OK"
1029
- ]);
1030
- const elapsed = Date.now() - start;
1031
- if (ok && stdout.includes("SSH_OK")) {
1032
- return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
1033
- }
1034
- if (stdout.includes("Permission denied")) {
1035
- return {
1036
- status: "error",
1037
- 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.`
1038
- };
1039
- }
1040
- if (stdout.includes("Connection refused")) {
1041
- return {
1042
- status: "error",
1043
- message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
1044
- };
1045
- }
1046
- if (stdout.includes("timed out")) {
1047
- return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
1048
- }
1049
- if (stdout.includes("Host key verification failed")) {
1050
- return {
1051
- status: "error",
1052
- message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
1053
- };
1054
- }
1055
- if (stdout.includes("Could not resolve")) {
1056
- return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
1073
+ /** Total number of successful SSH connects made by this pool since construction. */
1074
+ get connectCount() {
1075
+ return this._connectCount;
1057
1076
  }
1058
- return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
1059
- }
1077
+ };
1078
+
1079
+ // src/server.ts
1080
+ import { readFileSync as readFileSync4 } from "fs";
1081
+ import { dirname, join as join4 } from "path";
1082
+ import { fileURLToPath } from "url";
1083
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1084
+
1085
+ // src/tools.ts
1086
+ import { z } from "zod";
1060
1087
 
1061
1088
  // src/ops.ts
1062
1089
  function shellQuote(s) {
@@ -1096,7 +1123,7 @@ async function find(client, options, timeoutMs = 3e4) {
1096
1123
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
1097
1124
  );
1098
1125
  }
1099
- const args = [shellQuote(options.path)];
1126
+ const args = ["--", shellQuote(options.path)];
1100
1127
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1101
1128
  if (options.type) args.push("-type", options.type);
1102
1129
  if (options.name) args.push("-name", shellQuote(options.name));
@@ -1111,9 +1138,9 @@ async function find(client, options, timeoutMs = 3e4) {
1111
1138
  return result.stdout.split("\n").filter(Boolean);
1112
1139
  }
1113
1140
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1114
- let command = `tail -n ${lines} ${shellQuote(path)}`;
1141
+ let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1115
1142
  if (grep) {
1116
- command += ` | grep -i ${shellQuote(grep)}`;
1143
+ command += ` | grep -i -e ${shellQuote(grep)}`;
1117
1144
  }
1118
1145
  const result = await exec(client, command, timeoutMs);
1119
1146
  if (result.stderr.trim()) {
@@ -1122,16 +1149,17 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1122
1149
  return result.stdout;
1123
1150
  }
1124
1151
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1125
- const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1152
+ const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1126
1153
  const raw = result.stdout;
1127
1154
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1128
1155
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1129
1156
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1130
1157
  const sinceMatch = raw.match(/since\s+(.+?);/);
1158
+ const fallbackStatus = result.code === 0 ? "active" : "inactive";
1131
1159
  return {
1132
1160
  name: serviceName,
1133
1161
  active: activeMatch?.[1] === "active",
1134
- status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
1162
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
1135
1163
  description: descMatch?.[1]?.trim(),
1136
1164
  since: sinceMatch?.[1]?.trim(),
1137
1165
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
@@ -1514,10 +1542,12 @@ ${files.join("\n")}` }] };
1514
1542
  }
1515
1543
 
1516
1544
  // src/server.ts
1545
+ var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1546
+ var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1517
1547
  function createServer(pool) {
1518
1548
  const server = new McpServer({
1519
1549
  name: "ssh-mcp",
1520
- version: "0.7.0"
1550
+ version
1521
1551
  });
1522
1552
  registerTools(server, pool);
1523
1553
  return server;
@@ -1530,6 +1560,7 @@ async function main() {
1530
1560
  const transport = new StdioServerTransport();
1531
1561
  const shutdown = () => {
1532
1562
  pool.drain();
1563
+ killStartedAgent();
1533
1564
  process.exit(0);
1534
1565
  };
1535
1566
  process.on("SIGINT", shutdown);