@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/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 (config.password) {
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 defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
533
- for (const keyName of defaultKeys) {
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 connect(config) {
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
- const diag = formatDiagnostics(config.host);
580
- if (diag) {
581
- const enhanced = new Error(`${err.message}
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
- enhanced.cause = err;
586
- reject(enhanced);
587
- } else {
588
- reject(err);
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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
- const client = await connect({ host, port, username, privateKeyPath, password });
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
- } finally {
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.3.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.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
  },