@yawlabs/ssh-mcp 0.1.0 → 0.4.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 +184 -0
- package/dist/index.js +750 -114
- package/dist/server.d.ts +96 -22
- package/dist/server.js +764 -109
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -5,16 +5,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
7
|
// src/diagnose.ts
|
|
8
|
-
import {
|
|
9
|
-
import { existsSync, readFileSync } from "fs";
|
|
8
|
+
import { execFileSync } from "child_process";
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join } from "path";
|
|
12
|
-
function
|
|
12
|
+
function isValidHostname(host) {
|
|
13
|
+
return /^[a-zA-Z0-9._\-:[\]]+$/.test(host) && host.length <= 253;
|
|
14
|
+
}
|
|
15
|
+
function runArgs(cmd, args) {
|
|
13
16
|
try {
|
|
14
|
-
const stdout =
|
|
17
|
+
const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
15
18
|
return { stdout: stdout.trim(), ok: true };
|
|
16
19
|
} catch (e) {
|
|
17
|
-
|
|
20
|
+
const stdout = e.stdout?.toString().trim() || "";
|
|
21
|
+
const stderr = e.stderr?.toString().trim() || "";
|
|
22
|
+
const output = [stdout, stderr].filter(Boolean).join("\n") || e.message || "";
|
|
23
|
+
return { stdout: output, ok: false };
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
function checkSshAgent() {
|
|
@@ -25,7 +31,7 @@ function checkSshAgent() {
|
|
|
25
31
|
message: "SSH_AUTH_SOCK is not set. ssh-agent is not running or not exported to this shell."
|
|
26
32
|
};
|
|
27
33
|
}
|
|
28
|
-
const { stdout, ok } =
|
|
34
|
+
const { stdout, ok } = runArgs("ssh-add", ["-l"]);
|
|
29
35
|
if (!ok && stdout.includes("Could not open a connection")) {
|
|
30
36
|
return {
|
|
31
37
|
status: "error",
|
|
@@ -56,8 +62,9 @@ function checkSshKeys() {
|
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
65
|
+
const allFiles = readdirSync(sshDir).filter(
|
|
66
|
+
(f) => !f.endsWith(".pub") && !["known_hosts", "known_hosts.old", "config", "authorized_keys"].includes(f)
|
|
67
|
+
);
|
|
61
68
|
for (const f of allFiles) {
|
|
62
69
|
if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
|
|
63
70
|
try {
|
|
@@ -87,19 +94,35 @@ function checkKnownHosts(host) {
|
|
|
87
94
|
message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
|
-
|
|
97
|
+
if (!isValidHostname(host)) {
|
|
98
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
99
|
+
}
|
|
100
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
|
|
91
101
|
if (!ok || !stdout.trim()) {
|
|
92
102
|
return {
|
|
93
103
|
status: "warning",
|
|
94
|
-
message: `Host "${host}" is not in known_hosts. First connection will prompt for host key verification. To add it: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
|
|
104
|
+
message: `Host "${host}" is not in known_hosts. First connection will prompt for host key verification. To add it: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`
|
|
95
105
|
};
|
|
96
106
|
}
|
|
97
107
|
return { status: "ok", message: `Host "${host}" found in known_hosts` };
|
|
98
108
|
}
|
|
99
109
|
function checkConnectivity(host, port = 22) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
if (!isValidHostname(host)) {
|
|
111
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
112
|
+
}
|
|
113
|
+
const { ok, stdout } = runArgs("ssh", [
|
|
114
|
+
"-o",
|
|
115
|
+
"ConnectTimeout=5",
|
|
116
|
+
"-o",
|
|
117
|
+
"BatchMode=yes",
|
|
118
|
+
"-o",
|
|
119
|
+
"StrictHostKeyChecking=no",
|
|
120
|
+
"-p",
|
|
121
|
+
String(port),
|
|
122
|
+
host,
|
|
123
|
+
"echo",
|
|
124
|
+
"SSH_OK"
|
|
125
|
+
]);
|
|
103
126
|
if (ok && stdout.includes("SSH_OK")) {
|
|
104
127
|
return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
|
|
105
128
|
}
|
|
@@ -124,7 +147,7 @@ function checkConnectivity(host, port = 22) {
|
|
|
124
147
|
if (stdout.includes("Host key verification failed")) {
|
|
125
148
|
return {
|
|
126
149
|
status: "error",
|
|
127
|
-
message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R ${host} && ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
|
|
150
|
+
message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R "${host}" && ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`
|
|
128
151
|
};
|
|
129
152
|
}
|
|
130
153
|
if (stdout.includes("Could not resolve hostname")) {
|
|
@@ -148,8 +171,16 @@ function checkSshConfig(host) {
|
|
|
148
171
|
for (const line of lines) {
|
|
149
172
|
const trimmed = line.trim();
|
|
150
173
|
if (/^Host\s+/i.test(trimmed)) {
|
|
151
|
-
const
|
|
152
|
-
inHostBlock =
|
|
174
|
+
const patterns = trimmed.replace(/^Host\s+/i, "").trim().split(/\s+/);
|
|
175
|
+
inHostBlock = patterns.some((p) => {
|
|
176
|
+
if (p === "*") return true;
|
|
177
|
+
if (p === host) return true;
|
|
178
|
+
if (p.includes("*")) {
|
|
179
|
+
const regex = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
|
|
180
|
+
return regex.test(host);
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
});
|
|
153
184
|
if (inHostBlock) hostConfig.push(trimmed);
|
|
154
185
|
} else if (inHostBlock && trimmed) {
|
|
155
186
|
hostConfig.push(trimmed);
|
|
@@ -169,6 +200,13 @@ ${hostConfig.join("\n")}` };
|
|
|
169
200
|
function diagnose(host, port = 22) {
|
|
170
201
|
const checks = [];
|
|
171
202
|
const suggestions = [];
|
|
203
|
+
if (!isValidHostname(host)) {
|
|
204
|
+
return {
|
|
205
|
+
overall: "error",
|
|
206
|
+
checks: [{ name: "Input Validation", status: "error", message: `Invalid hostname: "${host}"` }],
|
|
207
|
+
suggestions: ["Provide a valid hostname (alphanumeric, dots, hyphens, colons, brackets only)"]
|
|
208
|
+
};
|
|
209
|
+
}
|
|
172
210
|
const agent = checkSshAgent();
|
|
173
211
|
checks.push({ name: "SSH Agent", ...agent });
|
|
174
212
|
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
@@ -180,12 +218,12 @@ function diagnose(host, port = 22) {
|
|
|
180
218
|
checks.push({ name: "SSH Config", ...config });
|
|
181
219
|
const known = checkKnownHosts(host);
|
|
182
220
|
checks.push({ name: "Known Hosts", ...known });
|
|
183
|
-
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
|
|
221
|
+
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
184
222
|
const conn = checkConnectivity(host, port);
|
|
185
223
|
checks.push({ name: "Connectivity", ...conn });
|
|
186
224
|
if (conn.status === "error" && conn.message.includes("Host key verification")) {
|
|
187
|
-
suggestions.push(`Remove stale host key: ssh-keygen -R ${host}`);
|
|
188
|
-
suggestions.push(`Re-add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
|
|
225
|
+
suggestions.push(`Remove stale host key: ssh-keygen -R "${host}"`);
|
|
226
|
+
suggestions.push(`Re-add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
189
227
|
}
|
|
190
228
|
if (conn.status === "error" && conn.message.includes("Permission denied")) {
|
|
191
229
|
suggestions.push("Check loaded keys: ssh-add -l");
|
|
@@ -195,30 +233,340 @@ function diagnose(host, port = 22) {
|
|
|
195
233
|
return { overall, checks, suggestions };
|
|
196
234
|
}
|
|
197
235
|
|
|
198
|
-
// src/
|
|
199
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
236
|
+
// src/env.ts
|
|
237
|
+
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
|
|
200
238
|
import { homedir as homedir2 } from "os";
|
|
201
239
|
import { join as join2 } from "path";
|
|
240
|
+
function ensureAgent() {
|
|
241
|
+
const sock = process.env.SSH_AUTH_SOCK;
|
|
242
|
+
if (sock) {
|
|
243
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
244
|
+
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
245
|
+
if (ok2 || noIdentities) {
|
|
246
|
+
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
247
|
+
return {
|
|
248
|
+
running: true,
|
|
249
|
+
reachable: true,
|
|
250
|
+
socket: sock,
|
|
251
|
+
keys,
|
|
252
|
+
started: false,
|
|
253
|
+
message: keys.length > 0 ? `ssh-agent running with ${keys.length} key(s) loaded` : "ssh-agent running but no keys loaded. Use ssh_key_load to add one."
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
258
|
+
if (ok) {
|
|
259
|
+
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
260
|
+
const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
|
|
261
|
+
if (sockMatch) {
|
|
262
|
+
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
263
|
+
if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
|
|
264
|
+
return {
|
|
265
|
+
running: true,
|
|
266
|
+
reachable: true,
|
|
267
|
+
socket: sockMatch[1],
|
|
268
|
+
keys: [],
|
|
269
|
+
started: true,
|
|
270
|
+
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
271
|
+
message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
running: false,
|
|
277
|
+
reachable: false,
|
|
278
|
+
keys: [],
|
|
279
|
+
started: false,
|
|
280
|
+
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function detectKeyType(filePath, fileName) {
|
|
284
|
+
const pubPath = `${filePath}.pub`;
|
|
285
|
+
if (existsSync2(pubPath)) {
|
|
286
|
+
try {
|
|
287
|
+
const pub = readFileSync2(pubPath, "utf8");
|
|
288
|
+
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
289
|
+
if (pub.includes("ssh-rsa")) return "rsa";
|
|
290
|
+
if (pub.includes("ecdsa")) return "ecdsa";
|
|
291
|
+
if (pub.includes("ssh-dss")) return "dsa";
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (fileName.includes("ed25519")) return "ed25519";
|
|
296
|
+
if (fileName.includes("rsa")) return "rsa";
|
|
297
|
+
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
298
|
+
if (fileName.includes("dsa")) return "dsa";
|
|
299
|
+
try {
|
|
300
|
+
const content = readFileSync2(filePath, "utf8");
|
|
301
|
+
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
302
|
+
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
303
|
+
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
return "unknown";
|
|
307
|
+
}
|
|
308
|
+
function listSshKeys() {
|
|
309
|
+
const sshDir = join2(homedir2(), ".ssh");
|
|
310
|
+
if (!existsSync2(sshDir)) return [];
|
|
311
|
+
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
312
|
+
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
313
|
+
if (agentOk && !agentOut.includes("no identities")) {
|
|
314
|
+
for (const line of agentOut.split("\n").filter(Boolean)) {
|
|
315
|
+
const match = line.match(/(\S+:\S+)/);
|
|
316
|
+
if (match) loadedFingerprints.add(match[1]);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
|
|
320
|
+
const keys = [];
|
|
321
|
+
let files;
|
|
322
|
+
try {
|
|
323
|
+
files = readdirSync2(sshDir);
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
|
|
329
|
+
const filePath = join2(sshDir, file);
|
|
330
|
+
try {
|
|
331
|
+
const stat = statSync(filePath);
|
|
332
|
+
if (!stat.isFile()) continue;
|
|
333
|
+
const content = readFileSync2(filePath, "utf8");
|
|
334
|
+
if (!content.includes("PRIVATE KEY")) continue;
|
|
335
|
+
const type = detectKeyType(filePath, file);
|
|
336
|
+
let fingerprint;
|
|
337
|
+
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
338
|
+
if (fpOk) {
|
|
339
|
+
const match = fpOut.match(/(\S+:\S+)/);
|
|
340
|
+
fingerprint = match?.[1];
|
|
341
|
+
}
|
|
342
|
+
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
343
|
+
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return keys;
|
|
348
|
+
}
|
|
349
|
+
function loadKey(keyPath) {
|
|
350
|
+
const agent = ensureAgent();
|
|
351
|
+
if (!agent.reachable) {
|
|
352
|
+
return { status: "error", message: agent.message };
|
|
353
|
+
}
|
|
354
|
+
const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
|
|
355
|
+
if (!existsSync2(resolved)) {
|
|
356
|
+
return { status: "error", message: `Key not found: ${resolved}` };
|
|
357
|
+
}
|
|
358
|
+
const { stdout, ok } = runArgs("ssh-add", [resolved]);
|
|
359
|
+
if (ok) {
|
|
360
|
+
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
361
|
+
}
|
|
362
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
|
|
363
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
|
|
364
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
365
|
+
}
|
|
366
|
+
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
367
|
+
}
|
|
368
|
+
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
369
|
+
}
|
|
370
|
+
function configLookup(host) {
|
|
371
|
+
if (!isValidHostname(host)) {
|
|
372
|
+
return { error: `Invalid hostname: "${host}"` };
|
|
373
|
+
}
|
|
374
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
375
|
+
if (!ok) {
|
|
376
|
+
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
377
|
+
}
|
|
378
|
+
const all = {};
|
|
379
|
+
const identityFiles = [];
|
|
380
|
+
for (const line of stdout.split("\n")) {
|
|
381
|
+
const spaceIdx = line.indexOf(" ");
|
|
382
|
+
if (spaceIdx > 0) {
|
|
383
|
+
const key = line.substring(0, spaceIdx);
|
|
384
|
+
const value = line.substring(spaceIdx + 1);
|
|
385
|
+
if (key === "identityfile") {
|
|
386
|
+
identityFiles.push(value);
|
|
387
|
+
} else {
|
|
388
|
+
all[key] = value;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
hostname: all.hostname || host,
|
|
394
|
+
user: all.user || "",
|
|
395
|
+
port: all.port || "22",
|
|
396
|
+
identityFile: identityFiles,
|
|
397
|
+
proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
398
|
+
proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
399
|
+
all,
|
|
400
|
+
raw: stdout
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function fixKnownHosts(host, port = 22) {
|
|
404
|
+
if (!isValidHostname(host)) {
|
|
405
|
+
return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
|
|
406
|
+
}
|
|
407
|
+
const actions = [];
|
|
408
|
+
const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
|
|
409
|
+
if (removeOk) {
|
|
410
|
+
actions.push(`Removed old host key for ${host}`);
|
|
411
|
+
}
|
|
412
|
+
if (port !== 22) {
|
|
413
|
+
const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
|
|
414
|
+
if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
|
|
415
|
+
}
|
|
416
|
+
const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
|
|
417
|
+
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
418
|
+
if (scanOk && scanOut.trim()) {
|
|
419
|
+
try {
|
|
420
|
+
const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
|
|
421
|
+
appendFileSync(knownHostsPath, `
|
|
422
|
+
${scanOut.trim()}
|
|
423
|
+
`);
|
|
424
|
+
actions.push(`Added new host key for ${host}`);
|
|
425
|
+
return { status: "ok", message: `Host key refreshed for ${host}`, actions };
|
|
426
|
+
} catch (e) {
|
|
427
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
428
|
+
return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
|
|
432
|
+
}
|
|
433
|
+
function checkGitSsh(host = "github.com", user = "git") {
|
|
434
|
+
if (!isValidHostname(host)) {
|
|
435
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
436
|
+
}
|
|
437
|
+
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
438
|
+
const text = stdout;
|
|
439
|
+
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
440
|
+
const userMatch = text.match(/Hi (\S+?)!/) || text.match(/@(\S+?)!/) || text.match(/logged in as (\S+)/);
|
|
441
|
+
return {
|
|
442
|
+
status: "ok",
|
|
443
|
+
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
444
|
+
authenticatedAs: userMatch?.[1]
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (text.includes("Permission denied")) {
|
|
448
|
+
return {
|
|
449
|
+
status: "error",
|
|
450
|
+
message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
if (text.includes("Connection refused")) {
|
|
454
|
+
return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
|
|
455
|
+
}
|
|
456
|
+
if (text.includes("timed out") || text.includes("Connection timed out")) {
|
|
457
|
+
return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
|
|
458
|
+
}
|
|
459
|
+
if (text.includes("Could not resolve")) {
|
|
460
|
+
return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
|
|
461
|
+
}
|
|
462
|
+
return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
|
|
463
|
+
}
|
|
464
|
+
function testConnection(host, port = 22) {
|
|
465
|
+
if (!isValidHostname(host)) {
|
|
466
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
467
|
+
}
|
|
468
|
+
const start = Date.now();
|
|
469
|
+
const { ok, stdout } = runArgs("ssh", [
|
|
470
|
+
"-o",
|
|
471
|
+
"ConnectTimeout=5",
|
|
472
|
+
"-o",
|
|
473
|
+
"BatchMode=yes",
|
|
474
|
+
"-o",
|
|
475
|
+
"StrictHostKeyChecking=no",
|
|
476
|
+
"-p",
|
|
477
|
+
String(port),
|
|
478
|
+
host,
|
|
479
|
+
"echo",
|
|
480
|
+
"SSH_OK"
|
|
481
|
+
]);
|
|
482
|
+
const elapsed = Date.now() - start;
|
|
483
|
+
if (ok && stdout.includes("SSH_OK")) {
|
|
484
|
+
return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
|
|
485
|
+
}
|
|
486
|
+
if (stdout.includes("Permission denied")) {
|
|
487
|
+
return {
|
|
488
|
+
status: "error",
|
|
489
|
+
message: `Authentication failed to ${host}:${port} (${elapsed}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (stdout.includes("Connection refused")) {
|
|
493
|
+
return {
|
|
494
|
+
status: "error",
|
|
495
|
+
message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (stdout.includes("timed out")) {
|
|
499
|
+
return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
|
|
500
|
+
}
|
|
501
|
+
if (stdout.includes("Host key verification failed")) {
|
|
502
|
+
return {
|
|
503
|
+
status: "error",
|
|
504
|
+
message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (stdout.includes("Could not resolve")) {
|
|
508
|
+
return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
|
|
509
|
+
}
|
|
510
|
+
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/ssh.ts
|
|
514
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
515
|
+
import { homedir as homedir3 } from "os";
|
|
516
|
+
import { join as join3 } from "path";
|
|
202
517
|
import { Client } from "ssh2";
|
|
518
|
+
function resolveFromSshConfig(host) {
|
|
519
|
+
try {
|
|
520
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
521
|
+
if (!ok) return null;
|
|
522
|
+
const config = {};
|
|
523
|
+
const identityFiles = [];
|
|
524
|
+
for (const line of stdout.split("\n")) {
|
|
525
|
+
const spaceIdx = line.indexOf(" ");
|
|
526
|
+
if (spaceIdx > 0) {
|
|
527
|
+
const key = line.substring(0, spaceIdx);
|
|
528
|
+
const value = line.substring(spaceIdx + 1);
|
|
529
|
+
if (key === "identityfile") {
|
|
530
|
+
identityFiles.push(value);
|
|
531
|
+
} else {
|
|
532
|
+
config[key] = value;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
hostname: config.hostname || host,
|
|
538
|
+
user: config.user || "",
|
|
539
|
+
port: config.port || "22",
|
|
540
|
+
identityFiles
|
|
541
|
+
};
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
203
546
|
function resolveConfig(config) {
|
|
547
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
204
548
|
const connectConfig = {
|
|
205
|
-
host: config.host,
|
|
206
|
-
port: config.port || 22,
|
|
207
|
-
username: config.username || process.env.USER || process.env.USERNAME || "root"
|
|
549
|
+
host: sshConfig?.hostname || config.host,
|
|
550
|
+
port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
|
|
551
|
+
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
552
|
+
keepaliveInterval: 15e3,
|
|
553
|
+
keepaliveCountMax: 3
|
|
208
554
|
};
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
555
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
|
|
556
|
+
if (agentSock) {
|
|
557
|
+
connectConfig.agent = agentSock;
|
|
558
|
+
}
|
|
559
|
+
if (config.password) {
|
|
212
560
|
connectConfig.password = config.password;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
561
|
+
}
|
|
562
|
+
if (config.privateKeyPath) {
|
|
563
|
+
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
564
|
+
} else if (!agentSock) {
|
|
565
|
+
const home = homedir3();
|
|
566
|
+
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")];
|
|
567
|
+
for (const keyPath of keyPaths) {
|
|
220
568
|
try {
|
|
221
|
-
connectConfig.privateKey =
|
|
569
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
222
570
|
break;
|
|
223
571
|
} catch {
|
|
224
572
|
}
|
|
@@ -226,92 +574,278 @@ function resolveConfig(config) {
|
|
|
226
574
|
}
|
|
227
575
|
return connectConfig;
|
|
228
576
|
}
|
|
229
|
-
function
|
|
577
|
+
function formatDiagnostics(host) {
|
|
578
|
+
try {
|
|
579
|
+
const checks = [
|
|
580
|
+
{ name: "SSH Agent", ...checkSshAgent() },
|
|
581
|
+
{ name: "SSH Keys", ...checkSshKeys() },
|
|
582
|
+
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
583
|
+
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
584
|
+
];
|
|
585
|
+
const parts = [];
|
|
586
|
+
const suggestions = [];
|
|
587
|
+
for (const check of checks) {
|
|
588
|
+
if (check.status !== "ok") {
|
|
589
|
+
parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const agent = checks[0];
|
|
593
|
+
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
594
|
+
if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
|
|
595
|
+
const keys = checks[1];
|
|
596
|
+
if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
|
|
597
|
+
const known = checks[3];
|
|
598
|
+
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
599
|
+
if (suggestions.length > 0) {
|
|
600
|
+
parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
|
|
601
|
+
}
|
|
602
|
+
return parts.length > 0 ? parts.join("\n") : "";
|
|
603
|
+
} catch {
|
|
604
|
+
return "";
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function connectRaw(connectConfig) {
|
|
230
608
|
return new Promise((resolve, reject) => {
|
|
231
609
|
const client = new Client();
|
|
232
|
-
const connectConfig = resolveConfig(config);
|
|
233
610
|
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
234
611
|
});
|
|
235
612
|
}
|
|
613
|
+
async function connect(config) {
|
|
614
|
+
const connectConfig = resolveConfig(config);
|
|
615
|
+
try {
|
|
616
|
+
return await connectRaw(connectConfig);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
const diag = formatDiagnostics(config.host);
|
|
619
|
+
if (diag) {
|
|
620
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
621
|
+
const enhanced = new Error(`${message}
|
|
622
|
+
|
|
623
|
+
SSH Diagnostics:
|
|
624
|
+
${diag}`);
|
|
625
|
+
enhanced.cause = err;
|
|
626
|
+
throw enhanced;
|
|
627
|
+
}
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
236
631
|
function exec(client, command, timeoutMs = 3e4) {
|
|
237
632
|
return new Promise((resolve, reject) => {
|
|
633
|
+
let settled = false;
|
|
634
|
+
const settle = (fn) => {
|
|
635
|
+
if (settled) return;
|
|
636
|
+
settled = true;
|
|
637
|
+
clearTimeout(timer);
|
|
638
|
+
fn();
|
|
639
|
+
};
|
|
238
640
|
const timer = setTimeout(() => {
|
|
239
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
641
|
+
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
240
642
|
}, timeoutMs);
|
|
241
643
|
client.exec(command, (err, stream) => {
|
|
242
644
|
if (err) {
|
|
243
|
-
|
|
244
|
-
return
|
|
645
|
+
settle(() => reject(err));
|
|
646
|
+
return;
|
|
245
647
|
}
|
|
246
648
|
let stdout = "";
|
|
247
649
|
let stderr = "";
|
|
248
650
|
stream.on("close", (code) => {
|
|
249
|
-
|
|
250
|
-
resolve({ stdout, stderr, code: code ?? 0 });
|
|
651
|
+
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
251
652
|
}).on("data", (data) => {
|
|
252
653
|
stdout += data.toString();
|
|
253
|
-
}).
|
|
654
|
+
}).on("error", (err2) => {
|
|
655
|
+
settle(() => reject(err2));
|
|
656
|
+
});
|
|
657
|
+
stream.stderr.on("data", (data) => {
|
|
254
658
|
stderr += data.toString();
|
|
659
|
+
}).on("error", (err2) => {
|
|
660
|
+
settle(() => reject(err2));
|
|
255
661
|
});
|
|
256
662
|
});
|
|
257
663
|
});
|
|
258
664
|
}
|
|
259
|
-
function
|
|
665
|
+
function getSftp(client) {
|
|
260
666
|
return new Promise((resolve, reject) => {
|
|
261
667
|
client.sftp((err, sftp) => {
|
|
262
668
|
if (err) return reject(err);
|
|
263
|
-
sftp
|
|
264
|
-
|
|
669
|
+
resolve(sftp);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
async function readFile(client, remotePath) {
|
|
674
|
+
const sftp = await getSftp(client);
|
|
675
|
+
try {
|
|
676
|
+
return await new Promise((resolve, reject) => {
|
|
677
|
+
sftp.readFile(remotePath, (err, data) => {
|
|
678
|
+
if (err) return reject(err);
|
|
265
679
|
resolve(data.toString("utf8"));
|
|
266
680
|
});
|
|
267
681
|
});
|
|
268
|
-
}
|
|
682
|
+
} finally {
|
|
683
|
+
sftp.end();
|
|
684
|
+
}
|
|
269
685
|
}
|
|
270
|
-
function writeFile(client, remotePath, content) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
sftp.writeFile(remotePath, content, (
|
|
275
|
-
if (
|
|
686
|
+
async function writeFile(client, remotePath, content) {
|
|
687
|
+
const sftp = await getSftp(client);
|
|
688
|
+
try {
|
|
689
|
+
await new Promise((resolve, reject) => {
|
|
690
|
+
sftp.writeFile(remotePath, content, (err) => {
|
|
691
|
+
if (err) return reject(err);
|
|
276
692
|
resolve();
|
|
277
693
|
});
|
|
278
694
|
});
|
|
279
|
-
}
|
|
695
|
+
} finally {
|
|
696
|
+
sftp.end();
|
|
697
|
+
}
|
|
280
698
|
}
|
|
281
|
-
function uploadFile(client, localPath, remotePath) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
sftp.fastPut(localPath, remotePath, (
|
|
286
|
-
if (
|
|
699
|
+
async function uploadFile(client, localPath, remotePath) {
|
|
700
|
+
const sftp = await getSftp(client);
|
|
701
|
+
try {
|
|
702
|
+
await new Promise((resolve, reject) => {
|
|
703
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
704
|
+
if (err) return reject(err);
|
|
287
705
|
resolve();
|
|
288
706
|
});
|
|
289
707
|
});
|
|
290
|
-
}
|
|
708
|
+
} finally {
|
|
709
|
+
sftp.end();
|
|
710
|
+
}
|
|
291
711
|
}
|
|
292
|
-
function downloadFile(client, remotePath, localPath) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
sftp.fastGet(remotePath, localPath, (
|
|
297
|
-
if (
|
|
712
|
+
async function downloadFile(client, remotePath, localPath) {
|
|
713
|
+
const sftp = await getSftp(client);
|
|
714
|
+
try {
|
|
715
|
+
await new Promise((resolve, reject) => {
|
|
716
|
+
sftp.fastGet(remotePath, localPath, (err) => {
|
|
717
|
+
if (err) return reject(err);
|
|
298
718
|
resolve();
|
|
299
719
|
});
|
|
300
720
|
});
|
|
301
|
-
}
|
|
721
|
+
} finally {
|
|
722
|
+
sftp.end();
|
|
723
|
+
}
|
|
302
724
|
}
|
|
303
|
-
function listDir(client, remotePath) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
sftp.readdir(remotePath, (
|
|
308
|
-
if (
|
|
725
|
+
async function listDir(client, remotePath) {
|
|
726
|
+
const sftp = await getSftp(client);
|
|
727
|
+
try {
|
|
728
|
+
return await new Promise((resolve, reject) => {
|
|
729
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
730
|
+
if (err) return reject(err);
|
|
309
731
|
resolve(list.map((item) => item.filename));
|
|
310
732
|
});
|
|
311
733
|
});
|
|
312
|
-
}
|
|
734
|
+
} finally {
|
|
735
|
+
sftp.end();
|
|
736
|
+
}
|
|
313
737
|
}
|
|
314
738
|
|
|
739
|
+
// src/pool.ts
|
|
740
|
+
var ConnectionPool = class {
|
|
741
|
+
entries = /* @__PURE__ */ new Map();
|
|
742
|
+
idleTtlMs;
|
|
743
|
+
constructor(options) {
|
|
744
|
+
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
745
|
+
}
|
|
746
|
+
async acquire(config) {
|
|
747
|
+
const connectConfig = resolveConfig(config);
|
|
748
|
+
const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
|
|
749
|
+
const existing = this.entries.get(key);
|
|
750
|
+
if (existing && !existing.dead) {
|
|
751
|
+
existing.refCount++;
|
|
752
|
+
if (existing.idleTimer) {
|
|
753
|
+
clearTimeout(existing.idleTimer);
|
|
754
|
+
existing.idleTimer = null;
|
|
755
|
+
}
|
|
756
|
+
return existing.client;
|
|
757
|
+
}
|
|
758
|
+
if (existing?.dead) {
|
|
759
|
+
this.entries.delete(key);
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
const client = await connectRaw(connectConfig);
|
|
763
|
+
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
764
|
+
const markDead = () => {
|
|
765
|
+
entry.dead = true;
|
|
766
|
+
if (entry.idleTimer) {
|
|
767
|
+
clearTimeout(entry.idleTimer);
|
|
768
|
+
entry.idleTimer = null;
|
|
769
|
+
}
|
|
770
|
+
if (this.entries.get(key) === entry) {
|
|
771
|
+
this.entries.delete(key);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
client.on("close", markDead);
|
|
775
|
+
client.on("end", markDead);
|
|
776
|
+
client.on("error", markDead);
|
|
777
|
+
this.entries.set(key, entry);
|
|
778
|
+
return client;
|
|
779
|
+
} catch (err) {
|
|
780
|
+
const diag = formatDiagnostics(config.host);
|
|
781
|
+
if (diag) {
|
|
782
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
783
|
+
const enhanced = new Error(`${message}
|
|
784
|
+
|
|
785
|
+
SSH Diagnostics:
|
|
786
|
+
${diag}`);
|
|
787
|
+
enhanced.cause = err;
|
|
788
|
+
throw enhanced;
|
|
789
|
+
}
|
|
790
|
+
throw err;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
release(client) {
|
|
794
|
+
for (const entry of this.entries.values()) {
|
|
795
|
+
if (entry.client === client) {
|
|
796
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
797
|
+
if (entry.refCount === 0 && !entry.dead) {
|
|
798
|
+
entry.idleTimer = setTimeout(() => {
|
|
799
|
+
try {
|
|
800
|
+
entry.client.end();
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
this.entries.delete(entry.key);
|
|
804
|
+
}, this.idleTtlMs);
|
|
805
|
+
entry.idleTimer.unref();
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
client.end();
|
|
812
|
+
} catch {
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async withConnection(config, fn) {
|
|
816
|
+
const client = await this.acquire(config);
|
|
817
|
+
try {
|
|
818
|
+
return await fn(client);
|
|
819
|
+
} finally {
|
|
820
|
+
this.release(client);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
drain() {
|
|
824
|
+
for (const entry of this.entries.values()) {
|
|
825
|
+
if (entry.idleTimer) {
|
|
826
|
+
clearTimeout(entry.idleTimer);
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
entry.client.end();
|
|
830
|
+
} catch {
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
this.entries.clear();
|
|
834
|
+
}
|
|
835
|
+
get size() {
|
|
836
|
+
return this.entries.size;
|
|
837
|
+
}
|
|
838
|
+
get stats() {
|
|
839
|
+
let active = 0;
|
|
840
|
+
let idle = 0;
|
|
841
|
+
for (const entry of this.entries.values()) {
|
|
842
|
+
if (entry.refCount > 0) active++;
|
|
843
|
+
else idle++;
|
|
844
|
+
}
|
|
845
|
+
return { active, idle };
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
315
849
|
// src/tools.ts
|
|
316
850
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
317
851
|
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
@@ -326,7 +860,8 @@ var connectionParams = {
|
|
|
326
860
|
privateKeyPath: KeyPathSchema,
|
|
327
861
|
password: PasswordSchema
|
|
328
862
|
};
|
|
329
|
-
function registerTools(server) {
|
|
863
|
+
function registerTools(server, pool) {
|
|
864
|
+
const connectionPool = pool ?? new ConnectionPool();
|
|
330
865
|
server.tool(
|
|
331
866
|
"ssh_exec",
|
|
332
867
|
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
@@ -336,8 +871,7 @@ function registerTools(server) {
|
|
|
336
871
|
timeout: TimeoutSchema
|
|
337
872
|
},
|
|
338
873
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
339
|
-
|
|
340
|
-
try {
|
|
874
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
341
875
|
const result = await exec(client, command, timeout || 3e4);
|
|
342
876
|
const parts = [];
|
|
343
877
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -345,9 +879,7 @@ function registerTools(server) {
|
|
|
345
879
|
${result.stderr}`);
|
|
346
880
|
parts.push(`[exit code: ${result.code}]`);
|
|
347
881
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
348
|
-
}
|
|
349
|
-
client.end();
|
|
350
|
-
}
|
|
882
|
+
});
|
|
351
883
|
}
|
|
352
884
|
);
|
|
353
885
|
server.tool(
|
|
@@ -358,13 +890,10 @@ ${result.stderr}`);
|
|
|
358
890
|
path: z.string().describe("Absolute path to the remote file")
|
|
359
891
|
},
|
|
360
892
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
361
|
-
|
|
362
|
-
try {
|
|
893
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
363
894
|
const content = await readFile(client, path);
|
|
364
895
|
return { content: [{ type: "text", text: content }] };
|
|
365
|
-
}
|
|
366
|
-
client.end();
|
|
367
|
-
}
|
|
896
|
+
});
|
|
368
897
|
}
|
|
369
898
|
);
|
|
370
899
|
server.tool(
|
|
@@ -376,13 +905,10 @@ ${result.stderr}`);
|
|
|
376
905
|
content: z.string().describe("File content to write")
|
|
377
906
|
},
|
|
378
907
|
async ({ host, port, username, privateKeyPath, password, path, content }) => {
|
|
379
|
-
|
|
380
|
-
try {
|
|
908
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
381
909
|
await writeFile(client, path, content);
|
|
382
910
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
383
|
-
}
|
|
384
|
-
client.end();
|
|
385
|
-
}
|
|
911
|
+
});
|
|
386
912
|
}
|
|
387
913
|
);
|
|
388
914
|
server.tool(
|
|
@@ -394,13 +920,10 @@ ${result.stderr}`);
|
|
|
394
920
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
395
921
|
},
|
|
396
922
|
async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
|
|
397
|
-
|
|
398
|
-
try {
|
|
923
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
399
924
|
await uploadFile(client, localPath, remotePath);
|
|
400
925
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
401
|
-
}
|
|
402
|
-
client.end();
|
|
403
|
-
}
|
|
926
|
+
});
|
|
404
927
|
}
|
|
405
928
|
);
|
|
406
929
|
server.tool(
|
|
@@ -412,13 +935,10 @@ ${result.stderr}`);
|
|
|
412
935
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
413
936
|
},
|
|
414
937
|
async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
|
|
415
|
-
|
|
416
|
-
try {
|
|
938
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
417
939
|
await downloadFile(client, remotePath, localPath);
|
|
418
940
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
419
|
-
}
|
|
420
|
-
client.end();
|
|
421
|
-
}
|
|
941
|
+
});
|
|
422
942
|
}
|
|
423
943
|
);
|
|
424
944
|
server.tool(
|
|
@@ -429,13 +949,10 @@ ${result.stderr}`);
|
|
|
429
949
|
path: z.string().describe("Absolute path to the remote directory")
|
|
430
950
|
},
|
|
431
951
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
432
|
-
|
|
433
|
-
try {
|
|
952
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
434
953
|
const files = await listDir(client, path);
|
|
435
954
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
436
|
-
}
|
|
437
|
-
client.end();
|
|
438
|
-
}
|
|
955
|
+
});
|
|
439
956
|
}
|
|
440
957
|
);
|
|
441
958
|
server.tool(
|
|
@@ -466,31 +983,169 @@ ${result.stderr}`);
|
|
|
466
983
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
467
984
|
}
|
|
468
985
|
);
|
|
986
|
+
server.tool(
|
|
987
|
+
"ssh_agent_ensure",
|
|
988
|
+
"Ensure ssh-agent is running and reachable. Starts a new agent if needed and sets environment variables so subsequent SSH operations work. Use this FIRST when SSH operations fail with agent-related errors.",
|
|
989
|
+
{},
|
|
990
|
+
async () => {
|
|
991
|
+
const result = ensureAgent();
|
|
992
|
+
const lines = [];
|
|
993
|
+
lines.push(result.message);
|
|
994
|
+
if (result.socket) lines.push(`Socket: ${result.socket}`);
|
|
995
|
+
if (result.keys.length > 0) {
|
|
996
|
+
lines.push("Loaded keys:");
|
|
997
|
+
for (const k of result.keys) lines.push(` ${k}`);
|
|
998
|
+
}
|
|
999
|
+
if (result.env) {
|
|
1000
|
+
lines.push("Environment variables set in this session:");
|
|
1001
|
+
if (result.env.SSH_AUTH_SOCK) lines.push(` SSH_AUTH_SOCK=${result.env.SSH_AUTH_SOCK}`);
|
|
1002
|
+
if (result.env.SSH_AGENT_PID) lines.push(` SSH_AGENT_PID=${result.env.SSH_AGENT_PID}`);
|
|
1003
|
+
}
|
|
1004
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: !result.reachable };
|
|
1005
|
+
}
|
|
1006
|
+
);
|
|
1007
|
+
server.tool(
|
|
1008
|
+
"ssh_key_list",
|
|
1009
|
+
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded.",
|
|
1010
|
+
{},
|
|
1011
|
+
async () => {
|
|
1012
|
+
const keys = listSshKeys();
|
|
1013
|
+
if (keys.length === 0) {
|
|
1014
|
+
return {
|
|
1015
|
+
content: [
|
|
1016
|
+
{
|
|
1017
|
+
type: "text",
|
|
1018
|
+
text: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
|
|
1019
|
+
}
|
|
1020
|
+
]
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const lines = [`Found ${keys.length} SSH key(s):`, ""];
|
|
1024
|
+
for (const key of keys) {
|
|
1025
|
+
const status = key.loadedInAgent ? "LOADED" : "not loaded";
|
|
1026
|
+
lines.push(`${key.name} (${key.type}) [${status}]`);
|
|
1027
|
+
lines.push(` Path: ${key.path}`);
|
|
1028
|
+
if (key.fingerprint) lines.push(` Fingerprint: ${key.fingerprint}`);
|
|
1029
|
+
lines.push("");
|
|
1030
|
+
}
|
|
1031
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1032
|
+
}
|
|
1033
|
+
);
|
|
1034
|
+
server.tool(
|
|
1035
|
+
"ssh_key_load",
|
|
1036
|
+
"Load an SSH private key into the running agent. Ensures the agent is running first. Use this after ssh_key_list shows a key that is not loaded.",
|
|
1037
|
+
{
|
|
1038
|
+
keyPath: z.string().describe("Path to the SSH private key to load (e.g. ~/.ssh/id_ed25519)")
|
|
1039
|
+
},
|
|
1040
|
+
async ({ keyPath }) => {
|
|
1041
|
+
const result = loadKey(keyPath);
|
|
1042
|
+
return { content: [{ type: "text", text: result.message }], isError: result.status === "error" };
|
|
1043
|
+
}
|
|
1044
|
+
);
|
|
1045
|
+
server.tool(
|
|
1046
|
+
"ssh_config_lookup",
|
|
1047
|
+
"Resolve the effective SSH configuration for a host. Shows hostname, user, port, identity files, proxy settings, and all other options from ~/.ssh/config. Use this to understand how SSH will connect to a host.",
|
|
1048
|
+
{
|
|
1049
|
+
host: HostSchema
|
|
1050
|
+
},
|
|
1051
|
+
async ({ host }) => {
|
|
1052
|
+
const result = configLookup(host);
|
|
1053
|
+
if ("error" in result) {
|
|
1054
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
1055
|
+
}
|
|
1056
|
+
const lines = [`SSH config for "${host}":`, ""];
|
|
1057
|
+
lines.push(` Hostname: ${result.hostname}`);
|
|
1058
|
+
lines.push(` User: ${result.user}`);
|
|
1059
|
+
lines.push(` Port: ${result.port}`);
|
|
1060
|
+
if (result.identityFile.length > 0) {
|
|
1061
|
+
lines.push(` Identity files: ${result.identityFile.join(", ")}`);
|
|
1062
|
+
}
|
|
1063
|
+
if (result.proxyJump) lines.push(` ProxyJump: ${result.proxyJump}`);
|
|
1064
|
+
if (result.proxyCommand) lines.push(` ProxyCommand: ${result.proxyCommand}`);
|
|
1065
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1066
|
+
}
|
|
1067
|
+
);
|
|
1068
|
+
server.tool(
|
|
1069
|
+
"ssh_known_hosts_fix",
|
|
1070
|
+
"Remove a stale host key from known_hosts and re-scan the host to add the current key. Use this when you see 'Host key verification failed' errors, typically after a server has been recreated or reprovisioned.",
|
|
1071
|
+
{
|
|
1072
|
+
host: HostSchema,
|
|
1073
|
+
port: PortSchema
|
|
1074
|
+
},
|
|
1075
|
+
async ({ host, port }) => {
|
|
1076
|
+
const result = fixKnownHosts(host, port || 22);
|
|
1077
|
+
const lines = [result.message];
|
|
1078
|
+
if (result.actions.length > 0) {
|
|
1079
|
+
lines.push("");
|
|
1080
|
+
lines.push("Actions taken:");
|
|
1081
|
+
for (const a of result.actions) lines.push(` - ${a}`);
|
|
1082
|
+
}
|
|
1083
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
1084
|
+
}
|
|
1085
|
+
);
|
|
1086
|
+
server.tool(
|
|
1087
|
+
"ssh_test",
|
|
1088
|
+
"Quick connectivity test to an SSH host. Reports success/failure with timing and actionable error details. Lighter and faster than ssh_diagnose \u2014 use this for a quick check before running operations.",
|
|
1089
|
+
{
|
|
1090
|
+
host: HostSchema,
|
|
1091
|
+
port: PortSchema
|
|
1092
|
+
},
|
|
1093
|
+
async ({ host, port }) => {
|
|
1094
|
+
const result = testConnection(host, port || 22);
|
|
1095
|
+
return { content: [{ type: "text", text: result.message }], isError: result.status === "error" };
|
|
1096
|
+
}
|
|
1097
|
+
);
|
|
1098
|
+
server.tool(
|
|
1099
|
+
"ssh_git_check",
|
|
1100
|
+
"Test Git-over-SSH authentication to a hosting provider (GitHub, GitLab, Bitbucket, etc). Verifies your SSH key is registered and working. Use this when git clone/pull/push fails with SSH errors.",
|
|
1101
|
+
{
|
|
1102
|
+
host: z.string().optional().describe('Git hosting hostname (default: "github.com")'),
|
|
1103
|
+
user: z.string().optional().describe('SSH user for the git host (default: "git")')
|
|
1104
|
+
},
|
|
1105
|
+
async ({ host, user }) => {
|
|
1106
|
+
const result = checkGitSsh(host || "github.com", user || "git");
|
|
1107
|
+
const lines = [result.message];
|
|
1108
|
+
if (result.authenticatedAs) {
|
|
1109
|
+
lines.push(`Authenticated as: ${result.authenticatedAs}`);
|
|
1110
|
+
}
|
|
1111
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
1112
|
+
}
|
|
1113
|
+
);
|
|
469
1114
|
}
|
|
470
1115
|
|
|
471
1116
|
// src/server.ts
|
|
472
|
-
function createServer() {
|
|
1117
|
+
function createServer(pool) {
|
|
473
1118
|
const server = new McpServer({
|
|
474
1119
|
name: "ssh-mcp",
|
|
475
|
-
version: "0.
|
|
1120
|
+
version: "0.4.0"
|
|
476
1121
|
});
|
|
477
|
-
registerTools(server);
|
|
1122
|
+
registerTools(server, pool);
|
|
478
1123
|
return server;
|
|
479
1124
|
}
|
|
480
1125
|
export {
|
|
1126
|
+
ConnectionPool,
|
|
481
1127
|
checkConnectivity,
|
|
1128
|
+
checkGitSsh,
|
|
482
1129
|
checkKnownHosts,
|
|
483
1130
|
checkSshAgent,
|
|
484
1131
|
checkSshConfig,
|
|
485
1132
|
checkSshKeys,
|
|
1133
|
+
configLookup,
|
|
486
1134
|
connect,
|
|
1135
|
+
connectRaw,
|
|
487
1136
|
createServer,
|
|
488
1137
|
diagnose,
|
|
489
1138
|
downloadFile,
|
|
1139
|
+
ensureAgent,
|
|
490
1140
|
exec,
|
|
1141
|
+
fixKnownHosts,
|
|
491
1142
|
listDir,
|
|
1143
|
+
listSshKeys,
|
|
1144
|
+
loadKey,
|
|
492
1145
|
readFile,
|
|
493
1146
|
registerTools,
|
|
1147
|
+
resolveConfig,
|
|
1148
|
+
testConnection,
|
|
494
1149
|
uploadFile,
|
|
495
1150
|
writeFile
|
|
496
1151
|
};
|