@yawlabs/ssh-mcp 0.7.0 → 0.9.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 +219 -213
- package/dist/index.js +791 -679
- package/dist/server.d.ts +44 -40
- package/dist/server.js +255 -152
- package/package.json +61 -61
package/dist/server.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// src/server.ts
|
|
2
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
3
|
+
import { dirname, join as join4 } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
2
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
6
|
|
|
4
7
|
// src/tools.ts
|
|
@@ -6,7 +9,7 @@ import { z } from "zod";
|
|
|
6
9
|
|
|
7
10
|
// src/diagnose.ts
|
|
8
11
|
import { execFileSync } from "child_process";
|
|
9
|
-
import { existsSync,
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
10
13
|
import { homedir } from "os";
|
|
11
14
|
import { join } from "path";
|
|
12
15
|
function isValidHostname(host) {
|
|
@@ -14,7 +17,7 @@ function isValidHostname(host) {
|
|
|
14
17
|
if (host.startsWith("[")) {
|
|
15
18
|
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
16
19
|
}
|
|
17
|
-
return /^[a-zA-Z0-9._
|
|
20
|
+
return /^[a-zA-Z0-9._-]+$/.test(host);
|
|
18
21
|
}
|
|
19
22
|
function runArgs(cmd, args) {
|
|
20
23
|
try {
|
|
@@ -256,40 +259,33 @@ function diagnose(host, port = 22) {
|
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
// src/env.ts
|
|
259
|
-
import { appendFileSync, existsSync as existsSync2,
|
|
262
|
+
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
260
263
|
import { homedir as homedir2 } from "os";
|
|
261
264
|
import { join as join2 } from "path";
|
|
265
|
+
function probeAgent(socket, agentLabel) {
|
|
266
|
+
const { stdout, ok } = runArgs("ssh-add", ["-l"]);
|
|
267
|
+
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
268
|
+
if (!ok && !noIdentities) return null;
|
|
269
|
+
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
270
|
+
return {
|
|
271
|
+
running: true,
|
|
272
|
+
reachable: true,
|
|
273
|
+
socket,
|
|
274
|
+
keys,
|
|
275
|
+
started: false,
|
|
276
|
+
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.`
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
var startedAgentPid = null;
|
|
262
280
|
function ensureAgent() {
|
|
263
281
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
264
282
|
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
|
-
}
|
|
283
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
284
|
+
if (result) return result;
|
|
278
285
|
}
|
|
279
286
|
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
|
-
}
|
|
287
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
288
|
+
if (result) return result;
|
|
293
289
|
}
|
|
294
290
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
295
291
|
if (ok) {
|
|
@@ -297,7 +293,10 @@ function ensureAgent() {
|
|
|
297
293
|
const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
|
|
298
294
|
if (sockMatch) {
|
|
299
295
|
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
300
|
-
if (pidMatch)
|
|
296
|
+
if (pidMatch) {
|
|
297
|
+
process.env.SSH_AGENT_PID = pidMatch[1];
|
|
298
|
+
startedAgentPid = Number.parseInt(pidMatch[1], 10);
|
|
299
|
+
}
|
|
301
300
|
return {
|
|
302
301
|
running: true,
|
|
303
302
|
reachable: true,
|
|
@@ -305,7 +304,7 @@ function ensureAgent() {
|
|
|
305
304
|
keys: [],
|
|
306
305
|
started: true,
|
|
307
306
|
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."
|
|
307
|
+
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
308
|
};
|
|
310
309
|
}
|
|
311
310
|
}
|
|
@@ -431,8 +430,8 @@ function configLookup(host) {
|
|
|
431
430
|
user: all.user || "",
|
|
432
431
|
port: all.port || "22",
|
|
433
432
|
identityFile: identityFiles,
|
|
434
|
-
proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
435
|
-
proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
433
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
434
|
+
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
436
435
|
all,
|
|
437
436
|
raw: stdout
|
|
438
437
|
};
|
|
@@ -474,7 +473,7 @@ function checkGitSsh(host = "github.com", user = "git") {
|
|
|
474
473
|
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
475
474
|
const text = stdout;
|
|
476
475
|
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
477
|
-
const userMatch = text.match(/Hi (\S
|
|
476
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
478
477
|
return {
|
|
479
478
|
status: "ok",
|
|
480
479
|
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
@@ -626,33 +625,54 @@ function resolveConfig(config) {
|
|
|
626
625
|
keepaliveCountMax: 3,
|
|
627
626
|
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
628
627
|
};
|
|
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
628
|
if (config.privateKeyPath) {
|
|
637
629
|
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
638
|
-
} else if (
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
630
|
+
} else if (config.password) {
|
|
631
|
+
connectConfig.password = config.password;
|
|
632
|
+
} else {
|
|
633
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
634
|
+
if (agentSock) {
|
|
635
|
+
connectConfig.agent = agentSock;
|
|
636
|
+
} else {
|
|
637
|
+
const home = homedir3();
|
|
638
|
+
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")];
|
|
639
|
+
for (const keyPath of keyPaths) {
|
|
640
|
+
try {
|
|
641
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
642
|
+
break;
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
646
645
|
}
|
|
647
646
|
}
|
|
648
647
|
}
|
|
649
648
|
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
650
649
|
}
|
|
650
|
+
var DIAG_CACHE_TTL_MS = 2e3;
|
|
651
|
+
var diagAgentCache = null;
|
|
652
|
+
var diagKeysCache = null;
|
|
653
|
+
function cachedAgentCheck() {
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
|
|
656
|
+
return diagAgentCache.result;
|
|
657
|
+
}
|
|
658
|
+
const result = checkSshAgent();
|
|
659
|
+
diagAgentCache = { at: now, result };
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
function cachedKeysCheck() {
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
|
|
665
|
+
return diagKeysCache.result;
|
|
666
|
+
}
|
|
667
|
+
const result = checkSshKeys();
|
|
668
|
+
diagKeysCache = { at: now, result };
|
|
669
|
+
return result;
|
|
670
|
+
}
|
|
651
671
|
function formatDiagnostics(host) {
|
|
652
672
|
try {
|
|
653
673
|
const checks = [
|
|
654
|
-
{ name: "SSH Agent", ...
|
|
655
|
-
{ name: "SSH Keys", ...
|
|
674
|
+
{ name: "SSH Agent", ...cachedAgentCheck() },
|
|
675
|
+
{ name: "SSH Keys", ...cachedKeysCheck() },
|
|
656
676
|
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
657
677
|
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
658
678
|
];
|
|
@@ -729,9 +749,11 @@ ${diag}`);
|
|
|
729
749
|
throw err;
|
|
730
750
|
}
|
|
731
751
|
}
|
|
732
|
-
|
|
752
|
+
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
753
|
+
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
733
754
|
return new Promise((resolve, reject) => {
|
|
734
755
|
let settled = false;
|
|
756
|
+
let activeStream = null;
|
|
735
757
|
const settle = (fn) => {
|
|
736
758
|
if (settled) return;
|
|
737
759
|
settled = true;
|
|
@@ -739,6 +761,16 @@ function exec(client, command, timeoutMs = 3e4) {
|
|
|
739
761
|
fn();
|
|
740
762
|
};
|
|
741
763
|
const timer = setTimeout(() => {
|
|
764
|
+
if (activeStream) {
|
|
765
|
+
try {
|
|
766
|
+
activeStream.signal("TERM");
|
|
767
|
+
} catch {
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
activeStream.close();
|
|
771
|
+
} catch {
|
|
772
|
+
}
|
|
773
|
+
}
|
|
742
774
|
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
743
775
|
}, timeoutMs);
|
|
744
776
|
client.exec(command, (err, stream) => {
|
|
@@ -746,18 +778,53 @@ function exec(client, command, timeoutMs = 3e4) {
|
|
|
746
778
|
settle(() => reject(err));
|
|
747
779
|
return;
|
|
748
780
|
}
|
|
749
|
-
|
|
750
|
-
|
|
781
|
+
activeStream = stream;
|
|
782
|
+
const stdoutChunks = [];
|
|
783
|
+
const stderrChunks = [];
|
|
784
|
+
let stdoutBytes = 0;
|
|
785
|
+
let stderrBytes = 0;
|
|
786
|
+
let stdoutTruncated = false;
|
|
787
|
+
let stderrTruncated = false;
|
|
788
|
+
const appendStdout = (data) => {
|
|
789
|
+
if (stdoutTruncated) return;
|
|
790
|
+
const remaining = maxBytes - stdoutBytes;
|
|
791
|
+
if (data.length <= remaining) {
|
|
792
|
+
stdoutChunks.push(data);
|
|
793
|
+
stdoutBytes += data.length;
|
|
794
|
+
} else {
|
|
795
|
+
if (remaining > 0) {
|
|
796
|
+
stdoutChunks.push(data.subarray(0, remaining));
|
|
797
|
+
stdoutBytes += remaining;
|
|
798
|
+
}
|
|
799
|
+
stdoutTruncated = true;
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
const appendStderr = (data) => {
|
|
803
|
+
if (stderrTruncated) return;
|
|
804
|
+
const remaining = maxBytes - stderrBytes;
|
|
805
|
+
if (data.length <= remaining) {
|
|
806
|
+
stderrChunks.push(data);
|
|
807
|
+
stderrBytes += data.length;
|
|
808
|
+
} else {
|
|
809
|
+
if (remaining > 0) {
|
|
810
|
+
stderrChunks.push(data.subarray(0, remaining));
|
|
811
|
+
stderrBytes += remaining;
|
|
812
|
+
}
|
|
813
|
+
stderrTruncated = true;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
751
816
|
stream.on("close", (code) => {
|
|
817
|
+
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
818
|
+
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
819
|
+
if (stdoutTruncated) stdout += `
|
|
820
|
+
[output truncated at ${maxBytes} bytes]`;
|
|
821
|
+
if (stderrTruncated) stderr += `
|
|
822
|
+
[stderr truncated at ${maxBytes} bytes]`;
|
|
752
823
|
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
753
|
-
}).on("data", (
|
|
754
|
-
stdout += data.toString();
|
|
755
|
-
}).on("error", (err2) => {
|
|
824
|
+
}).on("data", appendStdout).on("error", (err2) => {
|
|
756
825
|
settle(() => reject(err2));
|
|
757
826
|
});
|
|
758
|
-
stream.stderr.on("data", (
|
|
759
|
-
stderr += data.toString();
|
|
760
|
-
}).on("error", (err2) => {
|
|
827
|
+
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
761
828
|
settle(() => reject(err2));
|
|
762
829
|
});
|
|
763
830
|
});
|
|
@@ -887,39 +954,43 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
887
954
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
888
955
|
);
|
|
889
956
|
}
|
|
890
|
-
const args = [shellQuote(options.path)];
|
|
957
|
+
const args = ["--", shellQuote(options.path)];
|
|
891
958
|
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
892
959
|
if (options.type) args.push("-type", options.type);
|
|
893
960
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
894
961
|
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
895
962
|
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
896
963
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
897
|
-
const command = `find ${args.join(" ")}
|
|
964
|
+
const command = `find ${args.join(" ")}`;
|
|
898
965
|
const result = await exec(client, command, timeoutMs);
|
|
966
|
+
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
967
|
+
throw new Error(result.stderr.trim());
|
|
968
|
+
}
|
|
899
969
|
return result.stdout.split("\n").filter(Boolean);
|
|
900
970
|
}
|
|
901
971
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
902
|
-
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
972
|
+
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
903
973
|
if (grep) {
|
|
904
|
-
command += ` | grep -i ${shellQuote(grep)}`;
|
|
974
|
+
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
905
975
|
}
|
|
906
976
|
const result = await exec(client, command, timeoutMs);
|
|
907
|
-
if (result.
|
|
977
|
+
if (result.stderr.trim()) {
|
|
908
978
|
throw new Error(result.stderr.trim());
|
|
909
979
|
}
|
|
910
980
|
return result.stdout;
|
|
911
981
|
}
|
|
912
982
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
913
|
-
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
983
|
+
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
914
984
|
const raw = result.stdout;
|
|
915
985
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
916
986
|
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
917
987
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
918
988
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
989
|
+
const fallbackStatus = result.code === 0 ? "active" : "inactive";
|
|
919
990
|
return {
|
|
920
991
|
name: serviceName,
|
|
921
992
|
active: activeMatch?.[1] === "active",
|
|
922
|
-
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` :
|
|
993
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
|
|
923
994
|
description: descMatch?.[1]?.trim(),
|
|
924
995
|
since: sinceMatch?.[1]?.trim(),
|
|
925
996
|
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
@@ -930,8 +1001,14 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
930
1001
|
// src/pool.ts
|
|
931
1002
|
var ConnectionPool = class {
|
|
932
1003
|
entries = /* @__PURE__ */ new Map();
|
|
1004
|
+
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
1005
|
+
// duplicate TCP connections when N tool calls fire simultaneously.
|
|
1006
|
+
pending = /* @__PURE__ */ new Map();
|
|
933
1007
|
idleTtlMs;
|
|
934
1008
|
maxPoolSize;
|
|
1009
|
+
// Total number of successful connects ever made by this pool. Useful for
|
|
1010
|
+
// introspection and for tests that want to prove connection reuse.
|
|
1011
|
+
_connectCount = 0;
|
|
935
1012
|
constructor(options) {
|
|
936
1013
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
937
1014
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -940,67 +1017,98 @@ var ConnectionPool = class {
|
|
|
940
1017
|
const resolved = resolveConfig(config);
|
|
941
1018
|
const cc = resolved.connectConfig;
|
|
942
1019
|
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;
|
|
1020
|
+
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
1021
|
+
let lastErr;
|
|
1022
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
1023
|
+
const existing = this.entries.get(key);
|
|
1024
|
+
if (existing && !existing.dead) {
|
|
1025
|
+
existing.refCount++;
|
|
1026
|
+
if (existing.idleTimer) {
|
|
1027
|
+
clearTimeout(existing.idleTimer);
|
|
1028
|
+
existing.idleTimer = null;
|
|
967
1029
|
}
|
|
1030
|
+
return existing.client;
|
|
968
1031
|
}
|
|
969
|
-
if (
|
|
970
|
-
|
|
1032
|
+
if (existing?.dead) {
|
|
1033
|
+
this.entries.delete(key);
|
|
971
1034
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1035
|
+
let pending = this.pending.get(key);
|
|
1036
|
+
if (!pending) {
|
|
1037
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
1038
|
+
let evicted = false;
|
|
1039
|
+
for (const [k, e] of this.entries) {
|
|
1040
|
+
if (e.refCount === 0) {
|
|
1041
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
1042
|
+
try {
|
|
1043
|
+
e.client.end();
|
|
1044
|
+
} catch {
|
|
1045
|
+
}
|
|
1046
|
+
this.entries.delete(k);
|
|
1047
|
+
evicted = true;
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (!evicted) {
|
|
1052
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
1053
|
+
}
|
|
984
1054
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1055
|
+
pending = (async () => {
|
|
1056
|
+
try {
|
|
1057
|
+
const client2 = await connectWithProxy(resolved);
|
|
1058
|
+
this._connectCount++;
|
|
1059
|
+
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
1060
|
+
const markDead = () => {
|
|
1061
|
+
entry2.dead = true;
|
|
1062
|
+
if (entry2.idleTimer) {
|
|
1063
|
+
clearTimeout(entry2.idleTimer);
|
|
1064
|
+
entry2.idleTimer = null;
|
|
1065
|
+
}
|
|
1066
|
+
if (this.entries.get(key) === entry2) {
|
|
1067
|
+
this.entries.delete(key);
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
client2.on("close", markDead);
|
|
1071
|
+
client2.on("end", markDead);
|
|
1072
|
+
client2.on("error", markDead);
|
|
1073
|
+
this.entries.set(key, entry2);
|
|
1074
|
+
return client2;
|
|
1075
|
+
} finally {
|
|
1076
|
+
this.pending.delete(key);
|
|
1077
|
+
}
|
|
1078
|
+
})();
|
|
1079
|
+
this.pending.set(key, pending);
|
|
1080
|
+
}
|
|
1081
|
+
let client;
|
|
1082
|
+
try {
|
|
1083
|
+
client = await pending;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
const diag = formatDiagnostics(config.host);
|
|
1086
|
+
if (diag) {
|
|
1087
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1088
|
+
const enhanced = new Error(`${message}
|
|
996
1089
|
|
|
997
1090
|
SSH Diagnostics:
|
|
998
1091
|
${diag}`);
|
|
999
|
-
|
|
1000
|
-
|
|
1092
|
+
enhanced.cause = err;
|
|
1093
|
+
throw enhanced;
|
|
1094
|
+
}
|
|
1095
|
+
throw err;
|
|
1096
|
+
}
|
|
1097
|
+
const entry = this.entries.get(key);
|
|
1098
|
+
if (!entry || entry.dead || entry.client !== client) {
|
|
1099
|
+
lastErr = new Error("connection died before acquire could take a ref");
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
entry.refCount++;
|
|
1103
|
+
if (entry.idleTimer) {
|
|
1104
|
+
clearTimeout(entry.idleTimer);
|
|
1105
|
+
entry.idleTimer = null;
|
|
1001
1106
|
}
|
|
1002
|
-
|
|
1107
|
+
return client;
|
|
1003
1108
|
}
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
`Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
1111
|
+
);
|
|
1004
1112
|
}
|
|
1005
1113
|
release(client) {
|
|
1006
1114
|
for (const entry of this.entries.values()) {
|
|
@@ -1056,6 +1164,10 @@ ${diag}`);
|
|
|
1056
1164
|
}
|
|
1057
1165
|
return { active, idle };
|
|
1058
1166
|
}
|
|
1167
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
1168
|
+
get connectCount() {
|
|
1169
|
+
return this._connectCount;
|
|
1170
|
+
}
|
|
1059
1171
|
};
|
|
1060
1172
|
|
|
1061
1173
|
// src/tools.ts
|
|
@@ -1063,7 +1175,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
|
1063
1175
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1064
1176
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1065
1177
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
1066
|
-
var PasswordSchema = z.string().optional().describe(
|
|
1178
|
+
var PasswordSchema = z.string().optional().describe(
|
|
1179
|
+
"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."
|
|
1180
|
+
);
|
|
1067
1181
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1068
1182
|
var connectionParams = {
|
|
1069
1183
|
host: HostSchema,
|
|
@@ -1082,8 +1196,8 @@ function registerTools(server, pool) {
|
|
|
1082
1196
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1083
1197
|
timeout: TimeoutSchema
|
|
1084
1198
|
},
|
|
1085
|
-
async ({
|
|
1086
|
-
return connectionPool.withConnection(
|
|
1199
|
+
async ({ command, timeout, ...conn }) => {
|
|
1200
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1087
1201
|
const result = await exec(client, command, timeout || 3e4);
|
|
1088
1202
|
const parts = [];
|
|
1089
1203
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -1101,8 +1215,8 @@ ${result.stderr}`);
|
|
|
1101
1215
|
...connectionParams,
|
|
1102
1216
|
path: z.string().describe("Absolute path to the remote file")
|
|
1103
1217
|
},
|
|
1104
|
-
async ({
|
|
1105
|
-
return connectionPool.withConnection(
|
|
1218
|
+
async ({ path, ...conn }) => {
|
|
1219
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1106
1220
|
const content = await readFile(client, path);
|
|
1107
1221
|
return { content: [{ type: "text", text: content }] };
|
|
1108
1222
|
});
|
|
@@ -1116,8 +1230,8 @@ ${result.stderr}`);
|
|
|
1116
1230
|
path: z.string().describe("Absolute path to the remote file"),
|
|
1117
1231
|
content: z.string().describe("File content to write")
|
|
1118
1232
|
},
|
|
1119
|
-
async ({
|
|
1120
|
-
return connectionPool.withConnection(
|
|
1233
|
+
async ({ path, content, ...conn }) => {
|
|
1234
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1121
1235
|
await writeFile(client, path, content);
|
|
1122
1236
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
1123
1237
|
});
|
|
@@ -1131,8 +1245,8 @@ ${result.stderr}`);
|
|
|
1131
1245
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1132
1246
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
1133
1247
|
},
|
|
1134
|
-
async ({
|
|
1135
|
-
return connectionPool.withConnection(
|
|
1248
|
+
async ({ localPath, remotePath, ...conn }) => {
|
|
1249
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1136
1250
|
await uploadFile(client, localPath, remotePath);
|
|
1137
1251
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
1138
1252
|
});
|
|
@@ -1146,8 +1260,8 @@ ${result.stderr}`);
|
|
|
1146
1260
|
remotePath: z.string().describe("Absolute path to the remote file"),
|
|
1147
1261
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1148
1262
|
},
|
|
1149
|
-
async ({
|
|
1150
|
-
return connectionPool.withConnection(
|
|
1263
|
+
async ({ remotePath, localPath, ...conn }) => {
|
|
1264
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1151
1265
|
await downloadFile(client, remotePath, localPath);
|
|
1152
1266
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
1153
1267
|
});
|
|
@@ -1160,8 +1274,8 @@ ${result.stderr}`);
|
|
|
1160
1274
|
...connectionParams,
|
|
1161
1275
|
path: z.string().describe("Absolute path to the remote directory")
|
|
1162
1276
|
},
|
|
1163
|
-
async ({
|
|
1164
|
-
return connectionPool.withConnection(
|
|
1277
|
+
async ({ path, ...conn }) => {
|
|
1278
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1165
1279
|
const files = await listDir(client, path);
|
|
1166
1280
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1167
1281
|
});
|
|
@@ -1367,21 +1481,8 @@ ${result.stderr}`);
|
|
|
1367
1481
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1368
1482
|
timeout: TimeoutSchema
|
|
1369
1483
|
},
|
|
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) => {
|
|
1484
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1485
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1385
1486
|
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1386
1487
|
if (files.length === 0) {
|
|
1387
1488
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
@@ -1401,8 +1502,8 @@ ${files.join("\n")}` }] };
|
|
|
1401
1502
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1402
1503
|
timeout: TimeoutSchema
|
|
1403
1504
|
},
|
|
1404
|
-
async ({
|
|
1405
|
-
return connectionPool.withConnection(
|
|
1505
|
+
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
1506
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1406
1507
|
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1407
1508
|
if (!output.trim()) {
|
|
1408
1509
|
return {
|
|
@@ -1426,8 +1527,8 @@ ${files.join("\n")}` }] };
|
|
|
1426
1527
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1427
1528
|
timeout: TimeoutSchema
|
|
1428
1529
|
},
|
|
1429
|
-
async ({
|
|
1430
|
-
return connectionPool.withConnection(
|
|
1530
|
+
async ({ service, timeout, ...conn }) => {
|
|
1531
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1431
1532
|
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1432
1533
|
const lines = [];
|
|
1433
1534
|
lines.push(`Service: ${status.name}`);
|
|
@@ -1444,10 +1545,12 @@ ${files.join("\n")}` }] };
|
|
|
1444
1545
|
}
|
|
1445
1546
|
|
|
1446
1547
|
// src/server.ts
|
|
1548
|
+
var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1549
|
+
var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1447
1550
|
function createServer(pool) {
|
|
1448
1551
|
const server = new McpServer({
|
|
1449
1552
|
name: "ssh-mcp",
|
|
1450
|
-
version
|
|
1553
|
+
version
|
|
1451
1554
|
});
|
|
1452
1555
|
registerTools(server, pool);
|
|
1453
1556
|
return server;
|