@yawlabs/ssh-mcp 0.7.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,714 +259,831 @@ 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
- }
281
+ process.kill(startedAgentPid);
282
+ } catch {
283
+ }
284
+ startedAgentPid = null;
285
+ }
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);
280
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
+ };
281
315
  }
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
- };
316
+ }
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)"'
323
+ };
324
+ }
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 {
335
+ }
336
+ }
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";
289
346
  } catch {
290
- return null;
291
347
  }
348
+ return "unknown";
292
349
  }
293
- function readKnownHostsKeys(host, port) {
294
- if (!isValidHostname(host)) return [];
295
- const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
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
+ }
360
+ }
361
+ const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
296
362
  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 {
363
+ let files;
364
+ try {
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];
308
383
  }
384
+ const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
385
+ keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
386
+ } catch {
309
387
  }
310
388
  }
311
389
  return keys;
312
390
  }
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));
321
- };
322
- }
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);
391
+ function loadKey(keyPath) {
392
+ const agent = ensureAgent();
393
+ if (!agent.reachable) {
394
+ return { status: "error", message: agent.message };
329
395
  }
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
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
339
- if (agentSock) {
340
- connectConfig.agent = agentSock;
396
+ const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
397
+ if (!existsSync2(resolved)) {
398
+ return { status: "error", message: `Key not found: ${resolved}` };
341
399
  }
342
- if (config.password) {
343
- connectConfig.password = config.password;
400
+ const { stdout, ok } = runArgs("ssh-add", [resolved]);
401
+ if (ok) {
402
+ return { status: "ok", message: `Key loaded: ${resolved}` };
344
403
  }
345
- if (config.privateKeyPath) {
346
- connectConfig.privateKey = readFileSync2(config.privateKeyPath);
347
- } else if (!agentSock) {
348
- const home = homedir2();
349
- 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")];
350
- for (const keyPath of keyPaths) {
351
- try {
352
- connectConfig.privateKey = readFileSync2(keyPath);
353
- break;
354
- } catch {
355
- }
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}` };
356
407
  }
408
+ return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
357
409
  }
358
- return { connectConfig, proxyJump: sshConfig?.proxyJump };
410
+ return { status: "error", message: `Failed to load key: ${stdout}` };
359
411
  }
360
- function formatDiagnostics(host) {
361
- try {
362
- const checks = [
363
- { name: "SSH Agent", ...checkSshAgent() },
364
- { name: "SSH Keys", ...checkSshKeys() },
365
- { name: "SSH Config", ...checkSshConfig(host) },
366
- { name: "Known Hosts", ...checkKnownHosts(host) }
367
- ];
368
- const parts = [];
369
- const suggestions = [];
370
- for (const check of checks) {
371
- if (check.status !== "ok") {
372
- parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
412
+ function configLookup(host) {
413
+ if (!isValidHostname(host)) {
414
+ return { error: `Invalid hostname: "${host}"` };
415
+ }
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;
373
431
  }
374
432
  }
375
- const agent = checks[0];
376
- if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
377
- if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
378
- const keys = checks[1];
379
- if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
380
- const known = checks[3];
381
- if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
382
- if (suggestions.length > 0) {
383
- parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
384
- }
385
- return parts.length > 0 ? parts.join("\n") : "";
386
- } catch {
387
- return "";
388
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
+ };
389
444
  }
390
- function connectRaw(connectConfig) {
391
- return new Promise((resolve, reject) => {
392
- const client = new Client();
393
- client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
394
- });
395
- }
396
- async function connectWithProxy(resolved) {
397
- if (!resolved.proxyJump) {
398
- return connectRaw(resolved.connectConfig);
445
+ function fixKnownHosts(host, port = 22) {
446
+ if (!isValidHostname(host)) {
447
+ return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
399
448
  }
400
- const jumpResolved = resolveConfig({ host: resolved.proxyJump });
401
- const jumpClient = await connectWithProxy(jumpResolved);
402
- const targetHost = resolved.connectConfig.host;
403
- const targetPort = resolved.connectConfig.port;
404
- const stream = await new Promise((resolve, reject) => {
405
- jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
406
- if (err) {
407
- jumpClient.end();
408
- return reject(err);
409
- }
410
- resolve(stream2);
411
- });
412
- });
413
- return new Promise((resolve, reject) => {
414
- const client = new Client();
415
- client.on("ready", () => resolve(client)).on("error", (err) => {
416
- jumpClient.end();
417
- reject(err);
418
- }).on("close", () => {
419
- jumpClient.end();
420
- }).connect({ ...resolved.connectConfig, sock: stream });
421
- });
422
- }
423
- function exec(client, command, timeoutMs = 3e4) {
424
- return new Promise((resolve, reject) => {
425
- let settled = false;
426
- const settle = (fn) => {
427
- if (settled) return;
428
- settled = true;
429
- clearTimeout(timer);
430
- fn();
431
- };
432
- const timer = setTimeout(() => {
433
- settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
434
- }, timeoutMs);
435
- client.exec(command, (err, stream) => {
436
- if (err) {
437
- settle(() => reject(err));
438
- return;
439
- }
440
- let stdout = "";
441
- let stderr = "";
442
- stream.on("close", (code) => {
443
- settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
444
- }).on("data", (data) => {
445
- stdout += data.toString();
446
- }).on("error", (err2) => {
447
- settle(() => reject(err2));
448
- });
449
- stream.stderr.on("data", (data) => {
450
- stderr += data.toString();
451
- }).on("error", (err2) => {
452
- settle(() => reject(err2));
453
- });
454
- });
455
- });
456
- }
457
- function getSftp(client) {
458
- return new Promise((resolve, reject) => {
459
- client.sftp((err, sftp) => {
460
- if (err) return reject(err);
461
- resolve(sftp);
462
- });
463
- });
464
- }
465
- var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
466
- async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
467
- const sftp = await getSftp(client);
468
- try {
469
- const stats = await new Promise((resolve, reject) => {
470
- sftp.stat(remotePath, (err, stats2) => {
471
- if (err) return reject(err);
472
- resolve(stats2);
473
- });
474
- });
475
- if (stats.size > maxBytes) {
476
- throw new Error(
477
- `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.`
478
- );
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 };
479
471
  }
480
- return await new Promise((resolve, reject) => {
481
- sftp.readFile(remotePath, (err, data) => {
482
- if (err) return reject(err);
483
- resolve(data.toString("utf8"));
484
- });
485
- });
486
- } finally {
487
- sftp.end();
488
472
  }
473
+ return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
489
474
  }
490
- async function writeFile(client, remotePath, content) {
491
- const sftp = await getSftp(client);
492
- try {
493
- await new Promise((resolve, reject) => {
494
- sftp.writeFile(remotePath, content, (err) => {
495
- if (err) return reject(err);
496
- resolve();
497
- });
498
- });
499
- } finally {
500
- sftp.end();
475
+ function checkGitSsh(host = "github.com", user = "git") {
476
+ if (!isValidHostname(host)) {
477
+ return { status: "error", message: `Invalid hostname: "${host}"` };
501
478
  }
502
- }
503
- async function uploadFile(client, localPath, remotePath) {
504
- const sftp = await getSftp(client);
505
- try {
506
- await new Promise((resolve, reject) => {
507
- sftp.fastPut(localPath, remotePath, (err) => {
508
- if (err) return reject(err);
509
- resolve();
510
- });
511
- });
512
- } finally {
513
- sftp.end();
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
+ };
514
488
  }
515
- }
516
- async function downloadFile(client, remotePath, localPath) {
517
- const sftp = await getSftp(client);
518
- try {
519
- await new Promise((resolve, reject) => {
520
- sftp.fastGet(remotePath, localPath, (err) => {
521
- if (err) return reject(err);
522
- resolve();
523
- });
524
- });
525
- } finally {
526
- sftp.end();
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
+ };
527
494
  }
528
- }
529
- async function listDir(client, remotePath) {
530
- const sftp = await getSftp(client);
531
- try {
532
- return await new Promise((resolve, reject) => {
533
- sftp.readdir(remotePath, (err, list) => {
534
- if (err) return reject(err);
535
- resolve(list.map((item) => item.filename));
536
- });
537
- });
538
- } finally {
539
- sftp.end();
495
+ if (text.includes("Connection refused")) {
496
+ return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
540
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)"}` };
541
505
  }
542
-
543
- // src/pool.ts
544
- var ConnectionPool = class {
545
- entries = /* @__PURE__ */ new Map();
546
- idleTtlMs;
547
- maxPoolSize;
548
- constructor(options) {
549
- this.idleTtlMs = options?.idleTtlMs ?? 6e4;
550
- this.maxPoolSize = options?.maxPoolSize ?? 100;
506
+ function testConnection(host, port = 22) {
507
+ if (!isValidHostname(host)) {
508
+ return { status: "error", message: `Invalid hostname: "${host}"` };
551
509
  }
552
- async acquire(config) {
553
- const resolved = resolveConfig(config);
554
- const cc = resolved.connectConfig;
555
- const key = `${cc.username}@${cc.host}:${cc.port}`;
556
- const existing = this.entries.get(key);
557
- if (existing && !existing.dead) {
558
- existing.refCount++;
559
- if (existing.idleTimer) {
560
- clearTimeout(existing.idleTimer);
561
- existing.idleTimer = null;
562
- }
563
- return existing.client;
564
- }
565
- if (existing?.dead) {
566
- this.entries.delete(key);
567
- }
568
- if (this.entries.size >= this.maxPoolSize) {
569
- let evicted = false;
570
- for (const [k, e] of this.entries) {
571
- if (e.refCount === 0) {
572
- if (e.idleTimer) clearTimeout(e.idleTimer);
573
- try {
574
- e.client.end();
575
- } catch {
576
- }
577
- this.entries.delete(k);
578
- evicted = true;
579
- break;
580
- }
581
- }
582
- if (!evicted) {
583
- throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
584
- }
585
- }
586
- try {
587
- const client = await connectWithProxy(resolved);
588
- const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
589
- const markDead = () => {
590
- entry.dead = true;
591
- if (entry.idleTimer) {
592
- clearTimeout(entry.idleTimer);
593
- entry.idleTimer = null;
594
- }
595
- if (this.entries.get(key) === entry) {
596
- this.entries.delete(key);
597
- }
598
- };
599
- client.on("close", markDead);
600
- client.on("end", markDead);
601
- client.on("error", markDead);
602
- this.entries.set(key, entry);
603
- return client;
604
- } catch (err) {
605
- const diag = formatDiagnostics(config.host);
606
- if (diag) {
607
- const message = err instanceof Error ? err.message : String(err);
608
- const enhanced = new Error(`${message}
609
-
610
- SSH Diagnostics:
611
- ${diag}`);
612
- enhanced.cause = err;
613
- throw enhanced;
614
- }
615
- throw err;
616
- }
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` };
617
527
  }
618
- release(client) {
619
- for (const entry of this.entries.values()) {
620
- if (entry.client === client) {
621
- entry.refCount = Math.max(0, entry.refCount - 1);
622
- if (entry.refCount === 0 && !entry.dead) {
623
- entry.idleTimer = setTimeout(() => {
624
- try {
625
- entry.client.end();
626
- } catch {
627
- }
628
- this.entries.delete(entry.key);
629
- }, this.idleTtlMs);
630
- entry.idleTimer.unref();
631
- }
632
- return;
633
- }
634
- }
635
- try {
636
- client.end();
637
- } catch {
638
- }
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
+ };
639
533
  }
640
- async withConnection(config, fn) {
641
- const client = await this.acquire(config);
642
- try {
643
- return await fn(client);
644
- } finally {
645
- this.release(client);
646
- }
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
+ };
647
539
  }
648
- drain() {
649
- for (const entry of this.entries.values()) {
650
- if (entry.idleTimer) {
651
- clearTimeout(entry.idleTimer);
652
- }
653
- try {
654
- entry.client.end();
655
- } catch {
656
- }
657
- }
658
- this.entries.clear();
540
+ if (stdout.includes("timed out")) {
541
+ return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
659
542
  }
660
- get size() {
661
- return this.entries.size;
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
+ };
662
548
  }
663
- get stats() {
664
- let active = 0;
665
- let idle = 0;
666
- for (const entry of this.entries.values()) {
667
- if (entry.refCount > 0) active++;
668
- else idle++;
669
- }
670
- return { active, idle };
549
+ if (stdout.includes("Could not resolve")) {
550
+ return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
671
551
  }
672
- };
673
-
674
- // src/server.ts
675
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
676
-
677
- // src/tools.ts
678
- import { z } from "zod";
552
+ return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
553
+ }
679
554
 
680
- // src/env.ts
681
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
555
+ // src/ssh.ts
556
+ import { readFileSync as readFileSync3 } from "fs";
682
557
  import { homedir as homedir3 } from "os";
683
558
  import { join as join3 } from "path";
684
- function ensureAgent() {
685
- const sock = process.env.SSH_AUTH_SOCK;
686
- if (sock) {
687
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
688
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
689
- if (ok2 || noIdentities) {
690
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
691
- return {
692
- running: true,
693
- reachable: true,
694
- socket: sock,
695
- keys,
696
- started: false,
697
- 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."
698
- };
699
- }
700
- }
701
- if (!sock && process.platform === "win32") {
702
- const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
703
- const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
704
- if (ok2 || noIdentities) {
705
- const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
706
- return {
707
- running: true,
708
- reachable: true,
709
- socket: "\\\\.\\pipe\\openssh-ssh-agent",
710
- keys,
711
- started: false,
712
- message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
713
- };
714
- }
715
- }
716
- const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
717
- if (ok) {
718
- const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
719
- const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
720
- if (sockMatch) {
721
- process.env.SSH_AUTH_SOCK = sockMatch[1];
722
- if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
723
- return {
724
- running: true,
725
- reachable: true,
726
- socket: sockMatch[1],
727
- keys: [],
728
- started: true,
729
- env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
730
- message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
731
- };
732
- }
733
- }
734
- return {
735
- running: false,
736
- reachable: false,
737
- keys: [],
738
- started: false,
739
- 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)"'
740
- };
741
- }
742
- function detectKeyType(filePath, fileName) {
743
- const pubPath = `${filePath}.pub`;
744
- if (existsSync2(pubPath)) {
745
- try {
746
- const pub = readFileSync3(pubPath, "utf8");
747
- if (pub.includes("ssh-ed25519")) return "ed25519";
748
- if (pub.includes("ssh-rsa")) return "rsa";
749
- if (pub.includes("ecdsa")) return "ecdsa";
750
- if (pub.includes("ssh-dss")) return "dsa";
751
- } catch {
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
+ }
752
577
  }
753
- }
754
- if (fileName.includes("ed25519")) return "ed25519";
755
- if (fileName.includes("rsa")) return "rsa";
756
- if (fileName.includes("ecdsa")) return "ecdsa";
757
- if (fileName.includes("dsa")) return "dsa";
758
- try {
759
- const content = readFileSync3(filePath, "utf8");
760
- if (content.includes("RSA PRIVATE KEY")) return "rsa";
761
- if (content.includes("EC PRIVATE KEY")) return "ecdsa";
762
- if (content.includes("DSA PRIVATE KEY")) return "dsa";
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
+ };
763
585
  } catch {
586
+ return null;
764
587
  }
765
- return "unknown";
766
588
  }
767
- function listSshKeys() {
768
- const sshDir = join3(homedir3(), ".ssh");
769
- if (!existsSync2(sshDir)) return [];
770
- const loadedFingerprints = /* @__PURE__ */ new Set();
771
- const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
772
- if (agentOk && !agentOut.includes("no identities")) {
773
- for (const line of agentOut.split("\n").filter(Boolean)) {
774
- const match = line.match(/(\S+:\S+)/);
775
- if (match) loadedFingerprints.add(match[1]);
776
- }
777
- }
778
- const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
589
+ function readKnownHostsKeys(host, port) {
590
+ if (!isValidHostname(host)) return [];
591
+ const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
779
592
  const keys = [];
780
- let files;
781
- try {
782
- files = readdirSync2(sshDir);
783
- } catch {
784
- return [];
785
- }
786
- for (const file of files) {
787
- if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
788
- const filePath = join3(sshDir, file);
789
- try {
790
- const stat = statSync(filePath);
791
- if (!stat.isFile()) continue;
792
- const content = readFileSync3(filePath, "utf8");
793
- if (!content.includes("PRIVATE KEY")) continue;
794
- const type = detectKeyType(filePath, file);
795
- let fingerprint;
796
- const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
797
- if (fpOk) {
798
- const match = fpOut.match(/(\S+:\S+)/);
799
- fingerprint = match?.[1];
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 {
800
604
  }
801
- const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
802
- keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
803
- } catch {
804
605
  }
805
606
  }
806
607
  return keys;
807
608
  }
808
- function loadKey(keyPath) {
809
- const agent = ensureAgent();
810
- if (!agent.reachable) {
811
- return { status: "error", message: agent.message };
812
- }
813
- const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
814
- if (!existsSync2(resolved)) {
815
- return { status: "error", message: `Key not found: ${resolved}` };
816
- }
817
- const { stdout, ok } = runArgs("ssh-add", [resolved]);
818
- if (ok) {
819
- return { status: "ok", message: `Key loaded: ${resolved}` };
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);
820
625
  }
821
- if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
822
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
823
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
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
+ }
824
652
  }
825
- return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
826
653
  }
827
- return { status: "error", message: `Failed to load key: ${stdout}` };
654
+ return { connectConfig, proxyJump: sshConfig?.proxyJump };
828
655
  }
829
- function configLookup(host) {
830
- if (!isValidHostname(host)) {
831
- return { error: `Invalid hostname: "${host}"` };
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 "";
832
705
  }
833
- const { stdout, ok } = runArgs("ssh", ["-G", host]);
834
- if (!ok) {
835
- return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
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);
836
716
  }
837
- const all = {};
838
- const identityFiles = [];
839
- for (const line of stdout.split("\n")) {
840
- const spaceIdx = line.indexOf(" ");
841
- if (spaceIdx > 0) {
842
- const key = line.substring(0, spaceIdx);
843
- const value = line.substring(spaceIdx + 1);
844
- if (key === "identityfile") {
845
- identityFiles.push(value);
846
- } else {
847
- all[key] = value;
717
+ const jumpResolved = resolveConfig({ host: resolved.proxyJump });
718
+ const jumpClient = await connectWithProxy(jumpResolved);
719
+ const targetHost = resolved.connectConfig.host;
720
+ const targetPort = resolved.connectConfig.port;
721
+ const stream = await new Promise((resolve, reject) => {
722
+ jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
723
+ if (err) {
724
+ jumpClient.end();
725
+ return reject(err);
726
+ }
727
+ resolve(stream2);
728
+ });
729
+ });
730
+ return new Promise((resolve, reject) => {
731
+ const client = new Client();
732
+ client.on("ready", () => resolve(client)).on("error", (err) => {
733
+ jumpClient.end();
734
+ reject(err);
735
+ }).on("close", () => {
736
+ jumpClient.end();
737
+ }).connect({ ...resolved.connectConfig, sock: stream });
738
+ });
739
+ }
740
+ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
741
+ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
742
+ return new Promise((resolve, reject) => {
743
+ let settled = false;
744
+ let activeStream = null;
745
+ const settle = (fn) => {
746
+ if (settled) return;
747
+ settled = true;
748
+ clearTimeout(timer);
749
+ fn();
750
+ };
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
+ }
762
+ settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
763
+ }, timeoutMs);
764
+ client.exec(command, (err, stream) => {
765
+ if (err) {
766
+ settle(() => reject(err));
767
+ return;
848
768
  }
769
+ activeStream = stream;
770
+ const stdoutChunks = [];
771
+ const stderrChunks = [];
772
+ let stdoutBytes = 0;
773
+ let stderrBytes = 0;
774
+ let stdoutTruncated = false;
775
+ let stderrTruncated = false;
776
+ const appendStdout = (data) => {
777
+ if (stdoutTruncated) return;
778
+ const remaining = maxBytes - stdoutBytes;
779
+ if (data.length <= remaining) {
780
+ stdoutChunks.push(data);
781
+ stdoutBytes += data.length;
782
+ } else {
783
+ if (remaining > 0) {
784
+ stdoutChunks.push(data.subarray(0, remaining));
785
+ stdoutBytes += remaining;
786
+ }
787
+ stdoutTruncated = true;
788
+ }
789
+ };
790
+ const appendStderr = (data) => {
791
+ if (stderrTruncated) return;
792
+ const remaining = maxBytes - stderrBytes;
793
+ if (data.length <= remaining) {
794
+ stderrChunks.push(data);
795
+ stderrBytes += data.length;
796
+ } else {
797
+ if (remaining > 0) {
798
+ stderrChunks.push(data.subarray(0, remaining));
799
+ stderrBytes += remaining;
800
+ }
801
+ stderrTruncated = true;
802
+ }
803
+ };
804
+ stream.on("close", (code) => {
805
+ let stdout = Buffer.concat(stdoutChunks).toString("utf8");
806
+ let stderr = Buffer.concat(stderrChunks).toString("utf8");
807
+ if (stdoutTruncated) stdout += `
808
+ [output truncated at ${maxBytes} bytes]`;
809
+ if (stderrTruncated) stderr += `
810
+ [stderr truncated at ${maxBytes} bytes]`;
811
+ settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
812
+ }).on("data", appendStdout).on("error", (err2) => {
813
+ settle(() => reject(err2));
814
+ });
815
+ stream.stderr.on("data", appendStderr).on("error", (err2) => {
816
+ settle(() => reject(err2));
817
+ });
818
+ });
819
+ });
820
+ }
821
+ function getSftp(client) {
822
+ return new Promise((resolve, reject) => {
823
+ client.sftp((err, sftp) => {
824
+ if (err) return reject(err);
825
+ resolve(sftp);
826
+ });
827
+ });
828
+ }
829
+ var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
830
+ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
831
+ const sftp = await getSftp(client);
832
+ try {
833
+ const stats = await new Promise((resolve, reject) => {
834
+ sftp.stat(remotePath, (err, stats2) => {
835
+ if (err) return reject(err);
836
+ resolve(stats2);
837
+ });
838
+ });
839
+ if (stats.size > maxBytes) {
840
+ throw new Error(
841
+ `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.`
842
+ );
849
843
  }
844
+ return await new Promise((resolve, reject) => {
845
+ sftp.readFile(remotePath, (err, data) => {
846
+ if (err) return reject(err);
847
+ resolve(data.toString("utf8"));
848
+ });
849
+ });
850
+ } finally {
851
+ sftp.end();
850
852
  }
851
- return {
852
- hostname: all.hostname || host,
853
- user: all.user || "",
854
- port: all.port || "22",
855
- identityFile: identityFiles,
856
- proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
857
- proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
858
- all,
859
- raw: stdout
860
- };
861
853
  }
862
- function fixKnownHosts(host, port = 22) {
863
- if (!isValidHostname(host)) {
864
- return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
865
- }
866
- const actions = [];
867
- const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
868
- if (removeOk) {
869
- actions.push(`Removed old host key for ${host}`);
870
- }
871
- if (port !== 22) {
872
- const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
873
- if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
874
- }
875
- const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
876
- const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
877
- if (scanOk && scanOut.trim()) {
878
- try {
879
- const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
880
- appendFileSync(knownHostsPath, `
881
- ${scanOut.trim()}
882
- `);
883
- actions.push(`Added new host key for ${host}`);
884
- return { status: "ok", message: `Host key refreshed for ${host}`, actions };
885
- } catch (e) {
886
- const msg = e instanceof Error ? e.message : String(e);
887
- return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
888
- }
854
+ async function writeFile(client, remotePath, content) {
855
+ const sftp = await getSftp(client);
856
+ try {
857
+ await new Promise((resolve, reject) => {
858
+ sftp.writeFile(remotePath, content, (err) => {
859
+ if (err) return reject(err);
860
+ resolve();
861
+ });
862
+ });
863
+ } finally {
864
+ sftp.end();
889
865
  }
890
- return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
891
866
  }
892
- function checkGitSsh(host = "github.com", user = "git") {
893
- if (!isValidHostname(host)) {
894
- return { status: "error", message: `Invalid hostname: "${host}"` };
895
- }
896
- const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
897
- const text = stdout;
898
- if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
899
- const userMatch = text.match(/Hi (\S+?)!/) || text.match(/@(\S+?)!/) || text.match(/logged in as (\S+)/);
900
- return {
901
- status: "ok",
902
- message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
903
- authenticatedAs: userMatch?.[1]
904
- };
905
- }
906
- if (text.includes("Permission denied")) {
907
- return {
908
- status: "error",
909
- 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.`
910
- };
911
- }
912
- if (text.includes("Connection refused")) {
913
- return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
867
+ async function uploadFile(client, localPath, remotePath) {
868
+ const sftp = await getSftp(client);
869
+ try {
870
+ await new Promise((resolve, reject) => {
871
+ sftp.fastPut(localPath, remotePath, (err) => {
872
+ if (err) return reject(err);
873
+ resolve();
874
+ });
875
+ });
876
+ } finally {
877
+ sftp.end();
914
878
  }
915
- if (text.includes("timed out") || text.includes("Connection timed out")) {
916
- return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
879
+ }
880
+ async function downloadFile(client, remotePath, localPath) {
881
+ const sftp = await getSftp(client);
882
+ try {
883
+ await new Promise((resolve, reject) => {
884
+ sftp.fastGet(remotePath, localPath, (err) => {
885
+ if (err) return reject(err);
886
+ resolve();
887
+ });
888
+ });
889
+ } finally {
890
+ sftp.end();
917
891
  }
918
- if (text.includes("Could not resolve")) {
919
- return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
892
+ }
893
+ async function listDir(client, remotePath) {
894
+ const sftp = await getSftp(client);
895
+ try {
896
+ return await new Promise((resolve, reject) => {
897
+ sftp.readdir(remotePath, (err, list) => {
898
+ if (err) return reject(err);
899
+ resolve(list.map((item) => item.filename));
900
+ });
901
+ });
902
+ } finally {
903
+ sftp.end();
920
904
  }
921
- return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
922
905
  }
923
- function testConnection(host, port = 22) {
924
- if (!isValidHostname(host)) {
925
- return { status: "error", message: `Invalid hostname: "${host}"` };
906
+
907
+ // src/pool.ts
908
+ var ConnectionPool = class {
909
+ entries = /* @__PURE__ */ new Map();
910
+ // Coalesces concurrent connect attempts for the same key so we don't open N
911
+ // duplicate TCP connections when N tool calls fire simultaneously.
912
+ pending = /* @__PURE__ */ new Map();
913
+ idleTtlMs;
914
+ maxPoolSize;
915
+ // Total number of successful connects ever made by this pool. Useful for
916
+ // introspection and for tests that want to prove connection reuse.
917
+ _connectCount = 0;
918
+ constructor(options) {
919
+ this.idleTtlMs = options?.idleTtlMs ?? 6e4;
920
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
926
921
  }
927
- const start = Date.now();
928
- const { ok, stdout } = runArgs("ssh", [
929
- "-o",
930
- "ConnectTimeout=5",
931
- "-o",
932
- "BatchMode=yes",
933
- "-o",
934
- "StrictHostKeyChecking=no",
935
- "-p",
936
- String(port),
937
- host,
938
- "echo",
939
- "SSH_OK"
940
- ]);
941
- const elapsed = Date.now() - start;
942
- if (ok && stdout.includes("SSH_OK")) {
943
- return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
922
+ async acquire(config) {
923
+ const resolved = resolveConfig(config);
924
+ const cc = resolved.connectConfig;
925
+ const key = `${cc.username}@${cc.host}:${cc.port}`;
926
+ const MAX_ACQUIRE_ATTEMPTS = 3;
927
+ let lastErr;
928
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
929
+ const existing = this.entries.get(key);
930
+ if (existing && !existing.dead) {
931
+ existing.refCount++;
932
+ if (existing.idleTimer) {
933
+ clearTimeout(existing.idleTimer);
934
+ existing.idleTimer = null;
935
+ }
936
+ return existing.client;
937
+ }
938
+ if (existing?.dead) {
939
+ this.entries.delete(key);
940
+ }
941
+ let pending = this.pending.get(key);
942
+ if (!pending) {
943
+ if (this.entries.size >= this.maxPoolSize) {
944
+ let evicted = false;
945
+ for (const [k, e] of this.entries) {
946
+ if (e.refCount === 0) {
947
+ if (e.idleTimer) clearTimeout(e.idleTimer);
948
+ try {
949
+ e.client.end();
950
+ } catch {
951
+ }
952
+ this.entries.delete(k);
953
+ evicted = true;
954
+ break;
955
+ }
956
+ }
957
+ if (!evicted) {
958
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
959
+ }
960
+ }
961
+ pending = (async () => {
962
+ try {
963
+ const client2 = await connectWithProxy(resolved);
964
+ this._connectCount++;
965
+ const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
966
+ const markDead = () => {
967
+ entry2.dead = true;
968
+ if (entry2.idleTimer) {
969
+ clearTimeout(entry2.idleTimer);
970
+ entry2.idleTimer = null;
971
+ }
972
+ if (this.entries.get(key) === entry2) {
973
+ this.entries.delete(key);
974
+ }
975
+ };
976
+ client2.on("close", markDead);
977
+ client2.on("end", markDead);
978
+ client2.on("error", markDead);
979
+ this.entries.set(key, entry2);
980
+ return client2;
981
+ } finally {
982
+ this.pending.delete(key);
983
+ }
984
+ })();
985
+ this.pending.set(key, pending);
986
+ }
987
+ let client;
988
+ try {
989
+ client = await pending;
990
+ } catch (err) {
991
+ const diag = formatDiagnostics(config.host);
992
+ if (diag) {
993
+ const message = err instanceof Error ? err.message : String(err);
994
+ const enhanced = new Error(`${message}
995
+
996
+ SSH Diagnostics:
997
+ ${diag}`);
998
+ enhanced.cause = err;
999
+ throw enhanced;
1000
+ }
1001
+ throw err;
1002
+ }
1003
+ const entry = this.entries.get(key);
1004
+ if (!entry || entry.dead || entry.client !== client) {
1005
+ lastErr = new Error("connection died before acquire could take a ref");
1006
+ continue;
1007
+ }
1008
+ entry.refCount++;
1009
+ if (entry.idleTimer) {
1010
+ clearTimeout(entry.idleTimer);
1011
+ entry.idleTimer = null;
1012
+ }
1013
+ return client;
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
+ );
944
1018
  }
945
- if (stdout.includes("Permission denied")) {
946
- return {
947
- status: "error",
948
- 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.`
949
- };
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
+ }
1035
+ }
1036
+ try {
1037
+ client.end();
1038
+ } catch {
1039
+ }
950
1040
  }
951
- if (stdout.includes("Connection refused")) {
952
- return {
953
- status: "error",
954
- message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
955
- };
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);
1047
+ }
956
1048
  }
957
- if (stdout.includes("timed out")) {
958
- return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
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 {
1057
+ }
1058
+ }
1059
+ this.entries.clear();
959
1060
  }
960
- if (stdout.includes("Host key verification failed")) {
961
- return {
962
- status: "error",
963
- message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
964
- };
1061
+ get size() {
1062
+ return this.entries.size;
965
1063
  }
966
- if (stdout.includes("Could not resolve")) {
967
- return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
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++;
1070
+ }
1071
+ return { active, idle };
968
1072
  }
969
- return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
970
- }
1073
+ /** Total number of successful SSH connects made by this pool since construction. */
1074
+ get connectCount() {
1075
+ return this._connectCount;
1076
+ }
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";
971
1087
 
972
1088
  // src/ops.ts
973
1089
  function shellQuote(s) {
@@ -1007,39 +1123,43 @@ async function find(client, options, timeoutMs = 3e4) {
1007
1123
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
1008
1124
  );
1009
1125
  }
1010
- const args = [shellQuote(options.path)];
1126
+ const args = ["--", shellQuote(options.path)];
1011
1127
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1012
1128
  if (options.type) args.push("-type", options.type);
1013
1129
  if (options.name) args.push("-name", shellQuote(options.name));
1014
1130
  if (options.minsize) args.push("-size", `+${options.minsize}`);
1015
1131
  if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1016
1132
  if (options.newer) args.push("-newer", shellQuote(options.newer));
1017
- const command = `find ${args.join(" ")} 2>/dev/null`;
1133
+ const command = `find ${args.join(" ")}`;
1018
1134
  const result = await exec(client, command, timeoutMs);
1135
+ if (!result.stdout.trim() && result.stderr.trim()) {
1136
+ throw new Error(result.stderr.trim());
1137
+ }
1019
1138
  return result.stdout.split("\n").filter(Boolean);
1020
1139
  }
1021
1140
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1022
- let command = `tail -n ${lines} ${shellQuote(path)}`;
1141
+ let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1023
1142
  if (grep) {
1024
- command += ` | grep -i ${shellQuote(grep)}`;
1143
+ command += ` | grep -i -e ${shellQuote(grep)}`;
1025
1144
  }
1026
1145
  const result = await exec(client, command, timeoutMs);
1027
- if (result.code !== 0 && result.stderr && !grep) {
1146
+ if (result.stderr.trim()) {
1028
1147
  throw new Error(result.stderr.trim());
1029
1148
  }
1030
1149
  return result.stdout;
1031
1150
  }
1032
1151
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1033
- 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);
1034
1153
  const raw = result.stdout;
1035
1154
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1036
1155
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1037
1156
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1038
1157
  const sinceMatch = raw.match(/since\s+(.+?);/);
1158
+ const fallbackStatus = result.code === 0 ? "active" : "inactive";
1039
1159
  return {
1040
1160
  name: serviceName,
1041
1161
  active: activeMatch?.[1] === "active",
1042
- status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
1162
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
1043
1163
  description: descMatch?.[1]?.trim(),
1044
1164
  since: sinceMatch?.[1]?.trim(),
1045
1165
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
@@ -1052,7 +1172,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
1052
1172
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1053
1173
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1054
1174
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
1055
- var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
1175
+ var PasswordSchema = z.string().optional().describe(
1176
+ "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."
1177
+ );
1056
1178
  var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
1057
1179
  var connectionParams = {
1058
1180
  host: HostSchema,
@@ -1071,8 +1193,8 @@ function registerTools(server, pool) {
1071
1193
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1072
1194
  timeout: TimeoutSchema
1073
1195
  },
1074
- async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
1075
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1196
+ async ({ command, timeout, ...conn }) => {
1197
+ return connectionPool.withConnection(conn, async (client) => {
1076
1198
  const result = await exec(client, command, timeout || 3e4);
1077
1199
  const parts = [];
1078
1200
  if (result.stdout) parts.push(result.stdout);
@@ -1090,8 +1212,8 @@ ${result.stderr}`);
1090
1212
  ...connectionParams,
1091
1213
  path: z.string().describe("Absolute path to the remote file")
1092
1214
  },
1093
- async ({ host, port, username, privateKeyPath, password, path }) => {
1094
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1215
+ async ({ path, ...conn }) => {
1216
+ return connectionPool.withConnection(conn, async (client) => {
1095
1217
  const content = await readFile(client, path);
1096
1218
  return { content: [{ type: "text", text: content }] };
1097
1219
  });
@@ -1105,8 +1227,8 @@ ${result.stderr}`);
1105
1227
  path: z.string().describe("Absolute path to the remote file"),
1106
1228
  content: z.string().describe("File content to write")
1107
1229
  },
1108
- async ({ host, port, username, privateKeyPath, password, path, content }) => {
1109
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1230
+ async ({ path, content, ...conn }) => {
1231
+ return connectionPool.withConnection(conn, async (client) => {
1110
1232
  await writeFile(client, path, content);
1111
1233
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
1112
1234
  });
@@ -1120,8 +1242,8 @@ ${result.stderr}`);
1120
1242
  localPath: z.string().describe("Path to the local file to upload"),
1121
1243
  remotePath: z.string().describe("Absolute path on the remote host")
1122
1244
  },
1123
- async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
1124
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1245
+ async ({ localPath, remotePath, ...conn }) => {
1246
+ return connectionPool.withConnection(conn, async (client) => {
1125
1247
  await uploadFile(client, localPath, remotePath);
1126
1248
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
1127
1249
  });
@@ -1135,8 +1257,8 @@ ${result.stderr}`);
1135
1257
  remotePath: z.string().describe("Absolute path to the remote file"),
1136
1258
  localPath: z.string().describe("Local path to save the downloaded file")
1137
1259
  },
1138
- async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
1139
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1260
+ async ({ remotePath, localPath, ...conn }) => {
1261
+ return connectionPool.withConnection(conn, async (client) => {
1140
1262
  await downloadFile(client, remotePath, localPath);
1141
1263
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
1142
1264
  });
@@ -1149,8 +1271,8 @@ ${result.stderr}`);
1149
1271
  ...connectionParams,
1150
1272
  path: z.string().describe("Absolute path to the remote directory")
1151
1273
  },
1152
- async ({ host, port, username, privateKeyPath, password, path }) => {
1153
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1274
+ async ({ path, ...conn }) => {
1275
+ return connectionPool.withConnection(conn, async (client) => {
1154
1276
  const files = await listDir(client, path);
1155
1277
  return { content: [{ type: "text", text: files.join("\n") }] };
1156
1278
  });
@@ -1356,21 +1478,8 @@ ${result.stderr}`);
1356
1478
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1357
1479
  timeout: TimeoutSchema
1358
1480
  },
1359
- async ({
1360
- host,
1361
- port,
1362
- username,
1363
- privateKeyPath,
1364
- password,
1365
- path,
1366
- name,
1367
- type,
1368
- maxdepth,
1369
- minsize,
1370
- maxsize,
1371
- timeout
1372
- }) => {
1373
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1481
+ async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
1482
+ return connectionPool.withConnection(conn, async (client) => {
1374
1483
  const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1375
1484
  if (files.length === 0) {
1376
1485
  return { content: [{ type: "text", text: "No files found." }] };
@@ -1390,8 +1499,8 @@ ${files.join("\n")}` }] };
1390
1499
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1391
1500
  timeout: TimeoutSchema
1392
1501
  },
1393
- async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1394
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1502
+ async ({ path, lines, grep, timeout, ...conn }) => {
1503
+ return connectionPool.withConnection(conn, async (client) => {
1395
1504
  const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1396
1505
  if (!output.trim()) {
1397
1506
  return {
@@ -1415,8 +1524,8 @@ ${files.join("\n")}` }] };
1415
1524
  service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1416
1525
  timeout: TimeoutSchema
1417
1526
  },
1418
- async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1419
- return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1527
+ async ({ service, timeout, ...conn }) => {
1528
+ return connectionPool.withConnection(conn, async (client) => {
1420
1529
  const status = await serviceStatus(client, service, timeout || 3e4);
1421
1530
  const lines = [];
1422
1531
  lines.push(`Service: ${status.name}`);
@@ -1433,10 +1542,12 @@ ${files.join("\n")}` }] };
1433
1542
  }
1434
1543
 
1435
1544
  // src/server.ts
1545
+ var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1546
+ var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1436
1547
  function createServer(pool) {
1437
1548
  const server = new McpServer({
1438
1549
  name: "ssh-mcp",
1439
- version: "0.7.0"
1550
+ version
1440
1551
  });
1441
1552
  registerTools(server, pool);
1442
1553
  return server;
@@ -1449,6 +1560,7 @@ async function main() {
1449
1560
  const transport = new StdioServerTransport();
1450
1561
  const shutdown = () => {
1451
1562
  pool.drain();
1563
+ killStartedAgent();
1452
1564
  process.exit(0);
1453
1565
  };
1454
1566
  process.on("SIGINT", shutdown);