@yawlabs/ssh-mcp 0.3.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 +26 -5
- package/dist/index.js +360 -238
- package/dist/server.d.ts +44 -23
- package/dist/server.js +192 -58
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,6 +77,14 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
|
|
|
77
77
|
|
|
78
78
|
When any remote operation fails, ssh-mcp automatically runs diagnostics and includes the results in the error response. Your agent doesn't need to call `ssh_diagnose` separately — it gets told what's wrong and how to fix it right in the error message.
|
|
79
79
|
|
|
80
|
+
### Connection pooling
|
|
81
|
+
|
|
82
|
+
Remote operations reuse SSH connections automatically. When your agent makes multiple calls to the same host, the first call opens a connection and subsequent calls reuse it. Connections are kept alive for 60 seconds after the last use, then closed automatically.
|
|
83
|
+
|
|
84
|
+
### SSH config support
|
|
85
|
+
|
|
86
|
+
All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, and identity files from your SSH config are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves the real hostname, user, port, and identity file.
|
|
87
|
+
|
|
80
88
|
## Authentication
|
|
81
89
|
|
|
82
90
|
All remote operations accept connection parameters:
|
|
@@ -84,12 +92,12 @@ All remote operations accept connection parameters:
|
|
|
84
92
|
| Parameter | Description | Default |
|
|
85
93
|
|-----------|-------------|---------|
|
|
86
94
|
| `host` | SSH hostname or IP (required) | — |
|
|
87
|
-
| `port` | SSH port | `22` |
|
|
88
|
-
| `username` | SSH username |
|
|
95
|
+
| `port` | SSH port | From SSH config or `22` |
|
|
96
|
+
| `username` | SSH username | From SSH config or current user |
|
|
89
97
|
| `privateKeyPath` | Path to SSH private key | Auto-detect |
|
|
90
98
|
| `password` | SSH password (prefer keys) | — |
|
|
91
99
|
|
|
92
|
-
**Auth resolution order:** explicit key > explicit password > ssh-agent (`SSH_AUTH_SOCK`) > default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`).
|
|
100
|
+
**Auth resolution order:** explicit key > explicit password > ssh-agent (`SSH_AUTH_SOCK`) > SSH config identity files > default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`).
|
|
93
101
|
|
|
94
102
|
## Example workflows
|
|
95
103
|
|
|
@@ -123,7 +131,7 @@ Agent reports: "SSH server isn't running on new-server or port 22 is blocked"
|
|
|
123
131
|
## Programmatic usage
|
|
124
132
|
|
|
125
133
|
```typescript
|
|
126
|
-
import { connect, exec, diagnose, ensureAgent, listSshKeys, checkGitSsh } from '@yawlabs/ssh-mcp';
|
|
134
|
+
import { connect, exec, diagnose, ensureAgent, listSshKeys, checkGitSsh, ConnectionPool } from '@yawlabs/ssh-mcp';
|
|
127
135
|
|
|
128
136
|
// Fix SSH environment
|
|
129
137
|
const agent = ensureAgent();
|
|
@@ -139,12 +147,25 @@ for (const key of keys) {
|
|
|
139
147
|
console.log(`${key.name} (${key.type}) - ${key.loadedInAgent ? 'loaded' : 'not loaded'}`);
|
|
140
148
|
}
|
|
141
149
|
|
|
142
|
-
// Run a remote command
|
|
150
|
+
// Run a remote command (one-off)
|
|
143
151
|
const client = await connect({ host: 'my-server', username: 'deploy' });
|
|
144
152
|
const result = await exec(client, 'uptime');
|
|
145
153
|
console.log(result.stdout);
|
|
146
154
|
client.end();
|
|
147
155
|
|
|
156
|
+
// Run multiple commands with connection pooling
|
|
157
|
+
const pool = new ConnectionPool();
|
|
158
|
+
await pool.withConnection({ host: 'my-server' }, async (client) => {
|
|
159
|
+
const r1 = await exec(client, 'uptime');
|
|
160
|
+
console.log(r1.stdout);
|
|
161
|
+
});
|
|
162
|
+
// Connection stays open for 60s — next call reuses it
|
|
163
|
+
await pool.withConnection({ host: 'my-server' }, async (client) => {
|
|
164
|
+
const r2 = await exec(client, 'df -h');
|
|
165
|
+
console.log(r2.stdout);
|
|
166
|
+
});
|
|
167
|
+
pool.drain(); // close all connections when done
|
|
168
|
+
|
|
148
169
|
// Diagnose issues
|
|
149
170
|
const report = diagnose('my-server');
|
|
150
171
|
console.log(report.overall); // "ok" | "warning" | "error"
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
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
13
|
import { execFileSync } from "child_process";
|
|
@@ -238,10 +238,330 @@ function diagnose(host, port = 22) {
|
|
|
238
238
|
return { overall, checks, suggestions };
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// src/ssh.ts
|
|
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
|
+
}
|
|
270
|
+
function resolveConfig(config) {
|
|
271
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
272
|
+
const connectConfig = {
|
|
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
|
|
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
|
+
}
|
|
286
|
+
if (config.privateKeyPath) {
|
|
287
|
+
connectConfig.privateKey = readFileSync2(config.privateKeyPath);
|
|
288
|
+
} else if (!agentSock) {
|
|
289
|
+
const home = homedir2();
|
|
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) {
|
|
292
|
+
try {
|
|
293
|
+
connectConfig.privateKey = readFileSync2(keyPath);
|
|
294
|
+
break;
|
|
295
|
+
} catch {
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return connectConfig;
|
|
300
|
+
}
|
|
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) {
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
const client = new Client();
|
|
334
|
+
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function exec(client, command, timeoutMs = 3e4) {
|
|
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
|
+
};
|
|
346
|
+
const timer = setTimeout(() => {
|
|
347
|
+
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
348
|
+
}, timeoutMs);
|
|
349
|
+
client.exec(command, (err, stream) => {
|
|
350
|
+
if (err) {
|
|
351
|
+
settle(() => reject(err));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
let stdout = "";
|
|
355
|
+
let stderr = "";
|
|
356
|
+
stream.on("close", (code) => {
|
|
357
|
+
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
358
|
+
}).on("data", (data) => {
|
|
359
|
+
stdout += data.toString();
|
|
360
|
+
}).on("error", (err2) => {
|
|
361
|
+
settle(() => reject(err2));
|
|
362
|
+
});
|
|
363
|
+
stream.stderr.on("data", (data) => {
|
|
364
|
+
stderr += data.toString();
|
|
365
|
+
}).on("error", (err2) => {
|
|
366
|
+
settle(() => reject(err2));
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function getSftp(client) {
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
client.sftp((err, sftp) => {
|
|
374
|
+
if (err) return reject(err);
|
|
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);
|
|
385
|
+
resolve(data.toString("utf8"));
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
} finally {
|
|
389
|
+
sftp.end();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
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);
|
|
398
|
+
resolve();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
} finally {
|
|
402
|
+
sftp.end();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
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);
|
|
411
|
+
resolve();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
} finally {
|
|
415
|
+
sftp.end();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
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);
|
|
424
|
+
resolve();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
} finally {
|
|
428
|
+
sftp.end();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
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);
|
|
437
|
+
resolve(list.map((item) => item.filename));
|
|
438
|
+
});
|
|
439
|
+
});
|
|
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
|
+
|
|
241
561
|
// src/env.ts
|
|
242
|
-
import { appendFileSync, existsSync as existsSync2, readFileSync as
|
|
243
|
-
import { homedir as
|
|
244
|
-
import { join as
|
|
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";
|
|
245
565
|
function ensureAgent() {
|
|
246
566
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
247
567
|
if (sock) {
|
|
@@ -289,7 +609,7 @@ function detectKeyType(filePath, fileName) {
|
|
|
289
609
|
const pubPath = `${filePath}.pub`;
|
|
290
610
|
if (existsSync2(pubPath)) {
|
|
291
611
|
try {
|
|
292
|
-
const pub =
|
|
612
|
+
const pub = readFileSync3(pubPath, "utf8");
|
|
293
613
|
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
294
614
|
if (pub.includes("ssh-rsa")) return "rsa";
|
|
295
615
|
if (pub.includes("ecdsa")) return "ecdsa";
|
|
@@ -302,7 +622,7 @@ function detectKeyType(filePath, fileName) {
|
|
|
302
622
|
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
303
623
|
if (fileName.includes("dsa")) return "dsa";
|
|
304
624
|
try {
|
|
305
|
-
const content =
|
|
625
|
+
const content = readFileSync3(filePath, "utf8");
|
|
306
626
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
307
627
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
308
628
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
@@ -311,7 +631,7 @@ function detectKeyType(filePath, fileName) {
|
|
|
311
631
|
return "unknown";
|
|
312
632
|
}
|
|
313
633
|
function listSshKeys() {
|
|
314
|
-
const sshDir =
|
|
634
|
+
const sshDir = join3(homedir3(), ".ssh");
|
|
315
635
|
if (!existsSync2(sshDir)) return [];
|
|
316
636
|
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
317
637
|
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
@@ -331,11 +651,11 @@ function listSshKeys() {
|
|
|
331
651
|
}
|
|
332
652
|
for (const file of files) {
|
|
333
653
|
if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
|
|
334
|
-
const filePath =
|
|
654
|
+
const filePath = join3(sshDir, file);
|
|
335
655
|
try {
|
|
336
656
|
const stat = statSync(filePath);
|
|
337
657
|
if (!stat.isFile()) continue;
|
|
338
|
-
const content =
|
|
658
|
+
const content = readFileSync3(filePath, "utf8");
|
|
339
659
|
if (!content.includes("PRIVATE KEY")) continue;
|
|
340
660
|
const type = detectKeyType(filePath, file);
|
|
341
661
|
let fingerprint;
|
|
@@ -356,7 +676,7 @@ function loadKey(keyPath) {
|
|
|
356
676
|
if (!agent.reachable) {
|
|
357
677
|
return { status: "error", message: agent.message };
|
|
358
678
|
}
|
|
359
|
-
const resolved = keyPath.startsWith("~") ?
|
|
679
|
+
const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
|
|
360
680
|
if (!existsSync2(resolved)) {
|
|
361
681
|
return { status: "error", message: `Key not found: ${resolved}` };
|
|
362
682
|
}
|
|
@@ -422,7 +742,7 @@ function fixKnownHosts(host, port = 22) {
|
|
|
422
742
|
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
423
743
|
if (scanOk && scanOut.trim()) {
|
|
424
744
|
try {
|
|
425
|
-
const knownHostsPath =
|
|
745
|
+
const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
|
|
426
746
|
appendFileSync(knownHostsPath, `
|
|
427
747
|
${scanOut.trim()}
|
|
428
748
|
`);
|
|
@@ -515,194 +835,6 @@ function testConnection(host, port = 22) {
|
|
|
515
835
|
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
516
836
|
}
|
|
517
837
|
|
|
518
|
-
// src/ssh.ts
|
|
519
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
520
|
-
import { homedir as homedir3 } from "os";
|
|
521
|
-
import { join as join3 } from "path";
|
|
522
|
-
import { Client } from "ssh2";
|
|
523
|
-
function resolveConfig(config) {
|
|
524
|
-
const connectConfig = {
|
|
525
|
-
host: config.host,
|
|
526
|
-
port: config.port || 22,
|
|
527
|
-
username: config.username || process.env.USER || process.env.USERNAME || "root"
|
|
528
|
-
};
|
|
529
|
-
if (config.privateKeyPath) {
|
|
530
|
-
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
531
|
-
} else if (config.password) {
|
|
532
|
-
connectConfig.password = config.password;
|
|
533
|
-
} else if (config.agent || process.env.SSH_AUTH_SOCK) {
|
|
534
|
-
connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
|
|
535
|
-
} else {
|
|
536
|
-
const home = homedir3();
|
|
537
|
-
const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
|
|
538
|
-
for (const keyName of defaultKeys) {
|
|
539
|
-
const keyPath = join3(home, ".ssh", keyName);
|
|
540
|
-
try {
|
|
541
|
-
connectConfig.privateKey = readFileSync3(keyPath);
|
|
542
|
-
break;
|
|
543
|
-
} catch {
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
return connectConfig;
|
|
548
|
-
}
|
|
549
|
-
function formatDiagnostics(host) {
|
|
550
|
-
try {
|
|
551
|
-
const checks = [
|
|
552
|
-
{ name: "SSH Agent", ...checkSshAgent() },
|
|
553
|
-
{ name: "SSH Keys", ...checkSshKeys() },
|
|
554
|
-
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
555
|
-
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
556
|
-
];
|
|
557
|
-
const parts = [];
|
|
558
|
-
const suggestions = [];
|
|
559
|
-
for (const check of checks) {
|
|
560
|
-
if (check.status !== "ok") {
|
|
561
|
-
parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
const agent = checks[0];
|
|
565
|
-
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
566
|
-
if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
|
|
567
|
-
const keys = checks[1];
|
|
568
|
-
if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
|
|
569
|
-
const known = checks[3];
|
|
570
|
-
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
571
|
-
if (suggestions.length > 0) {
|
|
572
|
-
parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
|
|
573
|
-
}
|
|
574
|
-
return parts.length > 0 ? parts.join("\n") : "";
|
|
575
|
-
} catch {
|
|
576
|
-
return "";
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
function connect(config) {
|
|
580
|
-
return new Promise((resolve, reject) => {
|
|
581
|
-
const client = new Client();
|
|
582
|
-
const connectConfig = resolveConfig(config);
|
|
583
|
-
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
584
|
-
const diag = formatDiagnostics(config.host);
|
|
585
|
-
if (diag) {
|
|
586
|
-
const enhanced = new Error(`${err.message}
|
|
587
|
-
|
|
588
|
-
SSH Diagnostics:
|
|
589
|
-
${diag}`);
|
|
590
|
-
enhanced.cause = err;
|
|
591
|
-
reject(enhanced);
|
|
592
|
-
} else {
|
|
593
|
-
reject(err);
|
|
594
|
-
}
|
|
595
|
-
}).connect(connectConfig);
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
function exec(client, command, timeoutMs = 3e4) {
|
|
599
|
-
return new Promise((resolve, reject) => {
|
|
600
|
-
let settled = false;
|
|
601
|
-
const settle = (fn) => {
|
|
602
|
-
if (settled) return;
|
|
603
|
-
settled = true;
|
|
604
|
-
clearTimeout(timer);
|
|
605
|
-
fn();
|
|
606
|
-
};
|
|
607
|
-
const timer = setTimeout(() => {
|
|
608
|
-
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
609
|
-
}, timeoutMs);
|
|
610
|
-
client.exec(command, (err, stream) => {
|
|
611
|
-
if (err) {
|
|
612
|
-
settle(() => reject(err));
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
let stdout = "";
|
|
616
|
-
let stderr = "";
|
|
617
|
-
stream.on("close", (code) => {
|
|
618
|
-
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
619
|
-
}).on("data", (data) => {
|
|
620
|
-
stdout += data.toString();
|
|
621
|
-
}).on("error", (err2) => {
|
|
622
|
-
settle(() => reject(err2));
|
|
623
|
-
});
|
|
624
|
-
stream.stderr.on("data", (data) => {
|
|
625
|
-
stderr += data.toString();
|
|
626
|
-
}).on("error", (err2) => {
|
|
627
|
-
settle(() => reject(err2));
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
function getSftp(client) {
|
|
633
|
-
return new Promise((resolve, reject) => {
|
|
634
|
-
client.sftp((err, sftp) => {
|
|
635
|
-
if (err) return reject(err);
|
|
636
|
-
resolve(sftp);
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
async function readFile(client, remotePath) {
|
|
641
|
-
const sftp = await getSftp(client);
|
|
642
|
-
try {
|
|
643
|
-
return await new Promise((resolve, reject) => {
|
|
644
|
-
sftp.readFile(remotePath, (err, data) => {
|
|
645
|
-
if (err) return reject(err);
|
|
646
|
-
resolve(data.toString("utf8"));
|
|
647
|
-
});
|
|
648
|
-
});
|
|
649
|
-
} finally {
|
|
650
|
-
sftp.end();
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
async function writeFile(client, remotePath, content) {
|
|
654
|
-
const sftp = await getSftp(client);
|
|
655
|
-
try {
|
|
656
|
-
await new Promise((resolve, reject) => {
|
|
657
|
-
sftp.writeFile(remotePath, content, (err) => {
|
|
658
|
-
if (err) return reject(err);
|
|
659
|
-
resolve();
|
|
660
|
-
});
|
|
661
|
-
});
|
|
662
|
-
} finally {
|
|
663
|
-
sftp.end();
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
async function uploadFile(client, localPath, remotePath) {
|
|
667
|
-
const sftp = await getSftp(client);
|
|
668
|
-
try {
|
|
669
|
-
await new Promise((resolve, reject) => {
|
|
670
|
-
sftp.fastPut(localPath, remotePath, (err) => {
|
|
671
|
-
if (err) return reject(err);
|
|
672
|
-
resolve();
|
|
673
|
-
});
|
|
674
|
-
});
|
|
675
|
-
} finally {
|
|
676
|
-
sftp.end();
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
async function downloadFile(client, remotePath, localPath) {
|
|
680
|
-
const sftp = await getSftp(client);
|
|
681
|
-
try {
|
|
682
|
-
await new Promise((resolve, reject) => {
|
|
683
|
-
sftp.fastGet(remotePath, localPath, (err) => {
|
|
684
|
-
if (err) return reject(err);
|
|
685
|
-
resolve();
|
|
686
|
-
});
|
|
687
|
-
});
|
|
688
|
-
} finally {
|
|
689
|
-
sftp.end();
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
async function listDir(client, remotePath) {
|
|
693
|
-
const sftp = await getSftp(client);
|
|
694
|
-
try {
|
|
695
|
-
return await new Promise((resolve, reject) => {
|
|
696
|
-
sftp.readdir(remotePath, (err, list) => {
|
|
697
|
-
if (err) return reject(err);
|
|
698
|
-
resolve(list.map((item) => item.filename));
|
|
699
|
-
});
|
|
700
|
-
});
|
|
701
|
-
} finally {
|
|
702
|
-
sftp.end();
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
838
|
// src/tools.ts
|
|
707
839
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
708
840
|
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
@@ -717,7 +849,8 @@ var connectionParams = {
|
|
|
717
849
|
privateKeyPath: KeyPathSchema,
|
|
718
850
|
password: PasswordSchema
|
|
719
851
|
};
|
|
720
|
-
function registerTools(server) {
|
|
852
|
+
function registerTools(server, pool) {
|
|
853
|
+
const connectionPool = pool ?? new ConnectionPool();
|
|
721
854
|
server.tool(
|
|
722
855
|
"ssh_exec",
|
|
723
856
|
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
@@ -727,8 +860,7 @@ function registerTools(server) {
|
|
|
727
860
|
timeout: TimeoutSchema
|
|
728
861
|
},
|
|
729
862
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
730
|
-
|
|
731
|
-
try {
|
|
863
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
732
864
|
const result = await exec(client, command, timeout || 3e4);
|
|
733
865
|
const parts = [];
|
|
734
866
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -736,9 +868,7 @@ function registerTools(server) {
|
|
|
736
868
|
${result.stderr}`);
|
|
737
869
|
parts.push(`[exit code: ${result.code}]`);
|
|
738
870
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
739
|
-
}
|
|
740
|
-
client.end();
|
|
741
|
-
}
|
|
871
|
+
});
|
|
742
872
|
}
|
|
743
873
|
);
|
|
744
874
|
server.tool(
|
|
@@ -749,13 +879,10 @@ ${result.stderr}`);
|
|
|
749
879
|
path: z.string().describe("Absolute path to the remote file")
|
|
750
880
|
},
|
|
751
881
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
752
|
-
|
|
753
|
-
try {
|
|
882
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
754
883
|
const content = await readFile(client, path);
|
|
755
884
|
return { content: [{ type: "text", text: content }] };
|
|
756
|
-
}
|
|
757
|
-
client.end();
|
|
758
|
-
}
|
|
885
|
+
});
|
|
759
886
|
}
|
|
760
887
|
);
|
|
761
888
|
server.tool(
|
|
@@ -767,13 +894,10 @@ ${result.stderr}`);
|
|
|
767
894
|
content: z.string().describe("File content to write")
|
|
768
895
|
},
|
|
769
896
|
async ({ host, port, username, privateKeyPath, password, path, content }) => {
|
|
770
|
-
|
|
771
|
-
try {
|
|
897
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
772
898
|
await writeFile(client, path, content);
|
|
773
899
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
774
|
-
}
|
|
775
|
-
client.end();
|
|
776
|
-
}
|
|
900
|
+
});
|
|
777
901
|
}
|
|
778
902
|
);
|
|
779
903
|
server.tool(
|
|
@@ -785,13 +909,10 @@ ${result.stderr}`);
|
|
|
785
909
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
786
910
|
},
|
|
787
911
|
async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
|
|
788
|
-
|
|
789
|
-
try {
|
|
912
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
790
913
|
await uploadFile(client, localPath, remotePath);
|
|
791
914
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
792
|
-
}
|
|
793
|
-
client.end();
|
|
794
|
-
}
|
|
915
|
+
});
|
|
795
916
|
}
|
|
796
917
|
);
|
|
797
918
|
server.tool(
|
|
@@ -803,13 +924,10 @@ ${result.stderr}`);
|
|
|
803
924
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
804
925
|
},
|
|
805
926
|
async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
|
|
806
|
-
|
|
807
|
-
try {
|
|
927
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
808
928
|
await downloadFile(client, remotePath, localPath);
|
|
809
929
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
810
|
-
}
|
|
811
|
-
client.end();
|
|
812
|
-
}
|
|
930
|
+
});
|
|
813
931
|
}
|
|
814
932
|
);
|
|
815
933
|
server.tool(
|
|
@@ -820,13 +938,10 @@ ${result.stderr}`);
|
|
|
820
938
|
path: z.string().describe("Absolute path to the remote directory")
|
|
821
939
|
},
|
|
822
940
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
823
|
-
|
|
824
|
-
try {
|
|
941
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
825
942
|
const files = await listDir(client, path);
|
|
826
943
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
827
|
-
}
|
|
828
|
-
client.end();
|
|
829
|
-
}
|
|
944
|
+
});
|
|
830
945
|
}
|
|
831
946
|
);
|
|
832
947
|
server.tool(
|
|
@@ -988,19 +1103,26 @@ ${result.stderr}`);
|
|
|
988
1103
|
}
|
|
989
1104
|
|
|
990
1105
|
// src/server.ts
|
|
991
|
-
function createServer() {
|
|
1106
|
+
function createServer(pool) {
|
|
992
1107
|
const server = new McpServer({
|
|
993
1108
|
name: "ssh-mcp",
|
|
994
|
-
version: "0.
|
|
1109
|
+
version: "0.4.0"
|
|
995
1110
|
});
|
|
996
|
-
registerTools(server);
|
|
1111
|
+
registerTools(server, pool);
|
|
997
1112
|
return server;
|
|
998
1113
|
}
|
|
999
1114
|
|
|
1000
1115
|
// src/index.ts
|
|
1001
1116
|
async function main() {
|
|
1002
|
-
const
|
|
1117
|
+
const pool = new ConnectionPool();
|
|
1118
|
+
const server = createServer(pool);
|
|
1003
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);
|
|
1004
1126
|
await server.connect(transport);
|
|
1005
1127
|
}
|
|
1006
1128
|
main().catch((err) => {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,25 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { Client } from 'ssh2';
|
|
3
|
-
|
|
4
|
-
declare function registerTools(server: McpServer): void;
|
|
5
|
-
|
|
6
|
-
interface DiagnosticResult {
|
|
7
|
-
status: "ok" | "warning" | "error";
|
|
8
|
-
message: string;
|
|
9
|
-
}
|
|
10
|
-
interface DiagnosticReport {
|
|
11
|
-
overall: "ok" | "warning" | "error";
|
|
12
|
-
checks: Array<{
|
|
13
|
-
name: string;
|
|
14
|
-
} & DiagnosticResult>;
|
|
15
|
-
suggestions: string[];
|
|
16
|
-
}
|
|
17
|
-
declare function checkSshAgent(): DiagnosticResult;
|
|
18
|
-
declare function checkSshKeys(): DiagnosticResult;
|
|
19
|
-
declare function checkKnownHosts(host: string): DiagnosticResult;
|
|
20
|
-
declare function checkConnectivity(host: string, port?: number): DiagnosticResult;
|
|
21
|
-
declare function checkSshConfig(host: string): DiagnosticResult;
|
|
22
|
-
declare function diagnose(host: string, port?: number): DiagnosticReport;
|
|
2
|
+
import { Client, ConnectConfig } from 'ssh2';
|
|
23
3
|
|
|
24
4
|
interface SSHConfig {
|
|
25
5
|
host: string;
|
|
@@ -34,6 +14,8 @@ interface ExecResult {
|
|
|
34
14
|
stderr: string;
|
|
35
15
|
code: number;
|
|
36
16
|
}
|
|
17
|
+
declare function resolveConfig(config: SSHConfig): ConnectConfig;
|
|
18
|
+
declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
37
19
|
declare function connect(config: SSHConfig): Promise<Client>;
|
|
38
20
|
declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
39
21
|
declare function readFile(client: Client, remotePath: string): Promise<string>;
|
|
@@ -42,6 +24,45 @@ declare function uploadFile(client: Client, localPath: string, remotePath: strin
|
|
|
42
24
|
declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
|
43
25
|
declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
44
26
|
|
|
27
|
+
interface PoolOptions {
|
|
28
|
+
/** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
|
|
29
|
+
idleTtlMs?: number;
|
|
30
|
+
}
|
|
31
|
+
declare class ConnectionPool {
|
|
32
|
+
private entries;
|
|
33
|
+
private idleTtlMs;
|
|
34
|
+
constructor(options?: PoolOptions);
|
|
35
|
+
acquire(config: SSHConfig): Promise<Client>;
|
|
36
|
+
release(client: Client): void;
|
|
37
|
+
withConnection<T>(config: SSHConfig, fn: (client: Client) => Promise<T>): Promise<T>;
|
|
38
|
+
drain(): void;
|
|
39
|
+
get size(): number;
|
|
40
|
+
get stats(): {
|
|
41
|
+
active: number;
|
|
42
|
+
idle: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
|
47
|
+
|
|
48
|
+
interface DiagnosticResult {
|
|
49
|
+
status: "ok" | "warning" | "error";
|
|
50
|
+
message: string;
|
|
51
|
+
}
|
|
52
|
+
interface DiagnosticReport {
|
|
53
|
+
overall: "ok" | "warning" | "error";
|
|
54
|
+
checks: Array<{
|
|
55
|
+
name: string;
|
|
56
|
+
} & DiagnosticResult>;
|
|
57
|
+
suggestions: string[];
|
|
58
|
+
}
|
|
59
|
+
declare function checkSshAgent(): DiagnosticResult;
|
|
60
|
+
declare function checkSshKeys(): DiagnosticResult;
|
|
61
|
+
declare function checkKnownHosts(host: string): DiagnosticResult;
|
|
62
|
+
declare function checkConnectivity(host: string, port?: number): DiagnosticResult;
|
|
63
|
+
declare function checkSshConfig(host: string): DiagnosticResult;
|
|
64
|
+
declare function diagnose(host: string, port?: number): DiagnosticReport;
|
|
65
|
+
|
|
45
66
|
interface KeyInfo {
|
|
46
67
|
name: string;
|
|
47
68
|
path: string;
|
|
@@ -95,6 +116,6 @@ declare function testConnection(host: string, port?: number): {
|
|
|
95
116
|
message: string;
|
|
96
117
|
};
|
|
97
118
|
|
|
98
|
-
declare function createServer(): McpServer;
|
|
119
|
+
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
99
120
|
|
|
100
|
-
export { type AgentResult, type ConfigLookupResult, type DiagnosticReport, type DiagnosticResult, type ExecResult, type KeyInfo, type SSHConfig, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, createServer, diagnose, downloadFile, ensureAgent, exec, fixKnownHosts, listDir, listSshKeys, loadKey, readFile, registerTools, testConnection, uploadFile, writeFile };
|
|
121
|
+
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type KeyInfo, type PoolOptions, type SSHConfig, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, createServer, diagnose, downloadFile, ensureAgent, exec, fixKnownHosts, listDir, listSshKeys, loadKey, readFile, registerTools, resolveConfig, testConnection, uploadFile, writeFile };
|
package/dist/server.js
CHANGED
|
@@ -515,23 +515,56 @@ import { readFileSync as readFileSync3 } from "fs";
|
|
|
515
515
|
import { homedir as homedir3 } from "os";
|
|
516
516
|
import { join as join3 } from "path";
|
|
517
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
|
+
}
|
|
518
546
|
function resolveConfig(config) {
|
|
547
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
519
548
|
const connectConfig = {
|
|
520
|
-
host: config.host,
|
|
521
|
-
port: config.port || 22,
|
|
522
|
-
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
|
|
523
554
|
};
|
|
555
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
|
|
556
|
+
if (agentSock) {
|
|
557
|
+
connectConfig.agent = agentSock;
|
|
558
|
+
}
|
|
559
|
+
if (config.password) {
|
|
560
|
+
connectConfig.password = config.password;
|
|
561
|
+
}
|
|
524
562
|
if (config.privateKeyPath) {
|
|
525
563
|
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
526
|
-
} else if (
|
|
527
|
-
connectConfig.password = config.password;
|
|
528
|
-
} else if (config.agent || process.env.SSH_AUTH_SOCK) {
|
|
529
|
-
connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
|
|
530
|
-
} else {
|
|
564
|
+
} else if (!agentSock) {
|
|
531
565
|
const home = homedir3();
|
|
532
|
-
const
|
|
533
|
-
for (const
|
|
534
|
-
const keyPath = join3(home, ".ssh", keyName);
|
|
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) {
|
|
535
568
|
try {
|
|
536
569
|
connectConfig.privateKey = readFileSync3(keyPath);
|
|
537
570
|
break;
|
|
@@ -571,24 +604,29 @@ function formatDiagnostics(host) {
|
|
|
571
604
|
return "";
|
|
572
605
|
}
|
|
573
606
|
}
|
|
574
|
-
function
|
|
607
|
+
function connectRaw(connectConfig) {
|
|
575
608
|
return new Promise((resolve, reject) => {
|
|
576
609
|
const client = new Client();
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
610
|
+
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
611
|
+
});
|
|
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}
|
|
582
622
|
|
|
583
623
|
SSH Diagnostics:
|
|
584
624
|
${diag}`);
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}).connect(connectConfig);
|
|
591
|
-
});
|
|
625
|
+
enhanced.cause = err;
|
|
626
|
+
throw enhanced;
|
|
627
|
+
}
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
592
630
|
}
|
|
593
631
|
function exec(client, command, timeoutMs = 3e4) {
|
|
594
632
|
return new Promise((resolve, reject) => {
|
|
@@ -698,6 +736,116 @@ async function listDir(client, remotePath) {
|
|
|
698
736
|
}
|
|
699
737
|
}
|
|
700
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
|
+
|
|
701
849
|
// src/tools.ts
|
|
702
850
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
703
851
|
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
@@ -712,7 +860,8 @@ var connectionParams = {
|
|
|
712
860
|
privateKeyPath: KeyPathSchema,
|
|
713
861
|
password: PasswordSchema
|
|
714
862
|
};
|
|
715
|
-
function registerTools(server) {
|
|
863
|
+
function registerTools(server, pool) {
|
|
864
|
+
const connectionPool = pool ?? new ConnectionPool();
|
|
716
865
|
server.tool(
|
|
717
866
|
"ssh_exec",
|
|
718
867
|
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
@@ -722,8 +871,7 @@ function registerTools(server) {
|
|
|
722
871
|
timeout: TimeoutSchema
|
|
723
872
|
},
|
|
724
873
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
725
|
-
|
|
726
|
-
try {
|
|
874
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
727
875
|
const result = await exec(client, command, timeout || 3e4);
|
|
728
876
|
const parts = [];
|
|
729
877
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -731,9 +879,7 @@ function registerTools(server) {
|
|
|
731
879
|
${result.stderr}`);
|
|
732
880
|
parts.push(`[exit code: ${result.code}]`);
|
|
733
881
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
734
|
-
}
|
|
735
|
-
client.end();
|
|
736
|
-
}
|
|
882
|
+
});
|
|
737
883
|
}
|
|
738
884
|
);
|
|
739
885
|
server.tool(
|
|
@@ -744,13 +890,10 @@ ${result.stderr}`);
|
|
|
744
890
|
path: z.string().describe("Absolute path to the remote file")
|
|
745
891
|
},
|
|
746
892
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
747
|
-
|
|
748
|
-
try {
|
|
893
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
749
894
|
const content = await readFile(client, path);
|
|
750
895
|
return { content: [{ type: "text", text: content }] };
|
|
751
|
-
}
|
|
752
|
-
client.end();
|
|
753
|
-
}
|
|
896
|
+
});
|
|
754
897
|
}
|
|
755
898
|
);
|
|
756
899
|
server.tool(
|
|
@@ -762,13 +905,10 @@ ${result.stderr}`);
|
|
|
762
905
|
content: z.string().describe("File content to write")
|
|
763
906
|
},
|
|
764
907
|
async ({ host, port, username, privateKeyPath, password, path, content }) => {
|
|
765
|
-
|
|
766
|
-
try {
|
|
908
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
767
909
|
await writeFile(client, path, content);
|
|
768
910
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
769
|
-
}
|
|
770
|
-
client.end();
|
|
771
|
-
}
|
|
911
|
+
});
|
|
772
912
|
}
|
|
773
913
|
);
|
|
774
914
|
server.tool(
|
|
@@ -780,13 +920,10 @@ ${result.stderr}`);
|
|
|
780
920
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
781
921
|
},
|
|
782
922
|
async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
|
|
783
|
-
|
|
784
|
-
try {
|
|
923
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
785
924
|
await uploadFile(client, localPath, remotePath);
|
|
786
925
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
787
|
-
}
|
|
788
|
-
client.end();
|
|
789
|
-
}
|
|
926
|
+
});
|
|
790
927
|
}
|
|
791
928
|
);
|
|
792
929
|
server.tool(
|
|
@@ -798,13 +935,10 @@ ${result.stderr}`);
|
|
|
798
935
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
799
936
|
},
|
|
800
937
|
async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
|
|
801
|
-
|
|
802
|
-
try {
|
|
938
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
803
939
|
await downloadFile(client, remotePath, localPath);
|
|
804
940
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
805
|
-
}
|
|
806
|
-
client.end();
|
|
807
|
-
}
|
|
941
|
+
});
|
|
808
942
|
}
|
|
809
943
|
);
|
|
810
944
|
server.tool(
|
|
@@ -815,13 +949,10 @@ ${result.stderr}`);
|
|
|
815
949
|
path: z.string().describe("Absolute path to the remote directory")
|
|
816
950
|
},
|
|
817
951
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
818
|
-
|
|
819
|
-
try {
|
|
952
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
820
953
|
const files = await listDir(client, path);
|
|
821
954
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
822
|
-
}
|
|
823
|
-
client.end();
|
|
824
|
-
}
|
|
955
|
+
});
|
|
825
956
|
}
|
|
826
957
|
);
|
|
827
958
|
server.tool(
|
|
@@ -983,15 +1114,16 @@ ${result.stderr}`);
|
|
|
983
1114
|
}
|
|
984
1115
|
|
|
985
1116
|
// src/server.ts
|
|
986
|
-
function createServer() {
|
|
1117
|
+
function createServer(pool) {
|
|
987
1118
|
const server = new McpServer({
|
|
988
1119
|
name: "ssh-mcp",
|
|
989
|
-
version: "0.
|
|
1120
|
+
version: "0.4.0"
|
|
990
1121
|
});
|
|
991
|
-
registerTools(server);
|
|
1122
|
+
registerTools(server, pool);
|
|
992
1123
|
return server;
|
|
993
1124
|
}
|
|
994
1125
|
export {
|
|
1126
|
+
ConnectionPool,
|
|
995
1127
|
checkConnectivity,
|
|
996
1128
|
checkGitSsh,
|
|
997
1129
|
checkKnownHosts,
|
|
@@ -1000,6 +1132,7 @@ export {
|
|
|
1000
1132
|
checkSshKeys,
|
|
1001
1133
|
configLookup,
|
|
1002
1134
|
connect,
|
|
1135
|
+
connectRaw,
|
|
1003
1136
|
createServer,
|
|
1004
1137
|
diagnose,
|
|
1005
1138
|
downloadFile,
|
|
@@ -1011,6 +1144,7 @@ export {
|
|
|
1011
1144
|
loadKey,
|
|
1012
1145
|
readFile,
|
|
1013
1146
|
registerTools,
|
|
1147
|
+
resolveConfig,
|
|
1014
1148
|
testConnection,
|
|
1015
1149
|
uploadFile,
|
|
1016
1150
|
writeFile
|