@yawlabs/ssh-mcp 0.3.0 → 0.5.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 +43 -5
- package/dist/index.js +597 -228
- package/dist/server.d.ts +81 -17
- package/dist/server.js +446 -59
- package/package.json +2 -1
package/dist/server.js
CHANGED
|
@@ -25,6 +25,25 @@ function runArgs(cmd, args) {
|
|
|
25
25
|
}
|
|
26
26
|
function checkSshAgent() {
|
|
27
27
|
const sock = process.env.SSH_AUTH_SOCK;
|
|
28
|
+
if (!sock && process.platform === "win32") {
|
|
29
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
30
|
+
if (ok2) {
|
|
31
|
+
return { status: "ok", message: `Windows OpenSSH agent running with keys:
|
|
32
|
+
${stdout2}` };
|
|
33
|
+
}
|
|
34
|
+
if (stdout2.includes("no identities") || stdout2.includes("The agent has no identities")) {
|
|
35
|
+
return {
|
|
36
|
+
status: "warning",
|
|
37
|
+
message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!stdout2.includes("Error connecting") && !stdout2.includes("unable to")) {
|
|
41
|
+
return {
|
|
42
|
+
status: "warning",
|
|
43
|
+
message: "Windows OpenSSH Authentication Agent may not be running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
if (!sock) {
|
|
29
48
|
return {
|
|
30
49
|
status: "error",
|
|
@@ -254,6 +273,21 @@ function ensureAgent() {
|
|
|
254
273
|
};
|
|
255
274
|
}
|
|
256
275
|
}
|
|
276
|
+
if (!sock && process.platform === "win32") {
|
|
277
|
+
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
278
|
+
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
279
|
+
if (ok2 || noIdentities) {
|
|
280
|
+
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
281
|
+
return {
|
|
282
|
+
running: true,
|
|
283
|
+
reachable: true,
|
|
284
|
+
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
285
|
+
keys,
|
|
286
|
+
started: false,
|
|
287
|
+
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."
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
257
291
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
258
292
|
if (ok) {
|
|
259
293
|
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
@@ -277,7 +311,7 @@ function ensureAgent() {
|
|
|
277
311
|
reachable: false,
|
|
278
312
|
keys: [],
|
|
279
313
|
started: false,
|
|
280
|
-
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
314
|
+
message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
281
315
|
};
|
|
282
316
|
}
|
|
283
317
|
function detectKeyType(filePath, fileName) {
|
|
@@ -515,23 +549,57 @@ import { readFileSync as readFileSync3 } from "fs";
|
|
|
515
549
|
import { homedir as homedir3 } from "os";
|
|
516
550
|
import { join as join3 } from "path";
|
|
517
551
|
import { Client } from "ssh2";
|
|
552
|
+
function resolveFromSshConfig(host) {
|
|
553
|
+
try {
|
|
554
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
555
|
+
if (!ok) return null;
|
|
556
|
+
const config = {};
|
|
557
|
+
const identityFiles = [];
|
|
558
|
+
for (const line of stdout.split("\n")) {
|
|
559
|
+
const spaceIdx = line.indexOf(" ");
|
|
560
|
+
if (spaceIdx > 0) {
|
|
561
|
+
const key = line.substring(0, spaceIdx);
|
|
562
|
+
const value = line.substring(spaceIdx + 1);
|
|
563
|
+
if (key === "identityfile") {
|
|
564
|
+
identityFiles.push(value);
|
|
565
|
+
} else {
|
|
566
|
+
config[key] = value;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
hostname: config.hostname || host,
|
|
572
|
+
user: config.user || "",
|
|
573
|
+
port: config.port || "22",
|
|
574
|
+
identityFiles,
|
|
575
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
576
|
+
};
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
518
581
|
function resolveConfig(config) {
|
|
582
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
519
583
|
const connectConfig = {
|
|
520
|
-
host: config.host,
|
|
521
|
-
port: config.port || 22,
|
|
522
|
-
username: config.username || process.env.USER || process.env.USERNAME || "root"
|
|
584
|
+
host: sshConfig?.hostname || config.host,
|
|
585
|
+
port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
|
|
586
|
+
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
587
|
+
keepaliveInterval: 15e3,
|
|
588
|
+
keepaliveCountMax: 3
|
|
523
589
|
};
|
|
590
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
591
|
+
if (agentSock) {
|
|
592
|
+
connectConfig.agent = agentSock;
|
|
593
|
+
}
|
|
594
|
+
if (config.password) {
|
|
595
|
+
connectConfig.password = config.password;
|
|
596
|
+
}
|
|
524
597
|
if (config.privateKeyPath) {
|
|
525
598
|
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
526
|
-
} else if (
|
|
527
|
-
connectConfig.password = config.password;
|
|
528
|
-
} else if (config.agent || process.env.SSH_AUTH_SOCK) {
|
|
529
|
-
connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
|
|
530
|
-
} else {
|
|
599
|
+
} else if (!agentSock) {
|
|
531
600
|
const home = homedir3();
|
|
532
|
-
const
|
|
533
|
-
for (const
|
|
534
|
-
const keyPath = join3(home, ".ssh", keyName);
|
|
601
|
+
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")];
|
|
602
|
+
for (const keyPath of keyPaths) {
|
|
535
603
|
try {
|
|
536
604
|
connectConfig.privateKey = readFileSync3(keyPath);
|
|
537
605
|
break;
|
|
@@ -539,7 +607,7 @@ function resolveConfig(config) {
|
|
|
539
607
|
}
|
|
540
608
|
}
|
|
541
609
|
}
|
|
542
|
-
return connectConfig;
|
|
610
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
543
611
|
}
|
|
544
612
|
function formatDiagnostics(host) {
|
|
545
613
|
try {
|
|
@@ -571,24 +639,56 @@ function formatDiagnostics(host) {
|
|
|
571
639
|
return "";
|
|
572
640
|
}
|
|
573
641
|
}
|
|
574
|
-
function
|
|
642
|
+
function connectRaw(connectConfig) {
|
|
643
|
+
return new Promise((resolve, reject) => {
|
|
644
|
+
const client = new Client();
|
|
645
|
+
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
async function connectWithProxy(resolved) {
|
|
649
|
+
if (!resolved.proxyJump) {
|
|
650
|
+
return connectRaw(resolved.connectConfig);
|
|
651
|
+
}
|
|
652
|
+
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
653
|
+
const jumpClient = await connectWithProxy(jumpResolved);
|
|
654
|
+
const targetHost = resolved.connectConfig.host;
|
|
655
|
+
const targetPort = resolved.connectConfig.port;
|
|
656
|
+
const stream = await new Promise((resolve, reject) => {
|
|
657
|
+
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
658
|
+
if (err) {
|
|
659
|
+
jumpClient.end();
|
|
660
|
+
return reject(err);
|
|
661
|
+
}
|
|
662
|
+
resolve(stream2);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
575
665
|
return new Promise((resolve, reject) => {
|
|
576
666
|
const client = new Client();
|
|
577
|
-
const connectConfig = resolveConfig(config);
|
|
578
667
|
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
668
|
+
jumpClient.end();
|
|
669
|
+
reject(err);
|
|
670
|
+
}).on("close", () => {
|
|
671
|
+
jumpClient.end();
|
|
672
|
+
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async function connect(config) {
|
|
676
|
+
const resolved = resolveConfig(config);
|
|
677
|
+
try {
|
|
678
|
+
return await connectWithProxy(resolved);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
const diag = formatDiagnostics(config.host);
|
|
681
|
+
if (diag) {
|
|
682
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
683
|
+
const enhanced = new Error(`${message}
|
|
582
684
|
|
|
583
685
|
SSH Diagnostics:
|
|
584
686
|
${diag}`);
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}).connect(connectConfig);
|
|
591
|
-
});
|
|
687
|
+
enhanced.cause = err;
|
|
688
|
+
throw enhanced;
|
|
689
|
+
}
|
|
690
|
+
throw err;
|
|
691
|
+
}
|
|
592
692
|
}
|
|
593
693
|
function exec(client, command, timeoutMs = 3e4) {
|
|
594
694
|
return new Promise((resolve, reject) => {
|
|
@@ -698,6 +798,184 @@ async function listDir(client, remotePath) {
|
|
|
698
798
|
}
|
|
699
799
|
}
|
|
700
800
|
|
|
801
|
+
// src/ops.ts
|
|
802
|
+
function shellQuote(s) {
|
|
803
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
804
|
+
}
|
|
805
|
+
async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
806
|
+
const results = await Promise.allSettled(
|
|
807
|
+
hosts.map(async (hostConfig) => {
|
|
808
|
+
return pool.withConnection(hostConfig, async (client) => {
|
|
809
|
+
const result = await exec(client, command, timeoutMs);
|
|
810
|
+
return { host: hostConfig.host, ...result };
|
|
811
|
+
});
|
|
812
|
+
})
|
|
813
|
+
);
|
|
814
|
+
return results.map((result, i) => {
|
|
815
|
+
if (result.status === "fulfilled") {
|
|
816
|
+
return result.value;
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
host: hosts[i].host,
|
|
820
|
+
stdout: "",
|
|
821
|
+
stderr: "",
|
|
822
|
+
code: -1,
|
|
823
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
824
|
+
};
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
async function find(client, options, timeoutMs = 3e4) {
|
|
828
|
+
const args = [shellQuote(options.path)];
|
|
829
|
+
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
830
|
+
if (options.type) args.push("-type", options.type);
|
|
831
|
+
if (options.name) args.push("-name", shellQuote(options.name));
|
|
832
|
+
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
833
|
+
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
834
|
+
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
835
|
+
const command = `find ${args.join(" ")} 2>/dev/null`;
|
|
836
|
+
const result = await exec(client, command, timeoutMs);
|
|
837
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
838
|
+
}
|
|
839
|
+
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
840
|
+
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
841
|
+
if (grep) {
|
|
842
|
+
command += ` | grep -i ${shellQuote(grep)}`;
|
|
843
|
+
}
|
|
844
|
+
const result = await exec(client, command, timeoutMs);
|
|
845
|
+
if (result.code !== 0 && result.stderr && !grep) {
|
|
846
|
+
throw new Error(result.stderr.trim());
|
|
847
|
+
}
|
|
848
|
+
return result.stdout;
|
|
849
|
+
}
|
|
850
|
+
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
851
|
+
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
852
|
+
const raw = result.stdout;
|
|
853
|
+
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
854
|
+
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
855
|
+
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
856
|
+
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
857
|
+
return {
|
|
858
|
+
name: serviceName,
|
|
859
|
+
active: activeMatch?.[1] === "active",
|
|
860
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
|
|
861
|
+
description: descMatch?.[1]?.trim(),
|
|
862
|
+
since: sinceMatch?.[1]?.trim(),
|
|
863
|
+
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
864
|
+
raw
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/pool.ts
|
|
869
|
+
var ConnectionPool = class {
|
|
870
|
+
entries = /* @__PURE__ */ new Map();
|
|
871
|
+
idleTtlMs;
|
|
872
|
+
constructor(options) {
|
|
873
|
+
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
874
|
+
}
|
|
875
|
+
async acquire(config) {
|
|
876
|
+
const resolved = resolveConfig(config);
|
|
877
|
+
const cc = resolved.connectConfig;
|
|
878
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
879
|
+
const existing = this.entries.get(key);
|
|
880
|
+
if (existing && !existing.dead) {
|
|
881
|
+
existing.refCount++;
|
|
882
|
+
if (existing.idleTimer) {
|
|
883
|
+
clearTimeout(existing.idleTimer);
|
|
884
|
+
existing.idleTimer = null;
|
|
885
|
+
}
|
|
886
|
+
return existing.client;
|
|
887
|
+
}
|
|
888
|
+
if (existing?.dead) {
|
|
889
|
+
this.entries.delete(key);
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
const client = await connectWithProxy(resolved);
|
|
893
|
+
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
894
|
+
const markDead = () => {
|
|
895
|
+
entry.dead = true;
|
|
896
|
+
if (entry.idleTimer) {
|
|
897
|
+
clearTimeout(entry.idleTimer);
|
|
898
|
+
entry.idleTimer = null;
|
|
899
|
+
}
|
|
900
|
+
if (this.entries.get(key) === entry) {
|
|
901
|
+
this.entries.delete(key);
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
client.on("close", markDead);
|
|
905
|
+
client.on("end", markDead);
|
|
906
|
+
client.on("error", markDead);
|
|
907
|
+
this.entries.set(key, entry);
|
|
908
|
+
return client;
|
|
909
|
+
} catch (err) {
|
|
910
|
+
const diag = formatDiagnostics(config.host);
|
|
911
|
+
if (diag) {
|
|
912
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
913
|
+
const enhanced = new Error(`${message}
|
|
914
|
+
|
|
915
|
+
SSH Diagnostics:
|
|
916
|
+
${diag}`);
|
|
917
|
+
enhanced.cause = err;
|
|
918
|
+
throw enhanced;
|
|
919
|
+
}
|
|
920
|
+
throw err;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
release(client) {
|
|
924
|
+
for (const entry of this.entries.values()) {
|
|
925
|
+
if (entry.client === client) {
|
|
926
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
927
|
+
if (entry.refCount === 0 && !entry.dead) {
|
|
928
|
+
entry.idleTimer = setTimeout(() => {
|
|
929
|
+
try {
|
|
930
|
+
entry.client.end();
|
|
931
|
+
} catch {
|
|
932
|
+
}
|
|
933
|
+
this.entries.delete(entry.key);
|
|
934
|
+
}, this.idleTtlMs);
|
|
935
|
+
entry.idleTimer.unref();
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
client.end();
|
|
942
|
+
} catch {
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async withConnection(config, fn) {
|
|
946
|
+
const client = await this.acquire(config);
|
|
947
|
+
try {
|
|
948
|
+
return await fn(client);
|
|
949
|
+
} finally {
|
|
950
|
+
this.release(client);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
drain() {
|
|
954
|
+
for (const entry of this.entries.values()) {
|
|
955
|
+
if (entry.idleTimer) {
|
|
956
|
+
clearTimeout(entry.idleTimer);
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
entry.client.end();
|
|
960
|
+
} catch {
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
this.entries.clear();
|
|
964
|
+
}
|
|
965
|
+
get size() {
|
|
966
|
+
return this.entries.size;
|
|
967
|
+
}
|
|
968
|
+
get stats() {
|
|
969
|
+
let active = 0;
|
|
970
|
+
let idle = 0;
|
|
971
|
+
for (const entry of this.entries.values()) {
|
|
972
|
+
if (entry.refCount > 0) active++;
|
|
973
|
+
else idle++;
|
|
974
|
+
}
|
|
975
|
+
return { active, idle };
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
|
|
701
979
|
// src/tools.ts
|
|
702
980
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
703
981
|
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
@@ -712,7 +990,8 @@ var connectionParams = {
|
|
|
712
990
|
privateKeyPath: KeyPathSchema,
|
|
713
991
|
password: PasswordSchema
|
|
714
992
|
};
|
|
715
|
-
function registerTools(server) {
|
|
993
|
+
function registerTools(server, pool) {
|
|
994
|
+
const connectionPool = pool ?? new ConnectionPool();
|
|
716
995
|
server.tool(
|
|
717
996
|
"ssh_exec",
|
|
718
997
|
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
@@ -722,8 +1001,7 @@ function registerTools(server) {
|
|
|
722
1001
|
timeout: TimeoutSchema
|
|
723
1002
|
},
|
|
724
1003
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
725
|
-
|
|
726
|
-
try {
|
|
1004
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
727
1005
|
const result = await exec(client, command, timeout || 3e4);
|
|
728
1006
|
const parts = [];
|
|
729
1007
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -731,9 +1009,7 @@ function registerTools(server) {
|
|
|
731
1009
|
${result.stderr}`);
|
|
732
1010
|
parts.push(`[exit code: ${result.code}]`);
|
|
733
1011
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
734
|
-
}
|
|
735
|
-
client.end();
|
|
736
|
-
}
|
|
1012
|
+
});
|
|
737
1013
|
}
|
|
738
1014
|
);
|
|
739
1015
|
server.tool(
|
|
@@ -744,13 +1020,10 @@ ${result.stderr}`);
|
|
|
744
1020
|
path: z.string().describe("Absolute path to the remote file")
|
|
745
1021
|
},
|
|
746
1022
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
747
|
-
|
|
748
|
-
try {
|
|
1023
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
749
1024
|
const content = await readFile(client, path);
|
|
750
1025
|
return { content: [{ type: "text", text: content }] };
|
|
751
|
-
}
|
|
752
|
-
client.end();
|
|
753
|
-
}
|
|
1026
|
+
});
|
|
754
1027
|
}
|
|
755
1028
|
);
|
|
756
1029
|
server.tool(
|
|
@@ -762,13 +1035,10 @@ ${result.stderr}`);
|
|
|
762
1035
|
content: z.string().describe("File content to write")
|
|
763
1036
|
},
|
|
764
1037
|
async ({ host, port, username, privateKeyPath, password, path, content }) => {
|
|
765
|
-
|
|
766
|
-
try {
|
|
1038
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
767
1039
|
await writeFile(client, path, content);
|
|
768
1040
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
769
|
-
}
|
|
770
|
-
client.end();
|
|
771
|
-
}
|
|
1041
|
+
});
|
|
772
1042
|
}
|
|
773
1043
|
);
|
|
774
1044
|
server.tool(
|
|
@@ -780,13 +1050,10 @@ ${result.stderr}`);
|
|
|
780
1050
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
781
1051
|
},
|
|
782
1052
|
async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
|
|
783
|
-
|
|
784
|
-
try {
|
|
1053
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
785
1054
|
await uploadFile(client, localPath, remotePath);
|
|
786
1055
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
787
|
-
}
|
|
788
|
-
client.end();
|
|
789
|
-
}
|
|
1056
|
+
});
|
|
790
1057
|
}
|
|
791
1058
|
);
|
|
792
1059
|
server.tool(
|
|
@@ -798,13 +1065,10 @@ ${result.stderr}`);
|
|
|
798
1065
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
799
1066
|
},
|
|
800
1067
|
async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
|
|
801
|
-
|
|
802
|
-
try {
|
|
1068
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
803
1069
|
await downloadFile(client, remotePath, localPath);
|
|
804
1070
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
805
|
-
}
|
|
806
|
-
client.end();
|
|
807
|
-
}
|
|
1071
|
+
});
|
|
808
1072
|
}
|
|
809
1073
|
);
|
|
810
1074
|
server.tool(
|
|
@@ -815,13 +1079,10 @@ ${result.stderr}`);
|
|
|
815
1079
|
path: z.string().describe("Absolute path to the remote directory")
|
|
816
1080
|
},
|
|
817
1081
|
async ({ host, port, username, privateKeyPath, password, path }) => {
|
|
818
|
-
|
|
819
|
-
try {
|
|
1082
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
820
1083
|
const files = await listDir(client, path);
|
|
821
1084
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
822
|
-
}
|
|
823
|
-
client.end();
|
|
824
|
-
}
|
|
1085
|
+
});
|
|
825
1086
|
}
|
|
826
1087
|
);
|
|
827
1088
|
server.tool(
|
|
@@ -980,18 +1241,136 @@ ${result.stderr}`);
|
|
|
980
1241
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
|
|
981
1242
|
}
|
|
982
1243
|
);
|
|
1244
|
+
server.tool(
|
|
1245
|
+
"ssh_multi_exec",
|
|
1246
|
+
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
|
|
1247
|
+
{
|
|
1248
|
+
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1249
|
+
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1250
|
+
username: UsernameSchema,
|
|
1251
|
+
privateKeyPath: KeyPathSchema,
|
|
1252
|
+
password: PasswordSchema,
|
|
1253
|
+
timeout: TimeoutSchema
|
|
1254
|
+
},
|
|
1255
|
+
async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
|
|
1256
|
+
const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
|
|
1257
|
+
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1258
|
+
const lines = [];
|
|
1259
|
+
for (const r of results) {
|
|
1260
|
+
lines.push(`--- ${r.host} ---`);
|
|
1261
|
+
if (r.error) {
|
|
1262
|
+
lines.push(`[ERROR] ${r.error}`);
|
|
1263
|
+
} else {
|
|
1264
|
+
if (r.stdout) lines.push(r.stdout);
|
|
1265
|
+
if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
|
|
1266
|
+
lines.push(`[exit code: ${r.code}]`);
|
|
1267
|
+
}
|
|
1268
|
+
lines.push("");
|
|
1269
|
+
}
|
|
1270
|
+
const hasErrors = results.some((r) => r.error || r.code !== 0);
|
|
1271
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
|
|
1272
|
+
}
|
|
1273
|
+
);
|
|
1274
|
+
server.tool(
|
|
1275
|
+
"ssh_find",
|
|
1276
|
+
"Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
|
|
1277
|
+
{
|
|
1278
|
+
...connectionParams,
|
|
1279
|
+
path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
|
|
1280
|
+
name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
|
|
1281
|
+
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
|
|
1282
|
+
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1283
|
+
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1284
|
+
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1285
|
+
timeout: TimeoutSchema
|
|
1286
|
+
},
|
|
1287
|
+
async ({
|
|
1288
|
+
host,
|
|
1289
|
+
port,
|
|
1290
|
+
username,
|
|
1291
|
+
privateKeyPath,
|
|
1292
|
+
password,
|
|
1293
|
+
path,
|
|
1294
|
+
name,
|
|
1295
|
+
type,
|
|
1296
|
+
maxdepth,
|
|
1297
|
+
minsize,
|
|
1298
|
+
maxsize,
|
|
1299
|
+
timeout
|
|
1300
|
+
}) => {
|
|
1301
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1302
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1303
|
+
if (files.length === 0) {
|
|
1304
|
+
return { content: [{ type: "text", text: "No files found." }] };
|
|
1305
|
+
}
|
|
1306
|
+
return { content: [{ type: "text", text: `Found ${files.length} result(s):
|
|
1307
|
+
${files.join("\n")}` }] };
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
);
|
|
1311
|
+
server.tool(
|
|
1312
|
+
"ssh_tail",
|
|
1313
|
+
"Read the last N lines of a file on a remote host, optionally filtering by a grep pattern. Use this for reading log files instead of ssh_exec with manual tail/grep commands.",
|
|
1314
|
+
{
|
|
1315
|
+
...connectionParams,
|
|
1316
|
+
path: z.string().describe("Absolute path to the file to tail"),
|
|
1317
|
+
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1318
|
+
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1319
|
+
timeout: TimeoutSchema
|
|
1320
|
+
},
|
|
1321
|
+
async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
|
|
1322
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1323
|
+
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1324
|
+
if (!output.trim()) {
|
|
1325
|
+
return {
|
|
1326
|
+
content: [
|
|
1327
|
+
{
|
|
1328
|
+
type: "text",
|
|
1329
|
+
text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
|
|
1330
|
+
}
|
|
1331
|
+
]
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return { content: [{ type: "text", text: output }] };
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
);
|
|
1338
|
+
server.tool(
|
|
1339
|
+
"ssh_service_status",
|
|
1340
|
+
"Check the status of a systemd service on a remote host. Returns whether it's active, its PID, uptime, and description. Use this instead of ssh_exec with systemctl.",
|
|
1341
|
+
{
|
|
1342
|
+
...connectionParams,
|
|
1343
|
+
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1344
|
+
timeout: TimeoutSchema
|
|
1345
|
+
},
|
|
1346
|
+
async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
|
|
1347
|
+
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1348
|
+
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1349
|
+
const lines = [];
|
|
1350
|
+
lines.push(`Service: ${status.name}`);
|
|
1351
|
+
lines.push(`Status: ${status.status}`);
|
|
1352
|
+
if (status.description) lines.push(`Description: ${status.description}`);
|
|
1353
|
+
if (status.pid) lines.push(`PID: ${status.pid}`);
|
|
1354
|
+
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1355
|
+
lines.push("");
|
|
1356
|
+
lines.push(status.raw);
|
|
1357
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
);
|
|
983
1361
|
}
|
|
984
1362
|
|
|
985
1363
|
// src/server.ts
|
|
986
|
-
function createServer() {
|
|
1364
|
+
function createServer(pool) {
|
|
987
1365
|
const server = new McpServer({
|
|
988
1366
|
name: "ssh-mcp",
|
|
989
|
-
version: "0.
|
|
1367
|
+
version: "0.5.0"
|
|
990
1368
|
});
|
|
991
|
-
registerTools(server);
|
|
1369
|
+
registerTools(server, pool);
|
|
992
1370
|
return server;
|
|
993
1371
|
}
|
|
994
1372
|
export {
|
|
1373
|
+
ConnectionPool,
|
|
995
1374
|
checkConnectivity,
|
|
996
1375
|
checkGitSsh,
|
|
997
1376
|
checkKnownHosts,
|
|
@@ -1000,17 +1379,25 @@ export {
|
|
|
1000
1379
|
checkSshKeys,
|
|
1001
1380
|
configLookup,
|
|
1002
1381
|
connect,
|
|
1382
|
+
connectRaw,
|
|
1383
|
+
connectWithProxy,
|
|
1003
1384
|
createServer,
|
|
1004
1385
|
diagnose,
|
|
1005
1386
|
downloadFile,
|
|
1006
1387
|
ensureAgent,
|
|
1007
1388
|
exec,
|
|
1389
|
+
find,
|
|
1008
1390
|
fixKnownHosts,
|
|
1391
|
+
formatDiagnostics,
|
|
1009
1392
|
listDir,
|
|
1010
1393
|
listSshKeys,
|
|
1011
1394
|
loadKey,
|
|
1395
|
+
multiExec,
|
|
1012
1396
|
readFile,
|
|
1013
1397
|
registerTools,
|
|
1398
|
+
resolveConfig,
|
|
1399
|
+
serviceStatus,
|
|
1400
|
+
tail,
|
|
1014
1401
|
testConnection,
|
|
1015
1402
|
uploadFile,
|
|
1016
1403
|
writeFile
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"lint:fix": "biome check --write src/",
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"test": "vitest run",
|
|
27
|
+
"test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
|
|
27
28
|
"test:ci": "npm run build && npm test",
|
|
28
29
|
"prepublishOnly": "npm run build"
|
|
29
30
|
},
|