@yawlabs/ssh-mcp 0.7.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 +7 -1
- package/dist/index.js +223 -142
- package/dist/server.d.ts +5 -1
- package/dist/server.js +223 -142
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,7 +126,13 @@ All remote operations accept connection parameters:
|
|
|
126
126
|
| `privateKeyPath` | Path to SSH private key | Auto-detect |
|
|
127
127
|
| `password` | SSH password (prefer keys) | — |
|
|
128
128
|
|
|
129
|
-
**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`)
|
|
130
136
|
|
|
131
137
|
## Example workflows
|
|
132
138
|
|
package/dist/index.js
CHANGED
|
@@ -335,33 +335,54 @@ function resolveConfig(config) {
|
|
|
335
335
|
keepaliveCountMax: 3,
|
|
336
336
|
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
337
337
|
};
|
|
338
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
339
|
-
if (agentSock) {
|
|
340
|
-
connectConfig.agent = agentSock;
|
|
341
|
-
}
|
|
342
|
-
if (config.password) {
|
|
343
|
-
connectConfig.password = config.password;
|
|
344
|
-
}
|
|
345
338
|
if (config.privateKeyPath) {
|
|
346
339
|
connectConfig.privateKey = readFileSync2(config.privateKeyPath);
|
|
347
|
-
} else if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
340
|
+
} else if (config.password) {
|
|
341
|
+
connectConfig.password = config.password;
|
|
342
|
+
} else {
|
|
343
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
344
|
+
if (agentSock) {
|
|
345
|
+
connectConfig.agent = agentSock;
|
|
346
|
+
} else {
|
|
347
|
+
const home = homedir2();
|
|
348
|
+
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join2(home, p.slice(1)) : p) : [join2(home, ".ssh", "id_ed25519"), join2(home, ".ssh", "id_rsa"), join2(home, ".ssh", "id_ecdsa")];
|
|
349
|
+
for (const keyPath of keyPaths) {
|
|
350
|
+
try {
|
|
351
|
+
connectConfig.privateKey = readFileSync2(keyPath);
|
|
352
|
+
break;
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
355
|
}
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
359
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
|
+
}
|
|
360
381
|
function formatDiagnostics(host) {
|
|
361
382
|
try {
|
|
362
383
|
const checks = [
|
|
363
|
-
{ name: "SSH Agent", ...
|
|
364
|
-
{ name: "SSH Keys", ...
|
|
384
|
+
{ name: "SSH Agent", ...cachedAgentCheck() },
|
|
385
|
+
{ name: "SSH Keys", ...cachedKeysCheck() },
|
|
365
386
|
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
366
387
|
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
367
388
|
];
|
|
@@ -420,7 +441,8 @@ async function connectWithProxy(resolved) {
|
|
|
420
441
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
421
442
|
});
|
|
422
443
|
}
|
|
423
|
-
|
|
444
|
+
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
445
|
+
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
424
446
|
return new Promise((resolve, reject) => {
|
|
425
447
|
let settled = false;
|
|
426
448
|
const settle = (fn) => {
|
|
@@ -437,18 +459,52 @@ function exec(client, command, timeoutMs = 3e4) {
|
|
|
437
459
|
settle(() => reject(err));
|
|
438
460
|
return;
|
|
439
461
|
}
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
};
|
|
442
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]`;
|
|
443
503
|
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
444
|
-
}).on("data", (
|
|
445
|
-
stdout += data.toString();
|
|
446
|
-
}).on("error", (err2) => {
|
|
504
|
+
}).on("data", appendStdout).on("error", (err2) => {
|
|
447
505
|
settle(() => reject(err2));
|
|
448
506
|
});
|
|
449
|
-
stream.stderr.on("data", (
|
|
450
|
-
stderr += data.toString();
|
|
451
|
-
}).on("error", (err2) => {
|
|
507
|
+
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
452
508
|
settle(() => reject(err2));
|
|
453
509
|
});
|
|
454
510
|
});
|
|
@@ -543,8 +599,14 @@ async function listDir(client, remotePath) {
|
|
|
543
599
|
// src/pool.ts
|
|
544
600
|
var ConnectionPool = class {
|
|
545
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();
|
|
546
605
|
idleTtlMs;
|
|
547
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;
|
|
548
610
|
constructor(options) {
|
|
549
611
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
550
612
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -553,67 +615,98 @@ var ConnectionPool = class {
|
|
|
553
615
|
const resolved = resolveConfig(config);
|
|
554
616
|
const cc = resolved.connectConfig;
|
|
555
617
|
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
existing.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if (existing?.dead) {
|
|
566
|
-
this.entries.delete(key);
|
|
567
|
-
}
|
|
568
|
-
if (this.entries.size >= this.maxPoolSize) {
|
|
569
|
-
let evicted = false;
|
|
570
|
-
for (const [k, e] of this.entries) {
|
|
571
|
-
if (e.refCount === 0) {
|
|
572
|
-
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
573
|
-
try {
|
|
574
|
-
e.client.end();
|
|
575
|
-
} catch {
|
|
576
|
-
}
|
|
577
|
-
this.entries.delete(k);
|
|
578
|
-
evicted = true;
|
|
579
|
-
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;
|
|
580
627
|
}
|
|
628
|
+
return existing.client;
|
|
581
629
|
}
|
|
582
|
-
if (
|
|
583
|
-
|
|
630
|
+
if (existing?.dead) {
|
|
631
|
+
this.entries.delete(key);
|
|
584
632
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
+
}
|
|
597
652
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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}
|
|
609
687
|
|
|
610
688
|
SSH Diagnostics:
|
|
611
689
|
${diag}`);
|
|
612
|
-
|
|
613
|
-
|
|
690
|
+
enhanced.cause = err;
|
|
691
|
+
throw enhanced;
|
|
692
|
+
}
|
|
693
|
+
throw err;
|
|
614
694
|
}
|
|
615
|
-
|
|
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;
|
|
704
|
+
}
|
|
705
|
+
return client;
|
|
616
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
|
+
);
|
|
617
710
|
}
|
|
618
711
|
release(client) {
|
|
619
712
|
for (const entry of this.entries.values()) {
|
|
@@ -669,6 +762,10 @@ ${diag}`);
|
|
|
669
762
|
}
|
|
670
763
|
return { active, idle };
|
|
671
764
|
}
|
|
765
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
766
|
+
get connectCount() {
|
|
767
|
+
return this._connectCount;
|
|
768
|
+
}
|
|
672
769
|
};
|
|
673
770
|
|
|
674
771
|
// src/server.ts
|
|
@@ -681,37 +778,29 @@ import { z } from "zod";
|
|
|
681
778
|
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
|
|
682
779
|
import { homedir as homedir3 } from "os";
|
|
683
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
|
+
}
|
|
684
795
|
function ensureAgent() {
|
|
685
796
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
686
797
|
if (sock) {
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
if (ok2 || noIdentities) {
|
|
690
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
691
|
-
return {
|
|
692
|
-
running: true,
|
|
693
|
-
reachable: true,
|
|
694
|
-
socket: sock,
|
|
695
|
-
keys,
|
|
696
|
-
started: false,
|
|
697
|
-
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."
|
|
698
|
-
};
|
|
699
|
-
}
|
|
798
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
799
|
+
if (result) return result;
|
|
700
800
|
}
|
|
701
801
|
if (!sock && process.platform === "win32") {
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
if (ok2 || noIdentities) {
|
|
705
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
706
|
-
return {
|
|
707
|
-
running: true,
|
|
708
|
-
reachable: true,
|
|
709
|
-
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
710
|
-
keys,
|
|
711
|
-
started: false,
|
|
712
|
-
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."
|
|
713
|
-
};
|
|
714
|
-
}
|
|
802
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
803
|
+
if (result) return result;
|
|
715
804
|
}
|
|
716
805
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
717
806
|
if (ok) {
|
|
@@ -727,7 +816,7 @@ function ensureAgent() {
|
|
|
727
816
|
keys: [],
|
|
728
817
|
started: true,
|
|
729
818
|
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
730
|
-
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."
|
|
731
820
|
};
|
|
732
821
|
}
|
|
733
822
|
}
|
|
@@ -853,8 +942,8 @@ function configLookup(host) {
|
|
|
853
942
|
user: all.user || "",
|
|
854
943
|
port: all.port || "22",
|
|
855
944
|
identityFile: identityFiles,
|
|
856
|
-
proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
857
|
-
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,
|
|
858
947
|
all,
|
|
859
948
|
raw: stdout
|
|
860
949
|
};
|
|
@@ -896,7 +985,7 @@ function checkGitSsh(host = "github.com", user = "git") {
|
|
|
896
985
|
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
897
986
|
const text = stdout;
|
|
898
987
|
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
899
|
-
const userMatch = text.match(/Hi (\S
|
|
988
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
900
989
|
return {
|
|
901
990
|
status: "ok",
|
|
902
991
|
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
@@ -1014,8 +1103,11 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1014
1103
|
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
1015
1104
|
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
1016
1105
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1017
|
-
const command = `find ${args.join(" ")}
|
|
1106
|
+
const command = `find ${args.join(" ")}`;
|
|
1018
1107
|
const result = await exec(client, command, timeoutMs);
|
|
1108
|
+
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
1109
|
+
throw new Error(result.stderr.trim());
|
|
1110
|
+
}
|
|
1019
1111
|
return result.stdout.split("\n").filter(Boolean);
|
|
1020
1112
|
}
|
|
1021
1113
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
@@ -1024,7 +1116,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
1024
1116
|
command += ` | grep -i ${shellQuote(grep)}`;
|
|
1025
1117
|
}
|
|
1026
1118
|
const result = await exec(client, command, timeoutMs);
|
|
1027
|
-
if (result.
|
|
1119
|
+
if (result.stderr.trim()) {
|
|
1028
1120
|
throw new Error(result.stderr.trim());
|
|
1029
1121
|
}
|
|
1030
1122
|
return result.stdout;
|
|
@@ -1052,7 +1144,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
|
1052
1144
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1053
1145
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1054
1146
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
1055
|
-
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
|
+
);
|
|
1056
1150
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1057
1151
|
var connectionParams = {
|
|
1058
1152
|
host: HostSchema,
|
|
@@ -1071,8 +1165,8 @@ function registerTools(server, pool) {
|
|
|
1071
1165
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1072
1166
|
timeout: TimeoutSchema
|
|
1073
1167
|
},
|
|
1074
|
-
async ({
|
|
1075
|
-
return connectionPool.withConnection(
|
|
1168
|
+
async ({ command, timeout, ...conn }) => {
|
|
1169
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1076
1170
|
const result = await exec(client, command, timeout || 3e4);
|
|
1077
1171
|
const parts = [];
|
|
1078
1172
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -1090,8 +1184,8 @@ ${result.stderr}`);
|
|
|
1090
1184
|
...connectionParams,
|
|
1091
1185
|
path: z.string().describe("Absolute path to the remote file")
|
|
1092
1186
|
},
|
|
1093
|
-
async ({
|
|
1094
|
-
return connectionPool.withConnection(
|
|
1187
|
+
async ({ path, ...conn }) => {
|
|
1188
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1095
1189
|
const content = await readFile(client, path);
|
|
1096
1190
|
return { content: [{ type: "text", text: content }] };
|
|
1097
1191
|
});
|
|
@@ -1105,8 +1199,8 @@ ${result.stderr}`);
|
|
|
1105
1199
|
path: z.string().describe("Absolute path to the remote file"),
|
|
1106
1200
|
content: z.string().describe("File content to write")
|
|
1107
1201
|
},
|
|
1108
|
-
async ({
|
|
1109
|
-
return connectionPool.withConnection(
|
|
1202
|
+
async ({ path, content, ...conn }) => {
|
|
1203
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1110
1204
|
await writeFile(client, path, content);
|
|
1111
1205
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
1112
1206
|
});
|
|
@@ -1120,8 +1214,8 @@ ${result.stderr}`);
|
|
|
1120
1214
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1121
1215
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
1122
1216
|
},
|
|
1123
|
-
async ({
|
|
1124
|
-
return connectionPool.withConnection(
|
|
1217
|
+
async ({ localPath, remotePath, ...conn }) => {
|
|
1218
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1125
1219
|
await uploadFile(client, localPath, remotePath);
|
|
1126
1220
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
1127
1221
|
});
|
|
@@ -1135,8 +1229,8 @@ ${result.stderr}`);
|
|
|
1135
1229
|
remotePath: z.string().describe("Absolute path to the remote file"),
|
|
1136
1230
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1137
1231
|
},
|
|
1138
|
-
async ({
|
|
1139
|
-
return connectionPool.withConnection(
|
|
1232
|
+
async ({ remotePath, localPath, ...conn }) => {
|
|
1233
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1140
1234
|
await downloadFile(client, remotePath, localPath);
|
|
1141
1235
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
1142
1236
|
});
|
|
@@ -1149,8 +1243,8 @@ ${result.stderr}`);
|
|
|
1149
1243
|
...connectionParams,
|
|
1150
1244
|
path: z.string().describe("Absolute path to the remote directory")
|
|
1151
1245
|
},
|
|
1152
|
-
async ({
|
|
1153
|
-
return connectionPool.withConnection(
|
|
1246
|
+
async ({ path, ...conn }) => {
|
|
1247
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1154
1248
|
const files = await listDir(client, path);
|
|
1155
1249
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1156
1250
|
});
|
|
@@ -1356,21 +1450,8 @@ ${result.stderr}`);
|
|
|
1356
1450
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1357
1451
|
timeout: TimeoutSchema
|
|
1358
1452
|
},
|
|
1359
|
-
async ({
|
|
1360
|
-
|
|
1361
|
-
port,
|
|
1362
|
-
username,
|
|
1363
|
-
privateKeyPath,
|
|
1364
|
-
password,
|
|
1365
|
-
path,
|
|
1366
|
-
name,
|
|
1367
|
-
type,
|
|
1368
|
-
maxdepth,
|
|
1369
|
-
minsize,
|
|
1370
|
-
maxsize,
|
|
1371
|
-
timeout
|
|
1372
|
-
}) => {
|
|
1373
|
-
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) => {
|
|
1374
1455
|
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1375
1456
|
if (files.length === 0) {
|
|
1376
1457
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
@@ -1390,8 +1471,8 @@ ${files.join("\n")}` }] };
|
|
|
1390
1471
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1391
1472
|
timeout: TimeoutSchema
|
|
1392
1473
|
},
|
|
1393
|
-
async ({
|
|
1394
|
-
return connectionPool.withConnection(
|
|
1474
|
+
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
1475
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1395
1476
|
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1396
1477
|
if (!output.trim()) {
|
|
1397
1478
|
return {
|
|
@@ -1415,8 +1496,8 @@ ${files.join("\n")}` }] };
|
|
|
1415
1496
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1416
1497
|
timeout: TimeoutSchema
|
|
1417
1498
|
},
|
|
1418
|
-
async ({
|
|
1419
|
-
return connectionPool.withConnection(
|
|
1499
|
+
async ({ service, timeout, ...conn }) => {
|
|
1500
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1420
1501
|
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1421
1502
|
const lines = [];
|
|
1422
1503
|
lines.push(`Service: ${status.name}`);
|
package/dist/server.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ declare function formatDiagnostics(host: string): string;
|
|
|
24
24
|
declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
25
25
|
declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
|
|
26
26
|
declare function connect(config: SSHConfig): Promise<Client>;
|
|
27
|
-
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>;
|
|
28
28
|
declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
29
29
|
declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
30
30
|
declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
@@ -39,8 +39,10 @@ interface PoolOptions {
|
|
|
39
39
|
}
|
|
40
40
|
declare class ConnectionPool {
|
|
41
41
|
private entries;
|
|
42
|
+
private pending;
|
|
42
43
|
private idleTtlMs;
|
|
43
44
|
private maxPoolSize;
|
|
45
|
+
private _connectCount;
|
|
44
46
|
constructor(options?: PoolOptions);
|
|
45
47
|
acquire(config: SSHConfig): Promise<Client>;
|
|
46
48
|
release(client: Client): void;
|
|
@@ -51,6 +53,8 @@ declare class ConnectionPool {
|
|
|
51
53
|
active: number;
|
|
52
54
|
idle: number;
|
|
53
55
|
};
|
|
56
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
57
|
+
get connectCount(): number;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
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]}` : ""}`,
|
|
@@ -626,33 +618,54 @@ function resolveConfig(config) {
|
|
|
626
618
|
keepaliveCountMax: 3,
|
|
627
619
|
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
628
620
|
};
|
|
629
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
630
|
-
if (agentSock) {
|
|
631
|
-
connectConfig.agent = agentSock;
|
|
632
|
-
}
|
|
633
|
-
if (config.password) {
|
|
634
|
-
connectConfig.password = config.password;
|
|
635
|
-
}
|
|
636
621
|
if (config.privateKeyPath) {
|
|
637
622
|
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
638
|
-
} else if (
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
}
|
|
646
638
|
}
|
|
647
639
|
}
|
|
648
640
|
}
|
|
649
641
|
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
650
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
|
+
}
|
|
651
664
|
function formatDiagnostics(host) {
|
|
652
665
|
try {
|
|
653
666
|
const checks = [
|
|
654
|
-
{ name: "SSH Agent", ...
|
|
655
|
-
{ name: "SSH Keys", ...
|
|
667
|
+
{ name: "SSH Agent", ...cachedAgentCheck() },
|
|
668
|
+
{ name: "SSH Keys", ...cachedKeysCheck() },
|
|
656
669
|
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
657
670
|
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
658
671
|
];
|
|
@@ -729,7 +742,8 @@ ${diag}`);
|
|
|
729
742
|
throw err;
|
|
730
743
|
}
|
|
731
744
|
}
|
|
732
|
-
|
|
745
|
+
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
746
|
+
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
733
747
|
return new Promise((resolve, reject) => {
|
|
734
748
|
let settled = false;
|
|
735
749
|
const settle = (fn) => {
|
|
@@ -746,18 +760,52 @@ function exec(client, command, timeoutMs = 3e4) {
|
|
|
746
760
|
settle(() => reject(err));
|
|
747
761
|
return;
|
|
748
762
|
}
|
|
749
|
-
|
|
750
|
-
|
|
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
|
+
};
|
|
751
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]`;
|
|
752
804
|
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
753
|
-
}).on("data", (
|
|
754
|
-
stdout += data.toString();
|
|
755
|
-
}).on("error", (err2) => {
|
|
805
|
+
}).on("data", appendStdout).on("error", (err2) => {
|
|
756
806
|
settle(() => reject(err2));
|
|
757
807
|
});
|
|
758
|
-
stream.stderr.on("data", (
|
|
759
|
-
stderr += data.toString();
|
|
760
|
-
}).on("error", (err2) => {
|
|
808
|
+
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
761
809
|
settle(() => reject(err2));
|
|
762
810
|
});
|
|
763
811
|
});
|
|
@@ -894,8 +942,11 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
894
942
|
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
895
943
|
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
896
944
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
897
|
-
const command = `find ${args.join(" ")}
|
|
945
|
+
const command = `find ${args.join(" ")}`;
|
|
898
946
|
const result = await exec(client, command, timeoutMs);
|
|
947
|
+
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
948
|
+
throw new Error(result.stderr.trim());
|
|
949
|
+
}
|
|
899
950
|
return result.stdout.split("\n").filter(Boolean);
|
|
900
951
|
}
|
|
901
952
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
@@ -904,7 +955,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
904
955
|
command += ` | grep -i ${shellQuote(grep)}`;
|
|
905
956
|
}
|
|
906
957
|
const result = await exec(client, command, timeoutMs);
|
|
907
|
-
if (result.
|
|
958
|
+
if (result.stderr.trim()) {
|
|
908
959
|
throw new Error(result.stderr.trim());
|
|
909
960
|
}
|
|
910
961
|
return result.stdout;
|
|
@@ -930,8 +981,14 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
930
981
|
// src/pool.ts
|
|
931
982
|
var ConnectionPool = class {
|
|
932
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();
|
|
933
987
|
idleTtlMs;
|
|
934
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;
|
|
935
992
|
constructor(options) {
|
|
936
993
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
937
994
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -940,67 +997,98 @@ var ConnectionPool = class {
|
|
|
940
997
|
const resolved = resolveConfig(config);
|
|
941
998
|
const cc = resolved.connectConfig;
|
|
942
999
|
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
existing.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
if (existing?.dead) {
|
|
953
|
-
this.entries.delete(key);
|
|
954
|
-
}
|
|
955
|
-
if (this.entries.size >= this.maxPoolSize) {
|
|
956
|
-
let evicted = false;
|
|
957
|
-
for (const [k, e] of this.entries) {
|
|
958
|
-
if (e.refCount === 0) {
|
|
959
|
-
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
960
|
-
try {
|
|
961
|
-
e.client.end();
|
|
962
|
-
} catch {
|
|
963
|
-
}
|
|
964
|
-
this.entries.delete(k);
|
|
965
|
-
evicted = true;
|
|
966
|
-
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;
|
|
967
1009
|
}
|
|
1010
|
+
return existing.client;
|
|
968
1011
|
}
|
|
969
|
-
if (
|
|
970
|
-
|
|
1012
|
+
if (existing?.dead) {
|
|
1013
|
+
this.entries.delete(key);
|
|
971
1014
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
+
}
|
|
984
1034
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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}
|
|
996
1069
|
|
|
997
1070
|
SSH Diagnostics:
|
|
998
1071
|
${diag}`);
|
|
999
|
-
|
|
1000
|
-
|
|
1072
|
+
enhanced.cause = err;
|
|
1073
|
+
throw enhanced;
|
|
1074
|
+
}
|
|
1075
|
+
throw err;
|
|
1001
1076
|
}
|
|
1002
|
-
|
|
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;
|
|
1086
|
+
}
|
|
1087
|
+
return client;
|
|
1003
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
|
+
);
|
|
1004
1092
|
}
|
|
1005
1093
|
release(client) {
|
|
1006
1094
|
for (const entry of this.entries.values()) {
|
|
@@ -1056,6 +1144,10 @@ ${diag}`);
|
|
|
1056
1144
|
}
|
|
1057
1145
|
return { active, idle };
|
|
1058
1146
|
}
|
|
1147
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
1148
|
+
get connectCount() {
|
|
1149
|
+
return this._connectCount;
|
|
1150
|
+
}
|
|
1059
1151
|
};
|
|
1060
1152
|
|
|
1061
1153
|
// src/tools.ts
|
|
@@ -1063,7 +1155,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
|
1063
1155
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1064
1156
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1065
1157
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
1066
|
-
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
|
+
);
|
|
1067
1161
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1068
1162
|
var connectionParams = {
|
|
1069
1163
|
host: HostSchema,
|
|
@@ -1082,8 +1176,8 @@ function registerTools(server, pool) {
|
|
|
1082
1176
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1083
1177
|
timeout: TimeoutSchema
|
|
1084
1178
|
},
|
|
1085
|
-
async ({
|
|
1086
|
-
return connectionPool.withConnection(
|
|
1179
|
+
async ({ command, timeout, ...conn }) => {
|
|
1180
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1087
1181
|
const result = await exec(client, command, timeout || 3e4);
|
|
1088
1182
|
const parts = [];
|
|
1089
1183
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -1101,8 +1195,8 @@ ${result.stderr}`);
|
|
|
1101
1195
|
...connectionParams,
|
|
1102
1196
|
path: z.string().describe("Absolute path to the remote file")
|
|
1103
1197
|
},
|
|
1104
|
-
async ({
|
|
1105
|
-
return connectionPool.withConnection(
|
|
1198
|
+
async ({ path, ...conn }) => {
|
|
1199
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1106
1200
|
const content = await readFile(client, path);
|
|
1107
1201
|
return { content: [{ type: "text", text: content }] };
|
|
1108
1202
|
});
|
|
@@ -1116,8 +1210,8 @@ ${result.stderr}`);
|
|
|
1116
1210
|
path: z.string().describe("Absolute path to the remote file"),
|
|
1117
1211
|
content: z.string().describe("File content to write")
|
|
1118
1212
|
},
|
|
1119
|
-
async ({
|
|
1120
|
-
return connectionPool.withConnection(
|
|
1213
|
+
async ({ path, content, ...conn }) => {
|
|
1214
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1121
1215
|
await writeFile(client, path, content);
|
|
1122
1216
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
1123
1217
|
});
|
|
@@ -1131,8 +1225,8 @@ ${result.stderr}`);
|
|
|
1131
1225
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1132
1226
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
1133
1227
|
},
|
|
1134
|
-
async ({
|
|
1135
|
-
return connectionPool.withConnection(
|
|
1228
|
+
async ({ localPath, remotePath, ...conn }) => {
|
|
1229
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1136
1230
|
await uploadFile(client, localPath, remotePath);
|
|
1137
1231
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
1138
1232
|
});
|
|
@@ -1146,8 +1240,8 @@ ${result.stderr}`);
|
|
|
1146
1240
|
remotePath: z.string().describe("Absolute path to the remote file"),
|
|
1147
1241
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1148
1242
|
},
|
|
1149
|
-
async ({
|
|
1150
|
-
return connectionPool.withConnection(
|
|
1243
|
+
async ({ remotePath, localPath, ...conn }) => {
|
|
1244
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1151
1245
|
await downloadFile(client, remotePath, localPath);
|
|
1152
1246
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
1153
1247
|
});
|
|
@@ -1160,8 +1254,8 @@ ${result.stderr}`);
|
|
|
1160
1254
|
...connectionParams,
|
|
1161
1255
|
path: z.string().describe("Absolute path to the remote directory")
|
|
1162
1256
|
},
|
|
1163
|
-
async ({
|
|
1164
|
-
return connectionPool.withConnection(
|
|
1257
|
+
async ({ path, ...conn }) => {
|
|
1258
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1165
1259
|
const files = await listDir(client, path);
|
|
1166
1260
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1167
1261
|
});
|
|
@@ -1367,21 +1461,8 @@ ${result.stderr}`);
|
|
|
1367
1461
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1368
1462
|
timeout: TimeoutSchema
|
|
1369
1463
|
},
|
|
1370
|
-
async ({
|
|
1371
|
-
|
|
1372
|
-
port,
|
|
1373
|
-
username,
|
|
1374
|
-
privateKeyPath,
|
|
1375
|
-
password,
|
|
1376
|
-
path,
|
|
1377
|
-
name,
|
|
1378
|
-
type,
|
|
1379
|
-
maxdepth,
|
|
1380
|
-
minsize,
|
|
1381
|
-
maxsize,
|
|
1382
|
-
timeout
|
|
1383
|
-
}) => {
|
|
1384
|
-
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) => {
|
|
1385
1466
|
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1386
1467
|
if (files.length === 0) {
|
|
1387
1468
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
@@ -1401,8 +1482,8 @@ ${files.join("\n")}` }] };
|
|
|
1401
1482
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1402
1483
|
timeout: TimeoutSchema
|
|
1403
1484
|
},
|
|
1404
|
-
async ({
|
|
1405
|
-
return connectionPool.withConnection(
|
|
1485
|
+
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
1486
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1406
1487
|
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1407
1488
|
if (!output.trim()) {
|
|
1408
1489
|
return {
|
|
@@ -1426,8 +1507,8 @@ ${files.join("\n")}` }] };
|
|
|
1426
1507
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1427
1508
|
timeout: TimeoutSchema
|
|
1428
1509
|
},
|
|
1429
|
-
async ({
|
|
1430
|
-
return connectionPool.withConnection(
|
|
1510
|
+
async ({ service, timeout, ...conn }) => {
|
|
1511
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1431
1512
|
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1432
1513
|
const lines = [];
|
|
1433
1514
|
lines.push(`Service: ${status.name}`);
|