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