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