@yawlabs/ssh-mcp 0.6.0 → 0.8.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 +19 -1
- package/dist/index.js +264 -147
- package/dist/server.d.ts +7 -2
- package/dist/server.js +265 -147
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,18 @@ All connections respect your `~/.ssh/config`. Host aliases, custom ports, userna
|
|
|
98
98
|
|
|
99
99
|
**ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
|
|
100
100
|
|
|
101
|
+
### Host key verification
|
|
102
|
+
|
|
103
|
+
All remote operations verify the server's host key against `~/.ssh/known_hosts`:
|
|
104
|
+
|
|
105
|
+
- **Known host, key matches** — accept.
|
|
106
|
+
- **Known host, key changed** — reject (MITM protection).
|
|
107
|
+
- **Unknown host** — accept on first connection (TOFU). Use `ssh_known_hosts_fix` to pin the key for future mismatch detection.
|
|
108
|
+
|
|
109
|
+
For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
|
|
110
|
+
|
|
111
|
+
The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
|
|
112
|
+
|
|
101
113
|
### Windows support
|
|
102
114
|
|
|
103
115
|
On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
@@ -114,7 +126,13 @@ All remote operations accept connection parameters:
|
|
|
114
126
|
| `privateKeyPath` | Path to SSH private key | Auto-detect |
|
|
115
127
|
| `password` | SSH password (prefer keys) | — |
|
|
116
128
|
|
|
117
|
-
**Auth resolution order:**
|
|
129
|
+
**Auth resolution order:** ssh-mcp picks the first match from this list and does not fall through to later entries — this makes the auth method deterministic and predictable.
|
|
130
|
+
|
|
131
|
+
1. Explicit `privateKeyPath`
|
|
132
|
+
2. Explicit `password`
|
|
133
|
+
3. ssh-agent (`SSH_AUTH_SOCK` on Unix, `\\.\pipe\openssh-ssh-agent` on Windows)
|
|
134
|
+
4. Identity files from `~/.ssh/config` for the host
|
|
135
|
+
5. Default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`)
|
|
118
136
|
|
|
119
137
|
## Example workflows
|
|
120
138
|
|
package/dist/index.js
CHANGED
|
@@ -290,42 +290,99 @@ function resolveFromSshConfig(host) {
|
|
|
290
290
|
return null;
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
|
+
function readKnownHostsKeys(host, port) {
|
|
294
|
+
if (!isValidHostname(host)) return [];
|
|
295
|
+
const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
|
|
296
|
+
const keys = [];
|
|
297
|
+
for (const target of targets) {
|
|
298
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
299
|
+
if (!ok || !stdout.trim()) continue;
|
|
300
|
+
for (const line of stdout.split("\n")) {
|
|
301
|
+
const trimmed = line.trim();
|
|
302
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
303
|
+
const parts = trimmed.split(/\s+/);
|
|
304
|
+
if (parts.length < 3) continue;
|
|
305
|
+
try {
|
|
306
|
+
keys.push(Buffer.from(parts[2], "base64"));
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return keys;
|
|
312
|
+
}
|
|
313
|
+
function buildHostVerifier(hosts, port) {
|
|
314
|
+
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
315
|
+
return (key) => {
|
|
316
|
+
const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
|
|
317
|
+
if (known.length === 0) {
|
|
318
|
+
return !strict;
|
|
319
|
+
}
|
|
320
|
+
return known.some((k) => k.equals(key));
|
|
321
|
+
};
|
|
322
|
+
}
|
|
293
323
|
function resolveConfig(config) {
|
|
294
324
|
const sshConfig = resolveFromSshConfig(config.host);
|
|
325
|
+
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
326
|
+
const verifierHosts = [config.host];
|
|
327
|
+
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
328
|
+
verifierHosts.push(sshConfig.hostname);
|
|
329
|
+
}
|
|
295
330
|
const connectConfig = {
|
|
296
331
|
host: sshConfig?.hostname || config.host,
|
|
297
|
-
port
|
|
332
|
+
port,
|
|
298
333
|
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
299
334
|
keepaliveInterval: 15e3,
|
|
300
|
-
keepaliveCountMax: 3
|
|
335
|
+
keepaliveCountMax: 3,
|
|
336
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
301
337
|
};
|
|
302
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
303
|
-
if (agentSock) {
|
|
304
|
-
connectConfig.agent = agentSock;
|
|
305
|
-
}
|
|
306
|
-
if (config.password) {
|
|
307
|
-
connectConfig.password = config.password;
|
|
308
|
-
}
|
|
309
338
|
if (config.privateKeyPath) {
|
|
310
339
|
connectConfig.privateKey = readFileSync2(config.privateKeyPath);
|
|
311
|
-
} else if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
340
|
+
} else if (config.password) {
|
|
341
|
+
connectConfig.password = config.password;
|
|
342
|
+
} else {
|
|
343
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
344
|
+
if (agentSock) {
|
|
345
|
+
connectConfig.agent = agentSock;
|
|
346
|
+
} else {
|
|
347
|
+
const home = homedir2();
|
|
348
|
+
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join2(home, p.slice(1)) : p) : [join2(home, ".ssh", "id_ed25519"), join2(home, ".ssh", "id_rsa"), join2(home, ".ssh", "id_ecdsa")];
|
|
349
|
+
for (const keyPath of keyPaths) {
|
|
350
|
+
try {
|
|
351
|
+
connectConfig.privateKey = readFileSync2(keyPath);
|
|
352
|
+
break;
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
319
355
|
}
|
|
320
356
|
}
|
|
321
357
|
}
|
|
322
358
|
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
323
359
|
}
|
|
360
|
+
var DIAG_CACHE_TTL_MS = 2e3;
|
|
361
|
+
var diagAgentCache = null;
|
|
362
|
+
var diagKeysCache = null;
|
|
363
|
+
function cachedAgentCheck() {
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
|
|
366
|
+
return diagAgentCache.result;
|
|
367
|
+
}
|
|
368
|
+
const result = checkSshAgent();
|
|
369
|
+
diagAgentCache = { at: now, result };
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
function cachedKeysCheck() {
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
|
|
375
|
+
return diagKeysCache.result;
|
|
376
|
+
}
|
|
377
|
+
const result = checkSshKeys();
|
|
378
|
+
diagKeysCache = { at: now, result };
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
324
381
|
function formatDiagnostics(host) {
|
|
325
382
|
try {
|
|
326
383
|
const checks = [
|
|
327
|
-
{ name: "SSH Agent", ...
|
|
328
|
-
{ name: "SSH Keys", ...
|
|
384
|
+
{ name: "SSH Agent", ...cachedAgentCheck() },
|
|
385
|
+
{ name: "SSH Keys", ...cachedKeysCheck() },
|
|
329
386
|
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
330
387
|
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
331
388
|
];
|
|
@@ -384,7 +441,8 @@ async function connectWithProxy(resolved) {
|
|
|
384
441
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
385
442
|
});
|
|
386
443
|
}
|
|
387
|
-
|
|
444
|
+
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
445
|
+
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
388
446
|
return new Promise((resolve, reject) => {
|
|
389
447
|
let settled = false;
|
|
390
448
|
const settle = (fn) => {
|
|
@@ -401,18 +459,52 @@ function exec(client, command, timeoutMs = 3e4) {
|
|
|
401
459
|
settle(() => reject(err));
|
|
402
460
|
return;
|
|
403
461
|
}
|
|
404
|
-
|
|
405
|
-
|
|
462
|
+
const stdoutChunks = [];
|
|
463
|
+
const stderrChunks = [];
|
|
464
|
+
let stdoutBytes = 0;
|
|
465
|
+
let stderrBytes = 0;
|
|
466
|
+
let stdoutTruncated = false;
|
|
467
|
+
let stderrTruncated = false;
|
|
468
|
+
const appendStdout = (data) => {
|
|
469
|
+
if (stdoutTruncated) return;
|
|
470
|
+
const remaining = maxBytes - stdoutBytes;
|
|
471
|
+
if (data.length <= remaining) {
|
|
472
|
+
stdoutChunks.push(data);
|
|
473
|
+
stdoutBytes += data.length;
|
|
474
|
+
} else {
|
|
475
|
+
if (remaining > 0) {
|
|
476
|
+
stdoutChunks.push(data.subarray(0, remaining));
|
|
477
|
+
stdoutBytes += remaining;
|
|
478
|
+
}
|
|
479
|
+
stdoutTruncated = true;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const appendStderr = (data) => {
|
|
483
|
+
if (stderrTruncated) return;
|
|
484
|
+
const remaining = maxBytes - stderrBytes;
|
|
485
|
+
if (data.length <= remaining) {
|
|
486
|
+
stderrChunks.push(data);
|
|
487
|
+
stderrBytes += data.length;
|
|
488
|
+
} else {
|
|
489
|
+
if (remaining > 0) {
|
|
490
|
+
stderrChunks.push(data.subarray(0, remaining));
|
|
491
|
+
stderrBytes += remaining;
|
|
492
|
+
}
|
|
493
|
+
stderrTruncated = true;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
406
496
|
stream.on("close", (code) => {
|
|
497
|
+
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
498
|
+
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
499
|
+
if (stdoutTruncated) stdout += `
|
|
500
|
+
[output truncated at ${maxBytes} bytes]`;
|
|
501
|
+
if (stderrTruncated) stderr += `
|
|
502
|
+
[stderr truncated at ${maxBytes} bytes]`;
|
|
407
503
|
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
408
|
-
}).on("data", (
|
|
409
|
-
stdout += data.toString();
|
|
410
|
-
}).on("error", (err2) => {
|
|
504
|
+
}).on("data", appendStdout).on("error", (err2) => {
|
|
411
505
|
settle(() => reject(err2));
|
|
412
506
|
});
|
|
413
|
-
stream.stderr.on("data", (
|
|
414
|
-
stderr += data.toString();
|
|
415
|
-
}).on("error", (err2) => {
|
|
507
|
+
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
416
508
|
settle(() => reject(err2));
|
|
417
509
|
});
|
|
418
510
|
});
|
|
@@ -507,8 +599,14 @@ async function listDir(client, remotePath) {
|
|
|
507
599
|
// src/pool.ts
|
|
508
600
|
var ConnectionPool = class {
|
|
509
601
|
entries = /* @__PURE__ */ new Map();
|
|
602
|
+
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
603
|
+
// duplicate TCP connections when N tool calls fire simultaneously.
|
|
604
|
+
pending = /* @__PURE__ */ new Map();
|
|
510
605
|
idleTtlMs;
|
|
511
606
|
maxPoolSize;
|
|
607
|
+
// Total number of successful connects ever made by this pool. Useful for
|
|
608
|
+
// introspection and for tests that want to prove connection reuse.
|
|
609
|
+
_connectCount = 0;
|
|
512
610
|
constructor(options) {
|
|
513
611
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
514
612
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -517,67 +615,98 @@ var ConnectionPool = class {
|
|
|
517
615
|
const resolved = resolveConfig(config);
|
|
518
616
|
const cc = resolved.connectConfig;
|
|
519
617
|
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
existing.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (existing?.dead) {
|
|
530
|
-
this.entries.delete(key);
|
|
531
|
-
}
|
|
532
|
-
if (this.entries.size >= this.maxPoolSize) {
|
|
533
|
-
let evicted = false;
|
|
534
|
-
for (const [k, e] of this.entries) {
|
|
535
|
-
if (e.refCount === 0) {
|
|
536
|
-
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
537
|
-
try {
|
|
538
|
-
e.client.end();
|
|
539
|
-
} catch {
|
|
540
|
-
}
|
|
541
|
-
this.entries.delete(k);
|
|
542
|
-
evicted = true;
|
|
543
|
-
break;
|
|
618
|
+
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
619
|
+
let lastErr;
|
|
620
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
621
|
+
const existing = this.entries.get(key);
|
|
622
|
+
if (existing && !existing.dead) {
|
|
623
|
+
existing.refCount++;
|
|
624
|
+
if (existing.idleTimer) {
|
|
625
|
+
clearTimeout(existing.idleTimer);
|
|
626
|
+
existing.idleTimer = null;
|
|
544
627
|
}
|
|
628
|
+
return existing.client;
|
|
545
629
|
}
|
|
546
|
-
if (
|
|
547
|
-
|
|
630
|
+
if (existing?.dead) {
|
|
631
|
+
this.entries.delete(key);
|
|
548
632
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
633
|
+
let pending = this.pending.get(key);
|
|
634
|
+
if (!pending) {
|
|
635
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
636
|
+
let evicted = false;
|
|
637
|
+
for (const [k, e] of this.entries) {
|
|
638
|
+
if (e.refCount === 0) {
|
|
639
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
640
|
+
try {
|
|
641
|
+
e.client.end();
|
|
642
|
+
} catch {
|
|
643
|
+
}
|
|
644
|
+
this.entries.delete(k);
|
|
645
|
+
evicted = true;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!evicted) {
|
|
650
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
651
|
+
}
|
|
561
652
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
653
|
+
pending = (async () => {
|
|
654
|
+
try {
|
|
655
|
+
const client2 = await connectWithProxy(resolved);
|
|
656
|
+
this._connectCount++;
|
|
657
|
+
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
658
|
+
const markDead = () => {
|
|
659
|
+
entry2.dead = true;
|
|
660
|
+
if (entry2.idleTimer) {
|
|
661
|
+
clearTimeout(entry2.idleTimer);
|
|
662
|
+
entry2.idleTimer = null;
|
|
663
|
+
}
|
|
664
|
+
if (this.entries.get(key) === entry2) {
|
|
665
|
+
this.entries.delete(key);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
client2.on("close", markDead);
|
|
669
|
+
client2.on("end", markDead);
|
|
670
|
+
client2.on("error", markDead);
|
|
671
|
+
this.entries.set(key, entry2);
|
|
672
|
+
return client2;
|
|
673
|
+
} finally {
|
|
674
|
+
this.pending.delete(key);
|
|
675
|
+
}
|
|
676
|
+
})();
|
|
677
|
+
this.pending.set(key, pending);
|
|
678
|
+
}
|
|
679
|
+
let client;
|
|
680
|
+
try {
|
|
681
|
+
client = await pending;
|
|
682
|
+
} catch (err) {
|
|
683
|
+
const diag = formatDiagnostics(config.host);
|
|
684
|
+
if (diag) {
|
|
685
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
686
|
+
const enhanced = new Error(`${message}
|
|
573
687
|
|
|
574
688
|
SSH Diagnostics:
|
|
575
689
|
${diag}`);
|
|
576
|
-
|
|
577
|
-
|
|
690
|
+
enhanced.cause = err;
|
|
691
|
+
throw enhanced;
|
|
692
|
+
}
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
const entry = this.entries.get(key);
|
|
696
|
+
if (!entry || entry.dead || entry.client !== client) {
|
|
697
|
+
lastErr = new Error("connection died before acquire could take a ref");
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
entry.refCount++;
|
|
701
|
+
if (entry.idleTimer) {
|
|
702
|
+
clearTimeout(entry.idleTimer);
|
|
703
|
+
entry.idleTimer = null;
|
|
578
704
|
}
|
|
579
|
-
|
|
705
|
+
return client;
|
|
580
706
|
}
|
|
707
|
+
throw new Error(
|
|
708
|
+
`Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
709
|
+
);
|
|
581
710
|
}
|
|
582
711
|
release(client) {
|
|
583
712
|
for (const entry of this.entries.values()) {
|
|
@@ -633,6 +762,10 @@ ${diag}`);
|
|
|
633
762
|
}
|
|
634
763
|
return { active, idle };
|
|
635
764
|
}
|
|
765
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
766
|
+
get connectCount() {
|
|
767
|
+
return this._connectCount;
|
|
768
|
+
}
|
|
636
769
|
};
|
|
637
770
|
|
|
638
771
|
// src/server.ts
|
|
@@ -645,37 +778,29 @@ import { z } from "zod";
|
|
|
645
778
|
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
|
|
646
779
|
import { homedir as homedir3 } from "os";
|
|
647
780
|
import { join as join3 } from "path";
|
|
781
|
+
function probeAgent(socket, agentLabel) {
|
|
782
|
+
const { stdout, ok } = runArgs("ssh-add", ["-l"]);
|
|
783
|
+
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
784
|
+
if (!ok && !noIdentities) return null;
|
|
785
|
+
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
786
|
+
return {
|
|
787
|
+
running: true,
|
|
788
|
+
reachable: true,
|
|
789
|
+
socket,
|
|
790
|
+
keys,
|
|
791
|
+
started: false,
|
|
792
|
+
message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
|
|
793
|
+
};
|
|
794
|
+
}
|
|
648
795
|
function ensureAgent() {
|
|
649
796
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
650
797
|
if (sock) {
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
if (ok2 || noIdentities) {
|
|
654
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
655
|
-
return {
|
|
656
|
-
running: true,
|
|
657
|
-
reachable: true,
|
|
658
|
-
socket: sock,
|
|
659
|
-
keys,
|
|
660
|
-
started: false,
|
|
661
|
-
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."
|
|
662
|
-
};
|
|
663
|
-
}
|
|
798
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
799
|
+
if (result) return result;
|
|
664
800
|
}
|
|
665
801
|
if (!sock && process.platform === "win32") {
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
if (ok2 || noIdentities) {
|
|
669
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
670
|
-
return {
|
|
671
|
-
running: true,
|
|
672
|
-
reachable: true,
|
|
673
|
-
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
674
|
-
keys,
|
|
675
|
-
started: false,
|
|
676
|
-
message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
|
|
677
|
-
};
|
|
678
|
-
}
|
|
802
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
803
|
+
if (result) return result;
|
|
679
804
|
}
|
|
680
805
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
681
806
|
if (ok) {
|
|
@@ -691,7 +816,7 @@ function ensureAgent() {
|
|
|
691
816
|
keys: [],
|
|
692
817
|
started: true,
|
|
693
818
|
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
694
|
-
message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
819
|
+
message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
695
820
|
};
|
|
696
821
|
}
|
|
697
822
|
}
|
|
@@ -817,8 +942,8 @@ function configLookup(host) {
|
|
|
817
942
|
user: all.user || "",
|
|
818
943
|
port: all.port || "22",
|
|
819
944
|
identityFile: identityFiles,
|
|
820
|
-
proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
821
|
-
proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
945
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
946
|
+
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
822
947
|
all,
|
|
823
948
|
raw: stdout
|
|
824
949
|
};
|
|
@@ -860,7 +985,7 @@ function checkGitSsh(host = "github.com", user = "git") {
|
|
|
860
985
|
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
861
986
|
const text = stdout;
|
|
862
987
|
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
863
|
-
const userMatch = text.match(/Hi (\S
|
|
988
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
864
989
|
return {
|
|
865
990
|
status: "ok",
|
|
866
991
|
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
@@ -978,8 +1103,11 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
978
1103
|
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
979
1104
|
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
980
1105
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
981
|
-
const command = `find ${args.join(" ")}
|
|
1106
|
+
const command = `find ${args.join(" ")}`;
|
|
982
1107
|
const result = await exec(client, command, timeoutMs);
|
|
1108
|
+
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
1109
|
+
throw new Error(result.stderr.trim());
|
|
1110
|
+
}
|
|
983
1111
|
return result.stdout.split("\n").filter(Boolean);
|
|
984
1112
|
}
|
|
985
1113
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
@@ -988,7 +1116,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
988
1116
|
command += ` | grep -i ${shellQuote(grep)}`;
|
|
989
1117
|
}
|
|
990
1118
|
const result = await exec(client, command, timeoutMs);
|
|
991
|
-
if (result.
|
|
1119
|
+
if (result.stderr.trim()) {
|
|
992
1120
|
throw new Error(result.stderr.trim());
|
|
993
1121
|
}
|
|
994
1122
|
return result.stdout;
|
|
@@ -1016,7 +1144,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
|
1016
1144
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1017
1145
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1018
1146
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
1019
|
-
var PasswordSchema = z.string().optional().describe(
|
|
1147
|
+
var PasswordSchema = z.string().optional().describe(
|
|
1148
|
+
"SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
|
|
1149
|
+
);
|
|
1020
1150
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1021
1151
|
var connectionParams = {
|
|
1022
1152
|
host: HostSchema,
|
|
@@ -1029,14 +1159,14 @@ function registerTools(server, pool) {
|
|
|
1029
1159
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1030
1160
|
server.tool(
|
|
1031
1161
|
"ssh_exec",
|
|
1032
|
-
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
1162
|
+
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
|
|
1033
1163
|
{
|
|
1034
1164
|
...connectionParams,
|
|
1035
|
-
command: z.string().describe("Shell command to execute on the remote host"),
|
|
1165
|
+
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1036
1166
|
timeout: TimeoutSchema
|
|
1037
1167
|
},
|
|
1038
|
-
async ({
|
|
1039
|
-
return connectionPool.withConnection(
|
|
1168
|
+
async ({ command, timeout, ...conn }) => {
|
|
1169
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1040
1170
|
const result = await exec(client, command, timeout || 3e4);
|
|
1041
1171
|
const parts = [];
|
|
1042
1172
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -1054,8 +1184,8 @@ ${result.stderr}`);
|
|
|
1054
1184
|
...connectionParams,
|
|
1055
1185
|
path: z.string().describe("Absolute path to the remote file")
|
|
1056
1186
|
},
|
|
1057
|
-
async ({
|
|
1058
|
-
return connectionPool.withConnection(
|
|
1187
|
+
async ({ path, ...conn }) => {
|
|
1188
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1059
1189
|
const content = await readFile(client, path);
|
|
1060
1190
|
return { content: [{ type: "text", text: content }] };
|
|
1061
1191
|
});
|
|
@@ -1069,8 +1199,8 @@ ${result.stderr}`);
|
|
|
1069
1199
|
path: z.string().describe("Absolute path to the remote file"),
|
|
1070
1200
|
content: z.string().describe("File content to write")
|
|
1071
1201
|
},
|
|
1072
|
-
async ({
|
|
1073
|
-
return connectionPool.withConnection(
|
|
1202
|
+
async ({ path, content, ...conn }) => {
|
|
1203
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1074
1204
|
await writeFile(client, path, content);
|
|
1075
1205
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
1076
1206
|
});
|
|
@@ -1084,8 +1214,8 @@ ${result.stderr}`);
|
|
|
1084
1214
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1085
1215
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
1086
1216
|
},
|
|
1087
|
-
async ({
|
|
1088
|
-
return connectionPool.withConnection(
|
|
1217
|
+
async ({ localPath, remotePath, ...conn }) => {
|
|
1218
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1089
1219
|
await uploadFile(client, localPath, remotePath);
|
|
1090
1220
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
1091
1221
|
});
|
|
@@ -1099,8 +1229,8 @@ ${result.stderr}`);
|
|
|
1099
1229
|
remotePath: z.string().describe("Absolute path to the remote file"),
|
|
1100
1230
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1101
1231
|
},
|
|
1102
|
-
async ({
|
|
1103
|
-
return connectionPool.withConnection(
|
|
1232
|
+
async ({ remotePath, localPath, ...conn }) => {
|
|
1233
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1104
1234
|
await downloadFile(client, remotePath, localPath);
|
|
1105
1235
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
1106
1236
|
});
|
|
@@ -1113,8 +1243,8 @@ ${result.stderr}`);
|
|
|
1113
1243
|
...connectionParams,
|
|
1114
1244
|
path: z.string().describe("Absolute path to the remote directory")
|
|
1115
1245
|
},
|
|
1116
|
-
async ({
|
|
1117
|
-
return connectionPool.withConnection(
|
|
1246
|
+
async ({ path, ...conn }) => {
|
|
1247
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1118
1248
|
const files = await listDir(client, path);
|
|
1119
1249
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1120
1250
|
});
|
|
@@ -1320,21 +1450,8 @@ ${result.stderr}`);
|
|
|
1320
1450
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1321
1451
|
timeout: TimeoutSchema
|
|
1322
1452
|
},
|
|
1323
|
-
async ({
|
|
1324
|
-
|
|
1325
|
-
port,
|
|
1326
|
-
username,
|
|
1327
|
-
privateKeyPath,
|
|
1328
|
-
password,
|
|
1329
|
-
path,
|
|
1330
|
-
name,
|
|
1331
|
-
type,
|
|
1332
|
-
maxdepth,
|
|
1333
|
-
minsize,
|
|
1334
|
-
maxsize,
|
|
1335
|
-
timeout
|
|
1336
|
-
}) => {
|
|
1337
|
-
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1453
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1454
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1338
1455
|
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1339
1456
|
if (files.length === 0) {
|
|
1340
1457
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
@@ -1354,8 +1471,8 @@ ${files.join("\n")}` }] };
|
|
|
1354
1471
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1355
1472
|
timeout: TimeoutSchema
|
|
1356
1473
|
},
|
|
1357
|
-
async ({
|
|
1358
|
-
return connectionPool.withConnection(
|
|
1474
|
+
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
1475
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1359
1476
|
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1360
1477
|
if (!output.trim()) {
|
|
1361
1478
|
return {
|
|
@@ -1379,8 +1496,8 @@ ${files.join("\n")}` }] };
|
|
|
1379
1496
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1380
1497
|
timeout: TimeoutSchema
|
|
1381
1498
|
},
|
|
1382
|
-
async ({
|
|
1383
|
-
return connectionPool.withConnection(
|
|
1499
|
+
async ({ service, timeout, ...conn }) => {
|
|
1500
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1384
1501
|
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1385
1502
|
const lines = [];
|
|
1386
1503
|
lines.push(`Service: ${status.name}`);
|
|
@@ -1400,7 +1517,7 @@ ${files.join("\n")}` }] };
|
|
|
1400
1517
|
function createServer(pool) {
|
|
1401
1518
|
const server = new McpServer({
|
|
1402
1519
|
name: "ssh-mcp",
|
|
1403
|
-
version: "0.
|
|
1520
|
+
version: "0.7.0"
|
|
1404
1521
|
});
|
|
1405
1522
|
registerTools(server, pool);
|
|
1406
1523
|
return server;
|
package/dist/server.d.ts
CHANGED
|
@@ -18,12 +18,13 @@ interface ResolvedConfig {
|
|
|
18
18
|
connectConfig: ConnectConfig;
|
|
19
19
|
proxyJump?: string;
|
|
20
20
|
}
|
|
21
|
+
declare function readKnownHostsKeys(host: string, port?: number): Buffer[];
|
|
21
22
|
declare function resolveConfig(config: SSHConfig): ResolvedConfig;
|
|
22
23
|
declare function formatDiagnostics(host: string): string;
|
|
23
24
|
declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
24
25
|
declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
|
|
25
26
|
declare function connect(config: SSHConfig): Promise<Client>;
|
|
26
|
-
declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
27
|
+
declare function exec(client: Client, command: string, timeoutMs?: number, maxBytes?: number): Promise<ExecResult>;
|
|
27
28
|
declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
28
29
|
declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
29
30
|
declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
@@ -38,8 +39,10 @@ interface PoolOptions {
|
|
|
38
39
|
}
|
|
39
40
|
declare class ConnectionPool {
|
|
40
41
|
private entries;
|
|
42
|
+
private pending;
|
|
41
43
|
private idleTtlMs;
|
|
42
44
|
private maxPoolSize;
|
|
45
|
+
private _connectCount;
|
|
43
46
|
constructor(options?: PoolOptions);
|
|
44
47
|
acquire(config: SSHConfig): Promise<Client>;
|
|
45
48
|
release(client: Client): void;
|
|
@@ -50,6 +53,8 @@ declare class ConnectionPool {
|
|
|
50
53
|
active: number;
|
|
51
54
|
idle: number;
|
|
52
55
|
};
|
|
56
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
57
|
+
get connectCount(): number;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
|
@@ -164,4 +169,4 @@ declare function testConnection(host: string, port?: number): {
|
|
|
164
169
|
|
|
165
170
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
166
171
|
|
|
167
|
-
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
|
|
172
|
+
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
|
package/dist/server.js
CHANGED
|
@@ -259,37 +259,29 @@ function diagnose(host, port = 22) {
|
|
|
259
259
|
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
|
|
260
260
|
import { homedir as homedir2 } from "os";
|
|
261
261
|
import { join as join2 } from "path";
|
|
262
|
+
function probeAgent(socket, agentLabel) {
|
|
263
|
+
const { stdout, ok } = runArgs("ssh-add", ["-l"]);
|
|
264
|
+
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
265
|
+
if (!ok && !noIdentities) return null;
|
|
266
|
+
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
267
|
+
return {
|
|
268
|
+
running: true,
|
|
269
|
+
reachable: true,
|
|
270
|
+
socket,
|
|
271
|
+
keys,
|
|
272
|
+
started: false,
|
|
273
|
+
message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
|
|
274
|
+
};
|
|
275
|
+
}
|
|
262
276
|
function ensureAgent() {
|
|
263
277
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
264
278
|
if (sock) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
if (ok2 || noIdentities) {
|
|
268
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
269
|
-
return {
|
|
270
|
-
running: true,
|
|
271
|
-
reachable: true,
|
|
272
|
-
socket: sock,
|
|
273
|
-
keys,
|
|
274
|
-
started: false,
|
|
275
|
-
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."
|
|
276
|
-
};
|
|
277
|
-
}
|
|
279
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
280
|
+
if (result) return result;
|
|
278
281
|
}
|
|
279
282
|
if (!sock && process.platform === "win32") {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
if (ok2 || noIdentities) {
|
|
283
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
284
|
-
return {
|
|
285
|
-
running: true,
|
|
286
|
-
reachable: true,
|
|
287
|
-
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
288
|
-
keys,
|
|
289
|
-
started: false,
|
|
290
|
-
message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
|
|
291
|
-
};
|
|
292
|
-
}
|
|
283
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
284
|
+
if (result) return result;
|
|
293
285
|
}
|
|
294
286
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
295
287
|
if (ok) {
|
|
@@ -305,7 +297,7 @@ function ensureAgent() {
|
|
|
305
297
|
keys: [],
|
|
306
298
|
started: true,
|
|
307
299
|
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
308
|
-
message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
300
|
+
message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
309
301
|
};
|
|
310
302
|
}
|
|
311
303
|
}
|
|
@@ -431,8 +423,8 @@ function configLookup(host) {
|
|
|
431
423
|
user: all.user || "",
|
|
432
424
|
port: all.port || "22",
|
|
433
425
|
identityFile: identityFiles,
|
|
434
|
-
proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
435
|
-
proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
426
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
427
|
+
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
436
428
|
all,
|
|
437
429
|
raw: stdout
|
|
438
430
|
};
|
|
@@ -474,7 +466,7 @@ function checkGitSsh(host = "github.com", user = "git") {
|
|
|
474
466
|
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
475
467
|
const text = stdout;
|
|
476
468
|
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
477
|
-
const userMatch = text.match(/Hi (\S
|
|
469
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
478
470
|
return {
|
|
479
471
|
status: "ok",
|
|
480
472
|
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
@@ -581,42 +573,99 @@ function resolveFromSshConfig(host) {
|
|
|
581
573
|
return null;
|
|
582
574
|
}
|
|
583
575
|
}
|
|
576
|
+
function readKnownHostsKeys(host, port) {
|
|
577
|
+
if (!isValidHostname(host)) return [];
|
|
578
|
+
const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
|
|
579
|
+
const keys = [];
|
|
580
|
+
for (const target of targets) {
|
|
581
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
582
|
+
if (!ok || !stdout.trim()) continue;
|
|
583
|
+
for (const line of stdout.split("\n")) {
|
|
584
|
+
const trimmed = line.trim();
|
|
585
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
586
|
+
const parts = trimmed.split(/\s+/);
|
|
587
|
+
if (parts.length < 3) continue;
|
|
588
|
+
try {
|
|
589
|
+
keys.push(Buffer.from(parts[2], "base64"));
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return keys;
|
|
595
|
+
}
|
|
596
|
+
function buildHostVerifier(hosts, port) {
|
|
597
|
+
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
598
|
+
return (key) => {
|
|
599
|
+
const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
|
|
600
|
+
if (known.length === 0) {
|
|
601
|
+
return !strict;
|
|
602
|
+
}
|
|
603
|
+
return known.some((k) => k.equals(key));
|
|
604
|
+
};
|
|
605
|
+
}
|
|
584
606
|
function resolveConfig(config) {
|
|
585
607
|
const sshConfig = resolveFromSshConfig(config.host);
|
|
608
|
+
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
609
|
+
const verifierHosts = [config.host];
|
|
610
|
+
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
611
|
+
verifierHosts.push(sshConfig.hostname);
|
|
612
|
+
}
|
|
586
613
|
const connectConfig = {
|
|
587
614
|
host: sshConfig?.hostname || config.host,
|
|
588
|
-
port
|
|
615
|
+
port,
|
|
589
616
|
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
590
617
|
keepaliveInterval: 15e3,
|
|
591
|
-
keepaliveCountMax: 3
|
|
618
|
+
keepaliveCountMax: 3,
|
|
619
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
592
620
|
};
|
|
593
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
594
|
-
if (agentSock) {
|
|
595
|
-
connectConfig.agent = agentSock;
|
|
596
|
-
}
|
|
597
|
-
if (config.password) {
|
|
598
|
-
connectConfig.password = config.password;
|
|
599
|
-
}
|
|
600
621
|
if (config.privateKeyPath) {
|
|
601
622
|
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
602
|
-
} else if (
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
623
|
+
} else if (config.password) {
|
|
624
|
+
connectConfig.password = config.password;
|
|
625
|
+
} else {
|
|
626
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
627
|
+
if (agentSock) {
|
|
628
|
+
connectConfig.agent = agentSock;
|
|
629
|
+
} else {
|
|
630
|
+
const home = homedir3();
|
|
631
|
+
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")];
|
|
632
|
+
for (const keyPath of keyPaths) {
|
|
633
|
+
try {
|
|
634
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
635
|
+
break;
|
|
636
|
+
} catch {
|
|
637
|
+
}
|
|
610
638
|
}
|
|
611
639
|
}
|
|
612
640
|
}
|
|
613
641
|
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
614
642
|
}
|
|
643
|
+
var DIAG_CACHE_TTL_MS = 2e3;
|
|
644
|
+
var diagAgentCache = null;
|
|
645
|
+
var diagKeysCache = null;
|
|
646
|
+
function cachedAgentCheck() {
|
|
647
|
+
const now = Date.now();
|
|
648
|
+
if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
|
|
649
|
+
return diagAgentCache.result;
|
|
650
|
+
}
|
|
651
|
+
const result = checkSshAgent();
|
|
652
|
+
diagAgentCache = { at: now, result };
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
function cachedKeysCheck() {
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
|
|
658
|
+
return diagKeysCache.result;
|
|
659
|
+
}
|
|
660
|
+
const result = checkSshKeys();
|
|
661
|
+
diagKeysCache = { at: now, result };
|
|
662
|
+
return result;
|
|
663
|
+
}
|
|
615
664
|
function formatDiagnostics(host) {
|
|
616
665
|
try {
|
|
617
666
|
const checks = [
|
|
618
|
-
{ name: "SSH Agent", ...
|
|
619
|
-
{ name: "SSH Keys", ...
|
|
667
|
+
{ name: "SSH Agent", ...cachedAgentCheck() },
|
|
668
|
+
{ name: "SSH Keys", ...cachedKeysCheck() },
|
|
620
669
|
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
621
670
|
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
622
671
|
];
|
|
@@ -693,7 +742,8 @@ ${diag}`);
|
|
|
693
742
|
throw err;
|
|
694
743
|
}
|
|
695
744
|
}
|
|
696
|
-
|
|
745
|
+
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
746
|
+
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
697
747
|
return new Promise((resolve, reject) => {
|
|
698
748
|
let settled = false;
|
|
699
749
|
const settle = (fn) => {
|
|
@@ -710,18 +760,52 @@ function exec(client, command, timeoutMs = 3e4) {
|
|
|
710
760
|
settle(() => reject(err));
|
|
711
761
|
return;
|
|
712
762
|
}
|
|
713
|
-
|
|
714
|
-
|
|
763
|
+
const stdoutChunks = [];
|
|
764
|
+
const stderrChunks = [];
|
|
765
|
+
let stdoutBytes = 0;
|
|
766
|
+
let stderrBytes = 0;
|
|
767
|
+
let stdoutTruncated = false;
|
|
768
|
+
let stderrTruncated = false;
|
|
769
|
+
const appendStdout = (data) => {
|
|
770
|
+
if (stdoutTruncated) return;
|
|
771
|
+
const remaining = maxBytes - stdoutBytes;
|
|
772
|
+
if (data.length <= remaining) {
|
|
773
|
+
stdoutChunks.push(data);
|
|
774
|
+
stdoutBytes += data.length;
|
|
775
|
+
} else {
|
|
776
|
+
if (remaining > 0) {
|
|
777
|
+
stdoutChunks.push(data.subarray(0, remaining));
|
|
778
|
+
stdoutBytes += remaining;
|
|
779
|
+
}
|
|
780
|
+
stdoutTruncated = true;
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
const appendStderr = (data) => {
|
|
784
|
+
if (stderrTruncated) return;
|
|
785
|
+
const remaining = maxBytes - stderrBytes;
|
|
786
|
+
if (data.length <= remaining) {
|
|
787
|
+
stderrChunks.push(data);
|
|
788
|
+
stderrBytes += data.length;
|
|
789
|
+
} else {
|
|
790
|
+
if (remaining > 0) {
|
|
791
|
+
stderrChunks.push(data.subarray(0, remaining));
|
|
792
|
+
stderrBytes += remaining;
|
|
793
|
+
}
|
|
794
|
+
stderrTruncated = true;
|
|
795
|
+
}
|
|
796
|
+
};
|
|
715
797
|
stream.on("close", (code) => {
|
|
798
|
+
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
799
|
+
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
800
|
+
if (stdoutTruncated) stdout += `
|
|
801
|
+
[output truncated at ${maxBytes} bytes]`;
|
|
802
|
+
if (stderrTruncated) stderr += `
|
|
803
|
+
[stderr truncated at ${maxBytes} bytes]`;
|
|
716
804
|
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
717
|
-
}).on("data", (
|
|
718
|
-
stdout += data.toString();
|
|
719
|
-
}).on("error", (err2) => {
|
|
805
|
+
}).on("data", appendStdout).on("error", (err2) => {
|
|
720
806
|
settle(() => reject(err2));
|
|
721
807
|
});
|
|
722
|
-
stream.stderr.on("data", (
|
|
723
|
-
stderr += data.toString();
|
|
724
|
-
}).on("error", (err2) => {
|
|
808
|
+
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
725
809
|
settle(() => reject(err2));
|
|
726
810
|
});
|
|
727
811
|
});
|
|
@@ -858,8 +942,11 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
858
942
|
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
859
943
|
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
860
944
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
861
|
-
const command = `find ${args.join(" ")}
|
|
945
|
+
const command = `find ${args.join(" ")}`;
|
|
862
946
|
const result = await exec(client, command, timeoutMs);
|
|
947
|
+
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
948
|
+
throw new Error(result.stderr.trim());
|
|
949
|
+
}
|
|
863
950
|
return result.stdout.split("\n").filter(Boolean);
|
|
864
951
|
}
|
|
865
952
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
@@ -868,7 +955,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
868
955
|
command += ` | grep -i ${shellQuote(grep)}`;
|
|
869
956
|
}
|
|
870
957
|
const result = await exec(client, command, timeoutMs);
|
|
871
|
-
if (result.
|
|
958
|
+
if (result.stderr.trim()) {
|
|
872
959
|
throw new Error(result.stderr.trim());
|
|
873
960
|
}
|
|
874
961
|
return result.stdout;
|
|
@@ -894,8 +981,14 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
894
981
|
// src/pool.ts
|
|
895
982
|
var ConnectionPool = class {
|
|
896
983
|
entries = /* @__PURE__ */ new Map();
|
|
984
|
+
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
985
|
+
// duplicate TCP connections when N tool calls fire simultaneously.
|
|
986
|
+
pending = /* @__PURE__ */ new Map();
|
|
897
987
|
idleTtlMs;
|
|
898
988
|
maxPoolSize;
|
|
989
|
+
// Total number of successful connects ever made by this pool. Useful for
|
|
990
|
+
// introspection and for tests that want to prove connection reuse.
|
|
991
|
+
_connectCount = 0;
|
|
899
992
|
constructor(options) {
|
|
900
993
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
901
994
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -904,67 +997,98 @@ var ConnectionPool = class {
|
|
|
904
997
|
const resolved = resolveConfig(config);
|
|
905
998
|
const cc = resolved.connectConfig;
|
|
906
999
|
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
existing.
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
if (existing?.dead) {
|
|
917
|
-
this.entries.delete(key);
|
|
918
|
-
}
|
|
919
|
-
if (this.entries.size >= this.maxPoolSize) {
|
|
920
|
-
let evicted = false;
|
|
921
|
-
for (const [k, e] of this.entries) {
|
|
922
|
-
if (e.refCount === 0) {
|
|
923
|
-
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
924
|
-
try {
|
|
925
|
-
e.client.end();
|
|
926
|
-
} catch {
|
|
927
|
-
}
|
|
928
|
-
this.entries.delete(k);
|
|
929
|
-
evicted = true;
|
|
930
|
-
break;
|
|
1000
|
+
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
1001
|
+
let lastErr;
|
|
1002
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
1003
|
+
const existing = this.entries.get(key);
|
|
1004
|
+
if (existing && !existing.dead) {
|
|
1005
|
+
existing.refCount++;
|
|
1006
|
+
if (existing.idleTimer) {
|
|
1007
|
+
clearTimeout(existing.idleTimer);
|
|
1008
|
+
existing.idleTimer = null;
|
|
931
1009
|
}
|
|
1010
|
+
return existing.client;
|
|
932
1011
|
}
|
|
933
|
-
if (
|
|
934
|
-
|
|
1012
|
+
if (existing?.dead) {
|
|
1013
|
+
this.entries.delete(key);
|
|
935
1014
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1015
|
+
let pending = this.pending.get(key);
|
|
1016
|
+
if (!pending) {
|
|
1017
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
1018
|
+
let evicted = false;
|
|
1019
|
+
for (const [k, e] of this.entries) {
|
|
1020
|
+
if (e.refCount === 0) {
|
|
1021
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
1022
|
+
try {
|
|
1023
|
+
e.client.end();
|
|
1024
|
+
} catch {
|
|
1025
|
+
}
|
|
1026
|
+
this.entries.delete(k);
|
|
1027
|
+
evicted = true;
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (!evicted) {
|
|
1032
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
1033
|
+
}
|
|
948
1034
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1035
|
+
pending = (async () => {
|
|
1036
|
+
try {
|
|
1037
|
+
const client2 = await connectWithProxy(resolved);
|
|
1038
|
+
this._connectCount++;
|
|
1039
|
+
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
1040
|
+
const markDead = () => {
|
|
1041
|
+
entry2.dead = true;
|
|
1042
|
+
if (entry2.idleTimer) {
|
|
1043
|
+
clearTimeout(entry2.idleTimer);
|
|
1044
|
+
entry2.idleTimer = null;
|
|
1045
|
+
}
|
|
1046
|
+
if (this.entries.get(key) === entry2) {
|
|
1047
|
+
this.entries.delete(key);
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
client2.on("close", markDead);
|
|
1051
|
+
client2.on("end", markDead);
|
|
1052
|
+
client2.on("error", markDead);
|
|
1053
|
+
this.entries.set(key, entry2);
|
|
1054
|
+
return client2;
|
|
1055
|
+
} finally {
|
|
1056
|
+
this.pending.delete(key);
|
|
1057
|
+
}
|
|
1058
|
+
})();
|
|
1059
|
+
this.pending.set(key, pending);
|
|
1060
|
+
}
|
|
1061
|
+
let client;
|
|
1062
|
+
try {
|
|
1063
|
+
client = await pending;
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
const diag = formatDiagnostics(config.host);
|
|
1066
|
+
if (diag) {
|
|
1067
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1068
|
+
const enhanced = new Error(`${message}
|
|
960
1069
|
|
|
961
1070
|
SSH Diagnostics:
|
|
962
1071
|
${diag}`);
|
|
963
|
-
|
|
964
|
-
|
|
1072
|
+
enhanced.cause = err;
|
|
1073
|
+
throw enhanced;
|
|
1074
|
+
}
|
|
1075
|
+
throw err;
|
|
1076
|
+
}
|
|
1077
|
+
const entry = this.entries.get(key);
|
|
1078
|
+
if (!entry || entry.dead || entry.client !== client) {
|
|
1079
|
+
lastErr = new Error("connection died before acquire could take a ref");
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
entry.refCount++;
|
|
1083
|
+
if (entry.idleTimer) {
|
|
1084
|
+
clearTimeout(entry.idleTimer);
|
|
1085
|
+
entry.idleTimer = null;
|
|
965
1086
|
}
|
|
966
|
-
|
|
1087
|
+
return client;
|
|
967
1088
|
}
|
|
1089
|
+
throw new Error(
|
|
1090
|
+
`Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
1091
|
+
);
|
|
968
1092
|
}
|
|
969
1093
|
release(client) {
|
|
970
1094
|
for (const entry of this.entries.values()) {
|
|
@@ -1020,6 +1144,10 @@ ${diag}`);
|
|
|
1020
1144
|
}
|
|
1021
1145
|
return { active, idle };
|
|
1022
1146
|
}
|
|
1147
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
1148
|
+
get connectCount() {
|
|
1149
|
+
return this._connectCount;
|
|
1150
|
+
}
|
|
1023
1151
|
};
|
|
1024
1152
|
|
|
1025
1153
|
// src/tools.ts
|
|
@@ -1027,7 +1155,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
|
1027
1155
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1028
1156
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1029
1157
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
1030
|
-
var PasswordSchema = z.string().optional().describe(
|
|
1158
|
+
var PasswordSchema = z.string().optional().describe(
|
|
1159
|
+
"SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
|
|
1160
|
+
);
|
|
1031
1161
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1032
1162
|
var connectionParams = {
|
|
1033
1163
|
host: HostSchema,
|
|
@@ -1040,14 +1170,14 @@ function registerTools(server, pool) {
|
|
|
1040
1170
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1041
1171
|
server.tool(
|
|
1042
1172
|
"ssh_exec",
|
|
1043
|
-
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
1173
|
+
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
|
|
1044
1174
|
{
|
|
1045
1175
|
...connectionParams,
|
|
1046
|
-
command: z.string().describe("Shell command to execute on the remote host"),
|
|
1176
|
+
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1047
1177
|
timeout: TimeoutSchema
|
|
1048
1178
|
},
|
|
1049
|
-
async ({
|
|
1050
|
-
return connectionPool.withConnection(
|
|
1179
|
+
async ({ command, timeout, ...conn }) => {
|
|
1180
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1051
1181
|
const result = await exec(client, command, timeout || 3e4);
|
|
1052
1182
|
const parts = [];
|
|
1053
1183
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -1065,8 +1195,8 @@ ${result.stderr}`);
|
|
|
1065
1195
|
...connectionParams,
|
|
1066
1196
|
path: z.string().describe("Absolute path to the remote file")
|
|
1067
1197
|
},
|
|
1068
|
-
async ({
|
|
1069
|
-
return connectionPool.withConnection(
|
|
1198
|
+
async ({ path, ...conn }) => {
|
|
1199
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1070
1200
|
const content = await readFile(client, path);
|
|
1071
1201
|
return { content: [{ type: "text", text: content }] };
|
|
1072
1202
|
});
|
|
@@ -1080,8 +1210,8 @@ ${result.stderr}`);
|
|
|
1080
1210
|
path: z.string().describe("Absolute path to the remote file"),
|
|
1081
1211
|
content: z.string().describe("File content to write")
|
|
1082
1212
|
},
|
|
1083
|
-
async ({
|
|
1084
|
-
return connectionPool.withConnection(
|
|
1213
|
+
async ({ path, content, ...conn }) => {
|
|
1214
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1085
1215
|
await writeFile(client, path, content);
|
|
1086
1216
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
1087
1217
|
});
|
|
@@ -1095,8 +1225,8 @@ ${result.stderr}`);
|
|
|
1095
1225
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1096
1226
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
1097
1227
|
},
|
|
1098
|
-
async ({
|
|
1099
|
-
return connectionPool.withConnection(
|
|
1228
|
+
async ({ localPath, remotePath, ...conn }) => {
|
|
1229
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1100
1230
|
await uploadFile(client, localPath, remotePath);
|
|
1101
1231
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
1102
1232
|
});
|
|
@@ -1110,8 +1240,8 @@ ${result.stderr}`);
|
|
|
1110
1240
|
remotePath: z.string().describe("Absolute path to the remote file"),
|
|
1111
1241
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1112
1242
|
},
|
|
1113
|
-
async ({
|
|
1114
|
-
return connectionPool.withConnection(
|
|
1243
|
+
async ({ remotePath, localPath, ...conn }) => {
|
|
1244
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1115
1245
|
await downloadFile(client, remotePath, localPath);
|
|
1116
1246
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
1117
1247
|
});
|
|
@@ -1124,8 +1254,8 @@ ${result.stderr}`);
|
|
|
1124
1254
|
...connectionParams,
|
|
1125
1255
|
path: z.string().describe("Absolute path to the remote directory")
|
|
1126
1256
|
},
|
|
1127
|
-
async ({
|
|
1128
|
-
return connectionPool.withConnection(
|
|
1257
|
+
async ({ path, ...conn }) => {
|
|
1258
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1129
1259
|
const files = await listDir(client, path);
|
|
1130
1260
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1131
1261
|
});
|
|
@@ -1331,21 +1461,8 @@ ${result.stderr}`);
|
|
|
1331
1461
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1332
1462
|
timeout: TimeoutSchema
|
|
1333
1463
|
},
|
|
1334
|
-
async ({
|
|
1335
|
-
|
|
1336
|
-
port,
|
|
1337
|
-
username,
|
|
1338
|
-
privateKeyPath,
|
|
1339
|
-
password,
|
|
1340
|
-
path,
|
|
1341
|
-
name,
|
|
1342
|
-
type,
|
|
1343
|
-
maxdepth,
|
|
1344
|
-
minsize,
|
|
1345
|
-
maxsize,
|
|
1346
|
-
timeout
|
|
1347
|
-
}) => {
|
|
1348
|
-
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1464
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1465
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1349
1466
|
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1350
1467
|
if (files.length === 0) {
|
|
1351
1468
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
@@ -1365,8 +1482,8 @@ ${files.join("\n")}` }] };
|
|
|
1365
1482
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1366
1483
|
timeout: TimeoutSchema
|
|
1367
1484
|
},
|
|
1368
|
-
async ({
|
|
1369
|
-
return connectionPool.withConnection(
|
|
1485
|
+
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
1486
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1370
1487
|
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1371
1488
|
if (!output.trim()) {
|
|
1372
1489
|
return {
|
|
@@ -1390,8 +1507,8 @@ ${files.join("\n")}` }] };
|
|
|
1390
1507
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1391
1508
|
timeout: TimeoutSchema
|
|
1392
1509
|
},
|
|
1393
|
-
async ({
|
|
1394
|
-
return connectionPool.withConnection(
|
|
1510
|
+
async ({ service, timeout, ...conn }) => {
|
|
1511
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1395
1512
|
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1396
1513
|
const lines = [];
|
|
1397
1514
|
lines.push(`Service: ${status.name}`);
|
|
@@ -1411,7 +1528,7 @@ ${files.join("\n")}` }] };
|
|
|
1411
1528
|
function createServer(pool) {
|
|
1412
1529
|
const server = new McpServer({
|
|
1413
1530
|
name: "ssh-mcp",
|
|
1414
|
-
version: "0.
|
|
1531
|
+
version: "0.7.0"
|
|
1415
1532
|
});
|
|
1416
1533
|
registerTools(server, pool);
|
|
1417
1534
|
return server;
|
|
@@ -1441,6 +1558,7 @@ export {
|
|
|
1441
1558
|
loadKey,
|
|
1442
1559
|
multiExec,
|
|
1443
1560
|
readFile,
|
|
1561
|
+
readKnownHostsKeys,
|
|
1444
1562
|
registerTools,
|
|
1445
1563
|
resolveConfig,
|
|
1446
1564
|
serviceStatus,
|