@yawlabs/ssh-mcp 0.8.0 → 0.9.1
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 +464 -375
- package/dist/server.d.ts +40 -39
- package/dist/server.js +114 -43
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
import {
|
|
6
|
+
// src/env.ts
|
|
7
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
8
|
+
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
8
9
|
import { homedir as homedir2 } from "os";
|
|
9
10
|
import { join as join2 } from "path";
|
|
10
|
-
import { Client } from "ssh2";
|
|
11
11
|
|
|
12
12
|
// src/diagnose.ts
|
|
13
13
|
import { execFileSync } from "child_process";
|
|
14
|
-
import { existsSync,
|
|
14
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
15
15
|
import { homedir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
function isValidHostname(host) {
|
|
@@ -19,7 +19,7 @@ function isValidHostname(host) {
|
|
|
19
19
|
if (host.startsWith("[")) {
|
|
20
20
|
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
21
21
|
}
|
|
22
|
-
return /^[a-zA-Z0-9._
|
|
22
|
+
return /^[a-zA-Z0-9._-]+$/.test(host);
|
|
23
23
|
}
|
|
24
24
|
function runArgs(cmd, args) {
|
|
25
25
|
try {
|
|
@@ -260,31 +260,358 @@ function diagnose(host, port = 22) {
|
|
|
260
260
|
return { overall, checks, suggestions };
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// src/ssh-config.ts
|
|
264
|
+
function parseSshConfigOutput(stdout) {
|
|
265
|
+
const all = {};
|
|
266
|
+
const identityFiles = [];
|
|
267
|
+
for (const line of stdout.split("\n")) {
|
|
268
|
+
const spaceIdx = line.indexOf(" ");
|
|
269
|
+
if (spaceIdx > 0) {
|
|
270
|
+
const key = line.substring(0, spaceIdx);
|
|
271
|
+
const value = line.substring(spaceIdx + 1);
|
|
272
|
+
if (key === "identityfile") {
|
|
273
|
+
identityFiles.push(value);
|
|
274
|
+
} else {
|
|
275
|
+
all[key] = value;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { all, identityFiles };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/env.ts
|
|
283
|
+
function runArgsWithEnv(cmd, args, extraEnv) {
|
|
284
|
+
const env = {};
|
|
285
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
286
|
+
if (typeof v === "string") env[k] = v;
|
|
287
|
+
}
|
|
288
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
289
|
+
if (v === void 0) {
|
|
290
|
+
delete env[k];
|
|
291
|
+
} else {
|
|
292
|
+
env[k] = v;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const stdout = execFileSync2(cmd, args, {
|
|
297
|
+
env,
|
|
298
|
+
encoding: "utf8",
|
|
299
|
+
timeout: 1e4,
|
|
300
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
301
|
+
});
|
|
302
|
+
return { stdout: stdout.trim(), ok: true };
|
|
303
|
+
} catch (e) {
|
|
304
|
+
const err = e;
|
|
305
|
+
const so = err.stdout?.toString().trim() || "";
|
|
306
|
+
const se = err.stderr?.toString().trim() || "";
|
|
307
|
+
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
308
|
+
return { stdout: output, ok: false };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function probeAgent(socket, agentLabel) {
|
|
312
|
+
const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
|
|
313
|
+
const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
|
|
314
|
+
const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
|
|
315
|
+
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
316
|
+
if (!ok && !noIdentities) return null;
|
|
317
|
+
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
318
|
+
return {
|
|
319
|
+
running: true,
|
|
320
|
+
reachable: true,
|
|
321
|
+
socket,
|
|
322
|
+
keys,
|
|
323
|
+
started: false,
|
|
324
|
+
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.`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
var startedAgentPid = null;
|
|
328
|
+
function killStartedAgent() {
|
|
329
|
+
if (startedAgentPid === null) return;
|
|
330
|
+
try {
|
|
331
|
+
process.kill(startedAgentPid);
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
startedAgentPid = null;
|
|
335
|
+
}
|
|
336
|
+
function ensureAgent() {
|
|
337
|
+
const sock = process.env.SSH_AUTH_SOCK;
|
|
338
|
+
if (sock) {
|
|
339
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
340
|
+
if (result) return result;
|
|
341
|
+
}
|
|
342
|
+
if (!sock && process.platform === "win32") {
|
|
343
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
344
|
+
if (result) return result;
|
|
345
|
+
}
|
|
346
|
+
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
347
|
+
if (ok) {
|
|
348
|
+
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
349
|
+
const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
|
|
350
|
+
if (sockMatch) {
|
|
351
|
+
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
352
|
+
if (pidMatch) {
|
|
353
|
+
process.env.SSH_AGENT_PID = pidMatch[1];
|
|
354
|
+
startedAgentPid = Number.parseInt(pidMatch[1], 10);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
running: true,
|
|
358
|
+
reachable: true,
|
|
359
|
+
socket: sockMatch[1],
|
|
360
|
+
keys: [],
|
|
361
|
+
started: true,
|
|
362
|
+
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
363
|
+
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."
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
running: false,
|
|
369
|
+
reachable: false,
|
|
370
|
+
keys: [],
|
|
371
|
+
started: false,
|
|
372
|
+
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)"'
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function detectKeyType(filePath, fileName) {
|
|
376
|
+
const pubPath = `${filePath}.pub`;
|
|
377
|
+
if (existsSync2(pubPath)) {
|
|
378
|
+
try {
|
|
379
|
+
const pub = readFileSync2(pubPath, "utf8");
|
|
380
|
+
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
381
|
+
if (pub.includes("ssh-rsa")) return "rsa";
|
|
382
|
+
if (pub.includes("ecdsa")) return "ecdsa";
|
|
383
|
+
if (pub.includes("ssh-dss")) return "dsa";
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (fileName.includes("ed25519")) return "ed25519";
|
|
388
|
+
if (fileName.includes("rsa")) return "rsa";
|
|
389
|
+
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
390
|
+
if (fileName.includes("dsa")) return "dsa";
|
|
391
|
+
try {
|
|
392
|
+
const content = readFileSync2(filePath, "utf8");
|
|
393
|
+
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
394
|
+
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
395
|
+
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
396
|
+
if (content.includes("OPENSSH PRIVATE KEY")) {
|
|
397
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
|
|
398
|
+
if (ok) {
|
|
399
|
+
const match = stdout.match(/\(([^)]+)\)\s*$/);
|
|
400
|
+
if (match) return match[1].toLowerCase();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
return "unknown";
|
|
406
|
+
}
|
|
407
|
+
function listSshKeys() {
|
|
408
|
+
const sshDir = join2(homedir2(), ".ssh");
|
|
409
|
+
if (!existsSync2(sshDir)) return [];
|
|
410
|
+
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
411
|
+
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
412
|
+
if (agentOk && !agentOut.includes("no identities")) {
|
|
413
|
+
for (const line of agentOut.split("\n").filter(Boolean)) {
|
|
414
|
+
const match = line.match(/(\S+:\S+)/);
|
|
415
|
+
if (match) loadedFingerprints.add(match[1]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
|
|
419
|
+
const keys = [];
|
|
420
|
+
let files;
|
|
421
|
+
try {
|
|
422
|
+
files = readdirSync2(sshDir);
|
|
423
|
+
} catch {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
for (const file of files) {
|
|
427
|
+
if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
|
|
428
|
+
const filePath = join2(sshDir, file);
|
|
429
|
+
try {
|
|
430
|
+
const stat = statSync(filePath);
|
|
431
|
+
if (!stat.isFile()) continue;
|
|
432
|
+
const content = readFileSync2(filePath, "utf8");
|
|
433
|
+
if (!content.includes("PRIVATE KEY")) continue;
|
|
434
|
+
const type = detectKeyType(filePath, file);
|
|
435
|
+
let fingerprint;
|
|
436
|
+
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
437
|
+
if (fpOk) {
|
|
438
|
+
const match = fpOut.match(/(\S+:\S+)/);
|
|
439
|
+
fingerprint = match?.[1];
|
|
440
|
+
}
|
|
441
|
+
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
442
|
+
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
443
|
+
} catch {
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return keys;
|
|
447
|
+
}
|
|
448
|
+
function loadKey(keyPath) {
|
|
449
|
+
const agent = ensureAgent();
|
|
450
|
+
if (!agent.reachable) {
|
|
451
|
+
return { status: "error", message: agent.message };
|
|
452
|
+
}
|
|
453
|
+
const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
|
|
454
|
+
if (!existsSync2(resolved)) {
|
|
455
|
+
return { status: "error", message: `Key not found: ${resolved}` };
|
|
456
|
+
}
|
|
457
|
+
const { stdout, ok } = runArgs("ssh-add", [resolved]);
|
|
458
|
+
if (ok) {
|
|
459
|
+
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
460
|
+
}
|
|
461
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
|
|
462
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
|
|
463
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
464
|
+
}
|
|
465
|
+
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
466
|
+
}
|
|
467
|
+
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
468
|
+
}
|
|
469
|
+
function configLookup(host) {
|
|
470
|
+
if (!isValidHostname(host)) {
|
|
471
|
+
return { error: `Invalid hostname: "${host}"` };
|
|
472
|
+
}
|
|
473
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
474
|
+
if (!ok) {
|
|
475
|
+
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
476
|
+
}
|
|
477
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
478
|
+
return {
|
|
479
|
+
hostname: all.hostname || host,
|
|
480
|
+
user: all.user || "",
|
|
481
|
+
port: all.port || "22",
|
|
482
|
+
identityFile: identityFiles,
|
|
483
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
484
|
+
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
485
|
+
all,
|
|
486
|
+
raw: stdout
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function fixKnownHosts(host, port = 22) {
|
|
490
|
+
if (!isValidHostname(host)) {
|
|
491
|
+
return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
|
|
492
|
+
}
|
|
493
|
+
const actions = [];
|
|
494
|
+
const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
|
|
495
|
+
if (removeOk) {
|
|
496
|
+
actions.push(`Removed old host key for ${host}`);
|
|
497
|
+
}
|
|
498
|
+
if (port !== 22) {
|
|
499
|
+
const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
|
|
500
|
+
if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
|
|
501
|
+
}
|
|
502
|
+
const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
|
|
503
|
+
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
504
|
+
if (scanOk && scanOut.trim()) {
|
|
505
|
+
try {
|
|
506
|
+
const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
|
|
507
|
+
appendFileSync(knownHostsPath, `
|
|
508
|
+
${scanOut.trim()}
|
|
509
|
+
`);
|
|
510
|
+
actions.push(`Added new host key for ${host}`);
|
|
511
|
+
return { status: "ok", message: `Host key refreshed for ${host}`, actions };
|
|
512
|
+
} catch (e) {
|
|
513
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
514
|
+
return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
|
|
518
|
+
}
|
|
519
|
+
function checkGitSsh(host = "github.com", user = "git") {
|
|
520
|
+
if (!isValidHostname(host)) {
|
|
521
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
522
|
+
}
|
|
523
|
+
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
524
|
+
const text = stdout;
|
|
525
|
+
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
526
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
527
|
+
return {
|
|
528
|
+
status: "ok",
|
|
529
|
+
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
530
|
+
authenticatedAs: userMatch?.[1]
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (text.includes("Permission denied")) {
|
|
534
|
+
return {
|
|
535
|
+
status: "error",
|
|
536
|
+
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.`
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (text.includes("Connection refused")) {
|
|
540
|
+
return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
|
|
541
|
+
}
|
|
542
|
+
if (text.includes("timed out") || text.includes("Connection timed out")) {
|
|
543
|
+
return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
|
|
544
|
+
}
|
|
545
|
+
if (text.includes("Could not resolve")) {
|
|
546
|
+
return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
|
|
547
|
+
}
|
|
548
|
+
return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
|
|
549
|
+
}
|
|
550
|
+
function testConnection(host, port = 22) {
|
|
551
|
+
if (!isValidHostname(host)) {
|
|
552
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
553
|
+
}
|
|
554
|
+
const start = Date.now();
|
|
555
|
+
const { ok, stdout } = runArgs("ssh", [
|
|
556
|
+
"-o",
|
|
557
|
+
"ConnectTimeout=5",
|
|
558
|
+
"-o",
|
|
559
|
+
"BatchMode=yes",
|
|
560
|
+
"-o",
|
|
561
|
+
"StrictHostKeyChecking=no",
|
|
562
|
+
"-p",
|
|
563
|
+
String(port),
|
|
564
|
+
host,
|
|
565
|
+
"echo",
|
|
566
|
+
"SSH_OK"
|
|
567
|
+
]);
|
|
568
|
+
const elapsed = Date.now() - start;
|
|
569
|
+
if (ok && stdout.includes("SSH_OK")) {
|
|
570
|
+
return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
|
|
571
|
+
}
|
|
572
|
+
if (stdout.includes("Permission denied")) {
|
|
573
|
+
return {
|
|
574
|
+
status: "error",
|
|
575
|
+
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.`
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (stdout.includes("Connection refused")) {
|
|
579
|
+
return {
|
|
580
|
+
status: "error",
|
|
581
|
+
message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (stdout.includes("timed out")) {
|
|
585
|
+
return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
|
|
586
|
+
}
|
|
587
|
+
if (stdout.includes("Host key verification failed")) {
|
|
588
|
+
return {
|
|
589
|
+
status: "error",
|
|
590
|
+
message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (stdout.includes("Could not resolve")) {
|
|
594
|
+
return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
|
|
595
|
+
}
|
|
596
|
+
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
597
|
+
}
|
|
598
|
+
|
|
263
599
|
// src/ssh.ts
|
|
600
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
601
|
+
import { homedir as homedir3 } from "os";
|
|
602
|
+
import { join as join3 } from "path";
|
|
603
|
+
import { Client } from "ssh2";
|
|
264
604
|
function resolveFromSshConfig(host) {
|
|
265
605
|
try {
|
|
266
606
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
267
607
|
if (!ok) return null;
|
|
268
|
-
const
|
|
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
|
-
}
|
|
608
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
282
609
|
return {
|
|
283
|
-
hostname:
|
|
284
|
-
user:
|
|
285
|
-
port:
|
|
610
|
+
hostname: all.hostname || host,
|
|
611
|
+
user: all.user || "",
|
|
612
|
+
port: all.port || "22",
|
|
286
613
|
identityFiles,
|
|
287
|
-
proxyJump:
|
|
614
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
288
615
|
};
|
|
289
616
|
} catch {
|
|
290
617
|
return null;
|
|
@@ -336,7 +663,7 @@ function resolveConfig(config) {
|
|
|
336
663
|
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
337
664
|
};
|
|
338
665
|
if (config.privateKeyPath) {
|
|
339
|
-
connectConfig.privateKey =
|
|
666
|
+
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
340
667
|
} else if (config.password) {
|
|
341
668
|
connectConfig.password = config.password;
|
|
342
669
|
} else {
|
|
@@ -344,11 +671,11 @@ function resolveConfig(config) {
|
|
|
344
671
|
if (agentSock) {
|
|
345
672
|
connectConfig.agent = agentSock;
|
|
346
673
|
} else {
|
|
347
|
-
const home =
|
|
348
|
-
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ?
|
|
674
|
+
const home = homedir3();
|
|
675
|
+
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")];
|
|
349
676
|
for (const keyPath of keyPaths) {
|
|
350
677
|
try {
|
|
351
|
-
connectConfig.privateKey =
|
|
678
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
352
679
|
break;
|
|
353
680
|
} catch {
|
|
354
681
|
}
|
|
@@ -445,6 +772,7 @@ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
|
445
772
|
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
446
773
|
return new Promise((resolve, reject) => {
|
|
447
774
|
let settled = false;
|
|
775
|
+
let activeStream = null;
|
|
448
776
|
const settle = (fn) => {
|
|
449
777
|
if (settled) return;
|
|
450
778
|
settled = true;
|
|
@@ -452,6 +780,16 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
452
780
|
fn();
|
|
453
781
|
};
|
|
454
782
|
const timer = setTimeout(() => {
|
|
783
|
+
if (activeStream) {
|
|
784
|
+
try {
|
|
785
|
+
activeStream.signal("TERM");
|
|
786
|
+
} catch {
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
activeStream.close();
|
|
790
|
+
} catch {
|
|
791
|
+
}
|
|
792
|
+
}
|
|
455
793
|
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
456
794
|
}, timeoutMs);
|
|
457
795
|
client.exec(command, (err, stream) => {
|
|
@@ -459,6 +797,7 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
459
797
|
settle(() => reject(err));
|
|
460
798
|
return;
|
|
461
799
|
}
|
|
800
|
+
activeStream = stream;
|
|
462
801
|
const stdoutChunks = [];
|
|
463
802
|
const stderrChunks = [];
|
|
464
803
|
let stdoutBytes = 0;
|
|
@@ -607,6 +946,10 @@ var ConnectionPool = class {
|
|
|
607
946
|
// Total number of successful connects ever made by this pool. Useful for
|
|
608
947
|
// introspection and for tests that want to prove connection reuse.
|
|
609
948
|
_connectCount = 0;
|
|
949
|
+
// Once drained, the pool stays drained — new acquires reject and any in-flight
|
|
950
|
+
// factory closes the freshly-connected client instead of registering it.
|
|
951
|
+
// Consumers must construct a new pool to use again.
|
|
952
|
+
drained = false;
|
|
610
953
|
constructor(options) {
|
|
611
954
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
612
955
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -618,6 +961,9 @@ var ConnectionPool = class {
|
|
|
618
961
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
619
962
|
let lastErr;
|
|
620
963
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
964
|
+
if (this.drained) {
|
|
965
|
+
throw new Error("ConnectionPool was drained");
|
|
966
|
+
}
|
|
621
967
|
const existing = this.entries.get(key);
|
|
622
968
|
if (existing && !existing.dead) {
|
|
623
969
|
existing.refCount++;
|
|
@@ -653,6 +999,13 @@ var ConnectionPool = class {
|
|
|
653
999
|
pending = (async () => {
|
|
654
1000
|
try {
|
|
655
1001
|
const client2 = await connectWithProxy(resolved);
|
|
1002
|
+
if (this.drained) {
|
|
1003
|
+
try {
|
|
1004
|
+
client2.end();
|
|
1005
|
+
} catch {
|
|
1006
|
+
}
|
|
1007
|
+
throw new Error("ConnectionPool was drained while connecting");
|
|
1008
|
+
}
|
|
656
1009
|
this._connectCount++;
|
|
657
1010
|
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
658
1011
|
const markDead = () => {
|
|
@@ -704,359 +1057,80 @@ ${diag}`);
|
|
|
704
1057
|
}
|
|
705
1058
|
return client;
|
|
706
1059
|
}
|
|
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 {
|
|
841
|
-
}
|
|
842
|
-
}
|
|
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 {
|
|
1060
|
+
throw new Error(
|
|
1061
|
+
`Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
1062
|
+
);
|
|
853
1063
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1064
|
+
release(client) {
|
|
1065
|
+
for (const entry of this.entries.values()) {
|
|
1066
|
+
if (entry.client === client) {
|
|
1067
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
1068
|
+
if (entry.refCount === 0 && !entry.dead) {
|
|
1069
|
+
entry.idleTimer = setTimeout(() => {
|
|
1070
|
+
try {
|
|
1071
|
+
entry.client.end();
|
|
1072
|
+
} catch {
|
|
1073
|
+
}
|
|
1074
|
+
this.entries.delete(entry.key);
|
|
1075
|
+
}, this.idleTtlMs);
|
|
1076
|
+
entry.idleTimer.unref();
|
|
1077
|
+
}
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
865
1080
|
}
|
|
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
1081
|
try {
|
|
879
|
-
|
|
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 });
|
|
1082
|
+
client.end();
|
|
892
1083
|
} catch {
|
|
893
1084
|
}
|
|
894
1085
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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}` };
|
|
1086
|
+
async withConnection(config, fn) {
|
|
1087
|
+
const client = await this.acquire(config);
|
|
1088
|
+
try {
|
|
1089
|
+
return await fn(client);
|
|
1090
|
+
} finally {
|
|
1091
|
+
this.release(client);
|
|
913
1092
|
}
|
|
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
1093
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
} else {
|
|
936
|
-
all[key] = value;
|
|
1094
|
+
drain() {
|
|
1095
|
+
this.drained = true;
|
|
1096
|
+
for (const entry of this.entries.values()) {
|
|
1097
|
+
if (entry.idleTimer) {
|
|
1098
|
+
clearTimeout(entry.idleTimer);
|
|
1099
|
+
}
|
|
1100
|
+
try {
|
|
1101
|
+
entry.client.end();
|
|
1102
|
+
} catch {
|
|
937
1103
|
}
|
|
938
1104
|
}
|
|
1105
|
+
this.entries.clear();
|
|
1106
|
+
this.pending.clear();
|
|
939
1107
|
}
|
|
940
|
-
|
|
941
|
-
|
|
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}`);
|
|
1108
|
+
get size() {
|
|
1109
|
+
return this.entries.size;
|
|
963
1110
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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 };
|
|
1111
|
+
get stats() {
|
|
1112
|
+
let active = 0;
|
|
1113
|
+
let idle = 0;
|
|
1114
|
+
for (const entry of this.entries.values()) {
|
|
1115
|
+
if (entry.refCount > 0) active++;
|
|
1116
|
+
else idle++;
|
|
977
1117
|
}
|
|
1118
|
+
return { active, idle };
|
|
978
1119
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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.` };
|
|
1120
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
1121
|
+
get connectCount() {
|
|
1122
|
+
return this._connectCount;
|
|
1057
1123
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
// src/server.ts
|
|
1127
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1128
|
+
import { dirname, join as join4 } from "path";
|
|
1129
|
+
import { fileURLToPath } from "url";
|
|
1130
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1131
|
+
|
|
1132
|
+
// src/tools.ts
|
|
1133
|
+
import { z } from "zod";
|
|
1060
1134
|
|
|
1061
1135
|
// src/ops.ts
|
|
1062
1136
|
function shellQuote(s) {
|
|
@@ -1096,7 +1170,7 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1096
1170
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
1097
1171
|
);
|
|
1098
1172
|
}
|
|
1099
|
-
const args = [shellQuote(options.path)];
|
|
1173
|
+
const args = ["--", shellQuote(options.path)];
|
|
1100
1174
|
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1101
1175
|
if (options.type) args.push("-type", options.type);
|
|
1102
1176
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
@@ -1111,9 +1185,9 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1111
1185
|
return result.stdout.split("\n").filter(Boolean);
|
|
1112
1186
|
}
|
|
1113
1187
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
1114
|
-
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
1188
|
+
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
1115
1189
|
if (grep) {
|
|
1116
|
-
command += ` | grep -i ${shellQuote(grep)}`;
|
|
1190
|
+
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
1117
1191
|
}
|
|
1118
1192
|
const result = await exec(client, command, timeoutMs);
|
|
1119
1193
|
if (result.stderr.trim()) {
|
|
@@ -1122,16 +1196,17 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
1122
1196
|
return result.stdout;
|
|
1123
1197
|
}
|
|
1124
1198
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
1125
|
-
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1199
|
+
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1126
1200
|
const raw = result.stdout;
|
|
1127
1201
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1128
1202
|
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
1129
1203
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1130
1204
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1205
|
+
const fallbackStatus = result.code === 0 ? "active" : "inactive";
|
|
1131
1206
|
return {
|
|
1132
1207
|
name: serviceName,
|
|
1133
1208
|
active: activeMatch?.[1] === "active",
|
|
1134
|
-
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` :
|
|
1209
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
|
|
1135
1210
|
description: descMatch?.[1]?.trim(),
|
|
1136
1211
|
since: sinceMatch?.[1]?.trim(),
|
|
1137
1212
|
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
@@ -1514,10 +1589,12 @@ ${files.join("\n")}` }] };
|
|
|
1514
1589
|
}
|
|
1515
1590
|
|
|
1516
1591
|
// src/server.ts
|
|
1592
|
+
var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1593
|
+
var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1517
1594
|
function createServer(pool) {
|
|
1518
1595
|
const server = new McpServer({
|
|
1519
1596
|
name: "ssh-mcp",
|
|
1520
|
-
version
|
|
1597
|
+
version
|
|
1521
1598
|
});
|
|
1522
1599
|
registerTools(server, pool);
|
|
1523
1600
|
return server;
|
|
@@ -1528,12 +1605,24 @@ async function main() {
|
|
|
1528
1605
|
const pool = new ConnectionPool();
|
|
1529
1606
|
const server = createServer(pool);
|
|
1530
1607
|
const transport = new StdioServerTransport();
|
|
1531
|
-
|
|
1608
|
+
let shuttingDown = false;
|
|
1609
|
+
const shutdown = async () => {
|
|
1610
|
+
if (shuttingDown) return;
|
|
1611
|
+
shuttingDown = true;
|
|
1612
|
+
try {
|
|
1613
|
+
await server.close();
|
|
1614
|
+
} catch {
|
|
1615
|
+
}
|
|
1532
1616
|
pool.drain();
|
|
1533
|
-
|
|
1617
|
+
killStartedAgent();
|
|
1618
|
+
setTimeout(() => process.exit(0), 100);
|
|
1534
1619
|
};
|
|
1535
|
-
process.on("SIGINT",
|
|
1536
|
-
|
|
1620
|
+
process.on("SIGINT", () => {
|
|
1621
|
+
shutdown().catch(() => process.exit(1));
|
|
1622
|
+
});
|
|
1623
|
+
process.on("SIGTERM", () => {
|
|
1624
|
+
shutdown().catch(() => process.exit(1));
|
|
1625
|
+
});
|
|
1537
1626
|
await server.connect(transport);
|
|
1538
1627
|
}
|
|
1539
1628
|
main().catch((err) => {
|