@yawlabs/ssh-mcp 0.4.0 → 0.6.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 CHANGED
@@ -73,6 +73,17 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
73
73
  | `ssh_download` | Download a file from a remote host to local filesystem. |
74
74
  | `ssh_ls` | List files in a directory on a remote host. |
75
75
 
76
+ ### Higher-level operations
77
+
78
+ Tools that wrap common patterns agents build with ssh_exec — faster and less error-prone.
79
+
80
+ | Tool | Description |
81
+ |------|-------------|
82
+ | `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
83
+ | `ssh_find` | Search for files remotely with structured parameters (name, type, size, depth). |
84
+ | `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
85
+ | `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
86
+
76
87
  ### Auto-diagnostics
77
88
 
78
89
  When any remote operation fails, ssh-mcp automatically runs diagnostics and includes the results in the error response. Your agent doesn't need to call `ssh_diagnose` separately — it gets told what's wrong and how to fix it right in the error message.
@@ -83,7 +94,13 @@ Remote operations reuse SSH connections automatically. When your agent makes mul
83
94
 
84
95
  ### SSH config support
85
96
 
86
- All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, and identity files from your SSH config are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves the real hostname, user, port, and identity file.
97
+ All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
98
+
99
+ **ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
100
+
101
+ ### Windows support
102
+
103
+ On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
87
104
 
88
105
  ## Authentication
89
106
 
package/dist/index.js CHANGED
@@ -15,21 +15,43 @@ import { existsSync, readFileSync, readdirSync } from "fs";
15
15
  import { homedir } from "os";
16
16
  import { join } from "path";
17
17
  function isValidHostname(host) {
18
- return /^[a-zA-Z0-9._\-:[\]]+$/.test(host) && host.length <= 253;
18
+ if (host.length === 0 || host.length > 253) return false;
19
+ if (host.startsWith("[")) {
20
+ return /^\[[0-9a-fA-F:]+\]$/.test(host);
21
+ }
22
+ return /^[a-zA-Z0-9._\-]+$/.test(host);
19
23
  }
20
24
  function runArgs(cmd, args) {
21
25
  try {
22
26
  const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
23
27
  return { stdout: stdout.trim(), ok: true };
24
28
  } catch (e) {
25
- const stdout = e.stdout?.toString().trim() || "";
26
- const stderr = e.stderr?.toString().trim() || "";
27
- const output = [stdout, stderr].filter(Boolean).join("\n") || e.message || "";
29
+ const err = e;
30
+ const stdout = err.stdout?.toString().trim() || "";
31
+ const stderr = err.stderr?.toString().trim() || "";
32
+ const output = [stdout, stderr].filter(Boolean).join("\n") || err.message || "";
28
33
  return { stdout: output, ok: false };
29
34
  }
30
35
  }
31
36
  function checkSshAgent() {
32
37
  const sock = process.env.SSH_AUTH_SOCK;
38
+ if (!sock && process.platform === "win32") {
39
+ const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
40
+ if (ok2) {
41
+ return { status: "ok", message: `Windows OpenSSH agent running with keys:
42
+ ${stdout2}` };
43
+ }
44
+ if (stdout2.includes("no identities") || stdout2.includes("The agent has no identities")) {
45
+ return {
46
+ status: "warning",
47
+ message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
48
+ };
49
+ }
50
+ return {
51
+ status: "error",
52
+ message: "Windows OpenSSH Authentication Agent is not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
53
+ };
54
+ }
33
55
  if (!sock) {
34
56
  return {
35
57
  status: "error",
@@ -92,6 +114,9 @@ function checkSshKeys() {
92
114
  return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
93
115
  }
94
116
  function checkKnownHosts(host) {
117
+ if (!isValidHostname(host)) {
118
+ return { status: "error", message: `Invalid hostname: "${host}"` };
119
+ }
95
120
  const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
96
121
  if (!existsSync(knownHostsPath)) {
97
122
  return {
@@ -99,9 +124,6 @@ function checkKnownHosts(host) {
99
124
  message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
100
125
  };
101
126
  }
102
- if (!isValidHostname(host)) {
103
- return { status: "error", message: `Invalid hostname: "${host}"` };
104
- }
105
127
  const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
106
128
  if (!ok || !stdout.trim()) {
107
129
  return {
@@ -261,7 +283,8 @@ function resolveFromSshConfig(host) {
261
283
  hostname: config.hostname || host,
262
284
  user: config.user || "",
263
285
  port: config.port || "22",
264
- identityFiles
286
+ identityFiles,
287
+ proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
265
288
  };
266
289
  } catch {
267
290
  return null;
@@ -276,7 +299,7 @@ function resolveConfig(config) {
276
299
  keepaliveInterval: 15e3,
277
300
  keepaliveCountMax: 3
278
301
  };
279
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
302
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
280
303
  if (agentSock) {
281
304
  connectConfig.agent = agentSock;
282
305
  }
@@ -296,7 +319,7 @@ function resolveConfig(config) {
296
319
  }
297
320
  }
298
321
  }
299
- return connectConfig;
322
+ return { connectConfig, proxyJump: sshConfig?.proxyJump };
300
323
  }
301
324
  function formatDiagnostics(host) {
302
325
  try {
@@ -334,6 +357,33 @@ function connectRaw(connectConfig) {
334
357
  client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
335
358
  });
336
359
  }
360
+ async function connectWithProxy(resolved) {
361
+ if (!resolved.proxyJump) {
362
+ return connectRaw(resolved.connectConfig);
363
+ }
364
+ const jumpResolved = resolveConfig({ host: resolved.proxyJump });
365
+ const jumpClient = await connectWithProxy(jumpResolved);
366
+ const targetHost = resolved.connectConfig.host;
367
+ const targetPort = resolved.connectConfig.port;
368
+ const stream = await new Promise((resolve, reject) => {
369
+ jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
370
+ if (err) {
371
+ jumpClient.end();
372
+ return reject(err);
373
+ }
374
+ resolve(stream2);
375
+ });
376
+ });
377
+ return new Promise((resolve, reject) => {
378
+ const client = new Client();
379
+ client.on("ready", () => resolve(client)).on("error", (err) => {
380
+ jumpClient.end();
381
+ reject(err);
382
+ }).on("close", () => {
383
+ jumpClient.end();
384
+ }).connect({ ...resolved.connectConfig, sock: stream });
385
+ });
386
+ }
337
387
  function exec(client, command, timeoutMs = 3e4) {
338
388
  return new Promise((resolve, reject) => {
339
389
  let settled = false;
@@ -376,9 +426,21 @@ function getSftp(client) {
376
426
  });
377
427
  });
378
428
  }
379
- async function readFile(client, remotePath) {
429
+ var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
430
+ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
380
431
  const sftp = await getSftp(client);
381
432
  try {
433
+ const stats = await new Promise((resolve, reject) => {
434
+ sftp.stat(remotePath, (err, stats2) => {
435
+ if (err) return reject(err);
436
+ resolve(stats2);
437
+ });
438
+ });
439
+ if (stats.size > maxBytes) {
440
+ throw new Error(
441
+ `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
442
+ );
443
+ }
382
444
  return await new Promise((resolve, reject) => {
383
445
  sftp.readFile(remotePath, (err, data) => {
384
446
  if (err) return reject(err);
@@ -446,12 +508,15 @@ async function listDir(client, remotePath) {
446
508
  var ConnectionPool = class {
447
509
  entries = /* @__PURE__ */ new Map();
448
510
  idleTtlMs;
511
+ maxPoolSize;
449
512
  constructor(options) {
450
513
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
514
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
451
515
  }
452
516
  async acquire(config) {
453
- const connectConfig = resolveConfig(config);
454
- const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
517
+ const resolved = resolveConfig(config);
518
+ const cc = resolved.connectConfig;
519
+ const key = `${cc.username}@${cc.host}:${cc.port}`;
455
520
  const existing = this.entries.get(key);
456
521
  if (existing && !existing.dead) {
457
522
  existing.refCount++;
@@ -464,8 +529,26 @@ var ConnectionPool = class {
464
529
  if (existing?.dead) {
465
530
  this.entries.delete(key);
466
531
  }
532
+ if (this.entries.size >= this.maxPoolSize) {
533
+ let evicted = false;
534
+ for (const [k, e] of this.entries) {
535
+ if (e.refCount === 0) {
536
+ if (e.idleTimer) clearTimeout(e.idleTimer);
537
+ try {
538
+ e.client.end();
539
+ } catch {
540
+ }
541
+ this.entries.delete(k);
542
+ evicted = true;
543
+ break;
544
+ }
545
+ }
546
+ if (!evicted) {
547
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
548
+ }
549
+ }
467
550
  try {
468
- const client = await connectRaw(connectConfig);
551
+ const client = await connectWithProxy(resolved);
469
552
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
470
553
  const markDead = () => {
471
554
  entry.dead = true;
@@ -579,6 +662,21 @@ function ensureAgent() {
579
662
  };
580
663
  }
581
664
  }
665
+ if (!sock && process.platform === "win32") {
666
+ const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
667
+ const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
668
+ if (ok2 || noIdentities) {
669
+ const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
670
+ return {
671
+ running: true,
672
+ reachable: true,
673
+ socket: "\\\\.\\pipe\\openssh-ssh-agent",
674
+ keys,
675
+ started: false,
676
+ message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
677
+ };
678
+ }
679
+ }
582
680
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
583
681
  if (ok) {
584
682
  const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
@@ -602,7 +700,7 @@ function ensureAgent() {
602
700
  reachable: false,
603
701
  keys: [],
604
702
  started: false,
605
- message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
703
+ 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)"'
606
704
  };
607
705
  }
608
706
  function detectKeyType(filePath, fileName) {
@@ -835,13 +933,91 @@ function testConnection(host, port = 22) {
835
933
  return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
836
934
  }
837
935
 
936
+ // src/ops.ts
937
+ function shellQuote(s) {
938
+ return `'${s.replace(/'/g, "'\\''")}'`;
939
+ }
940
+ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
941
+ const results = await Promise.allSettled(
942
+ hosts.map(async (hostConfig) => {
943
+ return pool.withConnection(hostConfig, async (client) => {
944
+ const result = await exec(client, command, timeoutMs);
945
+ return { host: hostConfig.host, ...result };
946
+ });
947
+ })
948
+ );
949
+ return results.map((result, i) => {
950
+ if (result.status === "fulfilled") {
951
+ return result.value;
952
+ }
953
+ return {
954
+ host: hosts[i].host,
955
+ stdout: "",
956
+ stderr: "",
957
+ code: -1,
958
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
959
+ };
960
+ });
961
+ }
962
+ var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
963
+ async function find(client, options, timeoutMs = 3e4) {
964
+ if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
965
+ throw new Error(
966
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
967
+ );
968
+ }
969
+ if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
970
+ throw new Error(
971
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
972
+ );
973
+ }
974
+ const args = [shellQuote(options.path)];
975
+ if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
976
+ if (options.type) args.push("-type", options.type);
977
+ if (options.name) args.push("-name", shellQuote(options.name));
978
+ if (options.minsize) args.push("-size", `+${options.minsize}`);
979
+ if (options.maxsize) args.push("-size", `-${options.maxsize}`);
980
+ if (options.newer) args.push("-newer", shellQuote(options.newer));
981
+ const command = `find ${args.join(" ")} 2>/dev/null`;
982
+ const result = await exec(client, command, timeoutMs);
983
+ return result.stdout.split("\n").filter(Boolean);
984
+ }
985
+ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
986
+ let command = `tail -n ${lines} ${shellQuote(path)}`;
987
+ if (grep) {
988
+ command += ` | grep -i ${shellQuote(grep)}`;
989
+ }
990
+ const result = await exec(client, command, timeoutMs);
991
+ if (result.code !== 0 && result.stderr && !grep) {
992
+ throw new Error(result.stderr.trim());
993
+ }
994
+ return result.stdout;
995
+ }
996
+ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
997
+ const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
998
+ const raw = result.stdout;
999
+ const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
1000
+ const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
1001
+ const pidMatch = raw.match(/Main PID:\s+(\d+)/);
1002
+ const sinceMatch = raw.match(/since\s+(.+?);/);
1003
+ return {
1004
+ name: serviceName,
1005
+ active: activeMatch?.[1] === "active",
1006
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
1007
+ description: descMatch?.[1]?.trim(),
1008
+ since: sinceMatch?.[1]?.trim(),
1009
+ pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
1010
+ raw
1011
+ };
1012
+ }
1013
+
838
1014
  // src/tools.ts
839
1015
  var HostSchema = z.string().describe("SSH hostname or IP address");
840
- var PortSchema = z.number().optional().describe("SSH port (default: 22)");
1016
+ var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
841
1017
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
842
1018
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
843
1019
  var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
844
- var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
1020
+ var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
845
1021
  var connectionParams = {
846
1022
  host: HostSchema,
847
1023
  port: PortSchema,
@@ -969,7 +1145,7 @@ ${result.stderr}`);
969
1145
  lines.push(` - ${s}`);
970
1146
  }
971
1147
  }
972
- return { content: [{ type: "text", text: lines.join("\n") }] };
1148
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
973
1149
  }
974
1150
  );
975
1151
  server.tool(
@@ -1100,13 +1276,131 @@ ${result.stderr}`);
1100
1276
  return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
1101
1277
  }
1102
1278
  );
1279
+ server.tool(
1280
+ "ssh_multi_exec",
1281
+ "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.",
1282
+ {
1283
+ hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1284
+ command: z.string().describe("Shell command to execute on all hosts"),
1285
+ port: PortSchema,
1286
+ username: UsernameSchema,
1287
+ privateKeyPath: KeyPathSchema,
1288
+ password: PasswordSchema,
1289
+ timeout: TimeoutSchema
1290
+ },
1291
+ async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1292
+ const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1293
+ const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1294
+ const lines = [];
1295
+ for (const r of results) {
1296
+ lines.push(`--- ${r.host} ---`);
1297
+ if (r.error) {
1298
+ lines.push(`[ERROR] ${r.error}`);
1299
+ } else {
1300
+ if (r.stdout) lines.push(r.stdout);
1301
+ if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
1302
+ lines.push(`[exit code: ${r.code}]`);
1303
+ }
1304
+ lines.push("");
1305
+ }
1306
+ const hasErrors = results.some((r) => r.error || r.code !== 0);
1307
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
1308
+ }
1309
+ );
1310
+ server.tool(
1311
+ "ssh_find",
1312
+ "Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
1313
+ {
1314
+ ...connectionParams,
1315
+ path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
1316
+ name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
1317
+ type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
1318
+ maxdepth: z.number().optional().describe("Maximum directory depth to search"),
1319
+ minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
1320
+ maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1321
+ timeout: TimeoutSchema
1322
+ },
1323
+ async ({
1324
+ host,
1325
+ port,
1326
+ username,
1327
+ privateKeyPath,
1328
+ password,
1329
+ path,
1330
+ name,
1331
+ type,
1332
+ maxdepth,
1333
+ minsize,
1334
+ maxsize,
1335
+ timeout
1336
+ }) => {
1337
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1338
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1339
+ if (files.length === 0) {
1340
+ return { content: [{ type: "text", text: "No files found." }] };
1341
+ }
1342
+ return { content: [{ type: "text", text: `Found ${files.length} result(s):
1343
+ ${files.join("\n")}` }] };
1344
+ });
1345
+ }
1346
+ );
1347
+ server.tool(
1348
+ "ssh_tail",
1349
+ "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.",
1350
+ {
1351
+ ...connectionParams,
1352
+ path: z.string().describe("Absolute path to the file to tail"),
1353
+ lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
1354
+ grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1355
+ timeout: TimeoutSchema
1356
+ },
1357
+ async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1358
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1359
+ const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1360
+ if (!output.trim()) {
1361
+ return {
1362
+ content: [
1363
+ {
1364
+ type: "text",
1365
+ text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
1366
+ }
1367
+ ]
1368
+ };
1369
+ }
1370
+ return { content: [{ type: "text", text: output }] };
1371
+ });
1372
+ }
1373
+ );
1374
+ server.tool(
1375
+ "ssh_service_status",
1376
+ "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.",
1377
+ {
1378
+ ...connectionParams,
1379
+ service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1380
+ timeout: TimeoutSchema
1381
+ },
1382
+ async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1383
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1384
+ const status = await serviceStatus(client, service, timeout || 3e4);
1385
+ const lines = [];
1386
+ lines.push(`Service: ${status.name}`);
1387
+ lines.push(`Status: ${status.status}`);
1388
+ if (status.description) lines.push(`Description: ${status.description}`);
1389
+ if (status.pid) lines.push(`PID: ${status.pid}`);
1390
+ if (status.since) lines.push(`Since: ${status.since}`);
1391
+ lines.push("");
1392
+ lines.push(status.raw);
1393
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
1394
+ });
1395
+ }
1396
+ );
1103
1397
  }
1104
1398
 
1105
1399
  // src/server.ts
1106
1400
  function createServer(pool) {
1107
1401
  const server = new McpServer({
1108
1402
  name: "ssh-mcp",
1109
- version: "0.4.0"
1403
+ version: "0.6.0"
1110
1404
  });
1111
1405
  registerTools(server, pool);
1112
1406
  return server;
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { Client, ConnectConfig } from 'ssh2';
2
+ import { ConnectConfig, Client } from 'ssh2';
3
3
 
4
4
  interface SSHConfig {
5
5
  host: string;
@@ -14,11 +14,17 @@ interface ExecResult {
14
14
  stderr: string;
15
15
  code: number;
16
16
  }
17
- declare function resolveConfig(config: SSHConfig): ConnectConfig;
17
+ interface ResolvedConfig {
18
+ connectConfig: ConnectConfig;
19
+ proxyJump?: string;
20
+ }
21
+ declare function resolveConfig(config: SSHConfig): ResolvedConfig;
22
+ declare function formatDiagnostics(host: string): string;
18
23
  declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
24
+ declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
19
25
  declare function connect(config: SSHConfig): Promise<Client>;
20
26
  declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
21
- declare function readFile(client: Client, remotePath: string): Promise<string>;
27
+ declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
22
28
  declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
23
29
  declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
24
30
  declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
@@ -27,10 +33,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
27
33
  interface PoolOptions {
28
34
  /** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
29
35
  idleTtlMs?: number;
36
+ /** Maximum number of connections in the pool. Default: 100 */
37
+ maxPoolSize?: number;
30
38
  }
31
39
  declare class ConnectionPool {
32
40
  private entries;
33
41
  private idleTtlMs;
42
+ private maxPoolSize;
34
43
  constructor(options?: PoolOptions);
35
44
  acquire(config: SSHConfig): Promise<Client>;
36
45
  release(client: Client): void;
@@ -63,6 +72,43 @@ declare function checkConnectivity(host: string, port?: number): DiagnosticResul
63
72
  declare function checkSshConfig(host: string): DiagnosticResult;
64
73
  declare function diagnose(host: string, port?: number): DiagnosticReport;
65
74
 
75
+ interface MultiExecResult {
76
+ host: string;
77
+ stdout: string;
78
+ stderr: string;
79
+ code: number;
80
+ error?: string;
81
+ }
82
+ interface MultiExecHost {
83
+ host: string;
84
+ port?: number;
85
+ username?: string;
86
+ privateKeyPath?: string;
87
+ password?: string;
88
+ }
89
+ declare function multiExec(pool: ConnectionPool, hosts: MultiExecHost[], command: string, timeoutMs?: number): Promise<MultiExecResult[]>;
90
+ interface FindOptions {
91
+ path: string;
92
+ name?: string;
93
+ type?: "f" | "d" | "l";
94
+ maxdepth?: number;
95
+ minsize?: string;
96
+ maxsize?: string;
97
+ newer?: string;
98
+ }
99
+ declare function find(client: Client, options: FindOptions, timeoutMs?: number): Promise<string[]>;
100
+ declare function tail(client: Client, path: string, lines?: number, grep?: string, timeoutMs?: number): Promise<string>;
101
+ interface ServiceStatus {
102
+ name: string;
103
+ active: boolean;
104
+ status: string;
105
+ description?: string;
106
+ since?: string;
107
+ pid?: number;
108
+ raw: string;
109
+ }
110
+ declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
111
+
66
112
  interface KeyInfo {
67
113
  name: string;
68
114
  path: string;
@@ -118,4 +164,4 @@ declare function testConnection(host: string, port?: number): {
118
164
 
119
165
  declare function createServer(pool?: ConnectionPool): McpServer;
120
166
 
121
- export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type KeyInfo, type PoolOptions, type SSHConfig, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, createServer, diagnose, downloadFile, ensureAgent, exec, fixKnownHosts, listDir, listSshKeys, loadKey, readFile, registerTools, resolveConfig, testConnection, uploadFile, writeFile };
167
+ export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
package/dist/server.js CHANGED
@@ -10,21 +10,43 @@ import { existsSync, readFileSync, readdirSync } from "fs";
10
10
  import { homedir } from "os";
11
11
  import { join } from "path";
12
12
  function isValidHostname(host) {
13
- return /^[a-zA-Z0-9._\-:[\]]+$/.test(host) && host.length <= 253;
13
+ if (host.length === 0 || host.length > 253) return false;
14
+ if (host.startsWith("[")) {
15
+ return /^\[[0-9a-fA-F:]+\]$/.test(host);
16
+ }
17
+ return /^[a-zA-Z0-9._\-]+$/.test(host);
14
18
  }
15
19
  function runArgs(cmd, args) {
16
20
  try {
17
21
  const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
18
22
  return { stdout: stdout.trim(), ok: true };
19
23
  } catch (e) {
20
- const stdout = e.stdout?.toString().trim() || "";
21
- const stderr = e.stderr?.toString().trim() || "";
22
- const output = [stdout, stderr].filter(Boolean).join("\n") || e.message || "";
24
+ const err = e;
25
+ const stdout = err.stdout?.toString().trim() || "";
26
+ const stderr = err.stderr?.toString().trim() || "";
27
+ const output = [stdout, stderr].filter(Boolean).join("\n") || err.message || "";
23
28
  return { stdout: output, ok: false };
24
29
  }
25
30
  }
26
31
  function checkSshAgent() {
27
32
  const sock = process.env.SSH_AUTH_SOCK;
33
+ if (!sock && process.platform === "win32") {
34
+ const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
35
+ if (ok2) {
36
+ return { status: "ok", message: `Windows OpenSSH agent running with keys:
37
+ ${stdout2}` };
38
+ }
39
+ if (stdout2.includes("no identities") || stdout2.includes("The agent has no identities")) {
40
+ return {
41
+ status: "warning",
42
+ message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
43
+ };
44
+ }
45
+ return {
46
+ status: "error",
47
+ message: "Windows OpenSSH Authentication Agent is not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
48
+ };
49
+ }
28
50
  if (!sock) {
29
51
  return {
30
52
  status: "error",
@@ -87,6 +109,9 @@ function checkSshKeys() {
87
109
  return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
88
110
  }
89
111
  function checkKnownHosts(host) {
112
+ if (!isValidHostname(host)) {
113
+ return { status: "error", message: `Invalid hostname: "${host}"` };
114
+ }
90
115
  const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
91
116
  if (!existsSync(knownHostsPath)) {
92
117
  return {
@@ -94,9 +119,6 @@ function checkKnownHosts(host) {
94
119
  message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
95
120
  };
96
121
  }
97
- if (!isValidHostname(host)) {
98
- return { status: "error", message: `Invalid hostname: "${host}"` };
99
- }
100
122
  const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
101
123
  if (!ok || !stdout.trim()) {
102
124
  return {
@@ -254,6 +276,21 @@ function ensureAgent() {
254
276
  };
255
277
  }
256
278
  }
279
+ if (!sock && process.platform === "win32") {
280
+ const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
281
+ const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
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
+ }
293
+ }
257
294
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
258
295
  if (ok) {
259
296
  const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
@@ -277,7 +314,7 @@ function ensureAgent() {
277
314
  reachable: false,
278
315
  keys: [],
279
316
  started: false,
280
- message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
317
+ 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
318
  };
282
319
  }
283
320
  function detectKeyType(filePath, fileName) {
@@ -537,7 +574,8 @@ function resolveFromSshConfig(host) {
537
574
  hostname: config.hostname || host,
538
575
  user: config.user || "",
539
576
  port: config.port || "22",
540
- identityFiles
577
+ identityFiles,
578
+ proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
541
579
  };
542
580
  } catch {
543
581
  return null;
@@ -552,7 +590,7 @@ function resolveConfig(config) {
552
590
  keepaliveInterval: 15e3,
553
591
  keepaliveCountMax: 3
554
592
  };
555
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
593
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
556
594
  if (agentSock) {
557
595
  connectConfig.agent = agentSock;
558
596
  }
@@ -572,7 +610,7 @@ function resolveConfig(config) {
572
610
  }
573
611
  }
574
612
  }
575
- return connectConfig;
613
+ return { connectConfig, proxyJump: sshConfig?.proxyJump };
576
614
  }
577
615
  function formatDiagnostics(host) {
578
616
  try {
@@ -610,10 +648,37 @@ function connectRaw(connectConfig) {
610
648
  client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
611
649
  });
612
650
  }
651
+ async function connectWithProxy(resolved) {
652
+ if (!resolved.proxyJump) {
653
+ return connectRaw(resolved.connectConfig);
654
+ }
655
+ const jumpResolved = resolveConfig({ host: resolved.proxyJump });
656
+ const jumpClient = await connectWithProxy(jumpResolved);
657
+ const targetHost = resolved.connectConfig.host;
658
+ const targetPort = resolved.connectConfig.port;
659
+ const stream = await new Promise((resolve, reject) => {
660
+ jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
661
+ if (err) {
662
+ jumpClient.end();
663
+ return reject(err);
664
+ }
665
+ resolve(stream2);
666
+ });
667
+ });
668
+ return new Promise((resolve, reject) => {
669
+ const client = new Client();
670
+ client.on("ready", () => resolve(client)).on("error", (err) => {
671
+ jumpClient.end();
672
+ reject(err);
673
+ }).on("close", () => {
674
+ jumpClient.end();
675
+ }).connect({ ...resolved.connectConfig, sock: stream });
676
+ });
677
+ }
613
678
  async function connect(config) {
614
- const connectConfig = resolveConfig(config);
679
+ const resolved = resolveConfig(config);
615
680
  try {
616
- return await connectRaw(connectConfig);
681
+ return await connectWithProxy(resolved);
617
682
  } catch (err) {
618
683
  const diag = formatDiagnostics(config.host);
619
684
  if (diag) {
@@ -670,9 +735,21 @@ function getSftp(client) {
670
735
  });
671
736
  });
672
737
  }
673
- async function readFile(client, remotePath) {
738
+ var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
739
+ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
674
740
  const sftp = await getSftp(client);
675
741
  try {
742
+ const stats = await new Promise((resolve, reject) => {
743
+ sftp.stat(remotePath, (err, stats2) => {
744
+ if (err) return reject(err);
745
+ resolve(stats2);
746
+ });
747
+ });
748
+ if (stats.size > maxBytes) {
749
+ throw new Error(
750
+ `File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
751
+ );
752
+ }
676
753
  return await new Promise((resolve, reject) => {
677
754
  sftp.readFile(remotePath, (err, data) => {
678
755
  if (err) return reject(err);
@@ -736,16 +813,97 @@ async function listDir(client, remotePath) {
736
813
  }
737
814
  }
738
815
 
816
+ // src/ops.ts
817
+ function shellQuote(s) {
818
+ return `'${s.replace(/'/g, "'\\''")}'`;
819
+ }
820
+ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
821
+ const results = await Promise.allSettled(
822
+ hosts.map(async (hostConfig) => {
823
+ return pool.withConnection(hostConfig, async (client) => {
824
+ const result = await exec(client, command, timeoutMs);
825
+ return { host: hostConfig.host, ...result };
826
+ });
827
+ })
828
+ );
829
+ return results.map((result, i) => {
830
+ if (result.status === "fulfilled") {
831
+ return result.value;
832
+ }
833
+ return {
834
+ host: hosts[i].host,
835
+ stdout: "",
836
+ stderr: "",
837
+ code: -1,
838
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
839
+ };
840
+ });
841
+ }
842
+ var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
843
+ async function find(client, options, timeoutMs = 3e4) {
844
+ if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
845
+ throw new Error(
846
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
847
+ );
848
+ }
849
+ if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
850
+ throw new Error(
851
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
852
+ );
853
+ }
854
+ const args = [shellQuote(options.path)];
855
+ if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
856
+ if (options.type) args.push("-type", options.type);
857
+ if (options.name) args.push("-name", shellQuote(options.name));
858
+ if (options.minsize) args.push("-size", `+${options.minsize}`);
859
+ if (options.maxsize) args.push("-size", `-${options.maxsize}`);
860
+ if (options.newer) args.push("-newer", shellQuote(options.newer));
861
+ const command = `find ${args.join(" ")} 2>/dev/null`;
862
+ const result = await exec(client, command, timeoutMs);
863
+ return result.stdout.split("\n").filter(Boolean);
864
+ }
865
+ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
866
+ let command = `tail -n ${lines} ${shellQuote(path)}`;
867
+ if (grep) {
868
+ command += ` | grep -i ${shellQuote(grep)}`;
869
+ }
870
+ const result = await exec(client, command, timeoutMs);
871
+ if (result.code !== 0 && result.stderr && !grep) {
872
+ throw new Error(result.stderr.trim());
873
+ }
874
+ return result.stdout;
875
+ }
876
+ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
877
+ const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
878
+ const raw = result.stdout;
879
+ const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
880
+ const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
881
+ const pidMatch = raw.match(/Main PID:\s+(\d+)/);
882
+ const sinceMatch = raw.match(/since\s+(.+?);/);
883
+ return {
884
+ name: serviceName,
885
+ active: activeMatch?.[1] === "active",
886
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
887
+ description: descMatch?.[1]?.trim(),
888
+ since: sinceMatch?.[1]?.trim(),
889
+ pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
890
+ raw
891
+ };
892
+ }
893
+
739
894
  // src/pool.ts
740
895
  var ConnectionPool = class {
741
896
  entries = /* @__PURE__ */ new Map();
742
897
  idleTtlMs;
898
+ maxPoolSize;
743
899
  constructor(options) {
744
900
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
901
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
745
902
  }
746
903
  async acquire(config) {
747
- const connectConfig = resolveConfig(config);
748
- const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
904
+ const resolved = resolveConfig(config);
905
+ const cc = resolved.connectConfig;
906
+ const key = `${cc.username}@${cc.host}:${cc.port}`;
749
907
  const existing = this.entries.get(key);
750
908
  if (existing && !existing.dead) {
751
909
  existing.refCount++;
@@ -758,8 +916,26 @@ var ConnectionPool = class {
758
916
  if (existing?.dead) {
759
917
  this.entries.delete(key);
760
918
  }
919
+ if (this.entries.size >= this.maxPoolSize) {
920
+ let evicted = false;
921
+ for (const [k, e] of this.entries) {
922
+ if (e.refCount === 0) {
923
+ if (e.idleTimer) clearTimeout(e.idleTimer);
924
+ try {
925
+ e.client.end();
926
+ } catch {
927
+ }
928
+ this.entries.delete(k);
929
+ evicted = true;
930
+ break;
931
+ }
932
+ }
933
+ if (!evicted) {
934
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
935
+ }
936
+ }
761
937
  try {
762
- const client = await connectRaw(connectConfig);
938
+ const client = await connectWithProxy(resolved);
763
939
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
764
940
  const markDead = () => {
765
941
  entry.dead = true;
@@ -848,11 +1024,11 @@ ${diag}`);
848
1024
 
849
1025
  // src/tools.ts
850
1026
  var HostSchema = z.string().describe("SSH hostname or IP address");
851
- var PortSchema = z.number().optional().describe("SSH port (default: 22)");
1027
+ var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
852
1028
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
853
1029
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
854
1030
  var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
855
- var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
1031
+ var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
856
1032
  var connectionParams = {
857
1033
  host: HostSchema,
858
1034
  port: PortSchema,
@@ -980,7 +1156,7 @@ ${result.stderr}`);
980
1156
  lines.push(` - ${s}`);
981
1157
  }
982
1158
  }
983
- return { content: [{ type: "text", text: lines.join("\n") }] };
1159
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
984
1160
  }
985
1161
  );
986
1162
  server.tool(
@@ -1111,13 +1287,131 @@ ${result.stderr}`);
1111
1287
  return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
1112
1288
  }
1113
1289
  );
1290
+ server.tool(
1291
+ "ssh_multi_exec",
1292
+ "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.",
1293
+ {
1294
+ hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1295
+ command: z.string().describe("Shell command to execute on all hosts"),
1296
+ port: PortSchema,
1297
+ username: UsernameSchema,
1298
+ privateKeyPath: KeyPathSchema,
1299
+ password: PasswordSchema,
1300
+ timeout: TimeoutSchema
1301
+ },
1302
+ async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1303
+ const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1304
+ const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1305
+ const lines = [];
1306
+ for (const r of results) {
1307
+ lines.push(`--- ${r.host} ---`);
1308
+ if (r.error) {
1309
+ lines.push(`[ERROR] ${r.error}`);
1310
+ } else {
1311
+ if (r.stdout) lines.push(r.stdout);
1312
+ if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
1313
+ lines.push(`[exit code: ${r.code}]`);
1314
+ }
1315
+ lines.push("");
1316
+ }
1317
+ const hasErrors = results.some((r) => r.error || r.code !== 0);
1318
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
1319
+ }
1320
+ );
1321
+ server.tool(
1322
+ "ssh_find",
1323
+ "Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
1324
+ {
1325
+ ...connectionParams,
1326
+ path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
1327
+ name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
1328
+ type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
1329
+ maxdepth: z.number().optional().describe("Maximum directory depth to search"),
1330
+ minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
1331
+ maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1332
+ timeout: TimeoutSchema
1333
+ },
1334
+ async ({
1335
+ host,
1336
+ port,
1337
+ username,
1338
+ privateKeyPath,
1339
+ password,
1340
+ path,
1341
+ name,
1342
+ type,
1343
+ maxdepth,
1344
+ minsize,
1345
+ maxsize,
1346
+ timeout
1347
+ }) => {
1348
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1349
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1350
+ if (files.length === 0) {
1351
+ return { content: [{ type: "text", text: "No files found." }] };
1352
+ }
1353
+ return { content: [{ type: "text", text: `Found ${files.length} result(s):
1354
+ ${files.join("\n")}` }] };
1355
+ });
1356
+ }
1357
+ );
1358
+ server.tool(
1359
+ "ssh_tail",
1360
+ "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.",
1361
+ {
1362
+ ...connectionParams,
1363
+ path: z.string().describe("Absolute path to the file to tail"),
1364
+ lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
1365
+ grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1366
+ timeout: TimeoutSchema
1367
+ },
1368
+ async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1369
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1370
+ const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1371
+ if (!output.trim()) {
1372
+ return {
1373
+ content: [
1374
+ {
1375
+ type: "text",
1376
+ text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
1377
+ }
1378
+ ]
1379
+ };
1380
+ }
1381
+ return { content: [{ type: "text", text: output }] };
1382
+ });
1383
+ }
1384
+ );
1385
+ server.tool(
1386
+ "ssh_service_status",
1387
+ "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.",
1388
+ {
1389
+ ...connectionParams,
1390
+ service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1391
+ timeout: TimeoutSchema
1392
+ },
1393
+ async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1394
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1395
+ const status = await serviceStatus(client, service, timeout || 3e4);
1396
+ const lines = [];
1397
+ lines.push(`Service: ${status.name}`);
1398
+ lines.push(`Status: ${status.status}`);
1399
+ if (status.description) lines.push(`Description: ${status.description}`);
1400
+ if (status.pid) lines.push(`PID: ${status.pid}`);
1401
+ if (status.since) lines.push(`Since: ${status.since}`);
1402
+ lines.push("");
1403
+ lines.push(status.raw);
1404
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
1405
+ });
1406
+ }
1407
+ );
1114
1408
  }
1115
1409
 
1116
1410
  // src/server.ts
1117
1411
  function createServer(pool) {
1118
1412
  const server = new McpServer({
1119
1413
  name: "ssh-mcp",
1120
- version: "0.4.0"
1414
+ version: "0.6.0"
1121
1415
  });
1122
1416
  registerTools(server, pool);
1123
1417
  return server;
@@ -1133,18 +1427,24 @@ export {
1133
1427
  configLookup,
1134
1428
  connect,
1135
1429
  connectRaw,
1430
+ connectWithProxy,
1136
1431
  createServer,
1137
1432
  diagnose,
1138
1433
  downloadFile,
1139
1434
  ensureAgent,
1140
1435
  exec,
1436
+ find,
1141
1437
  fixKnownHosts,
1438
+ formatDiagnostics,
1142
1439
  listDir,
1143
1440
  listSshKeys,
1144
1441
  loadKey,
1442
+ multiExec,
1145
1443
  readFile,
1146
1444
  registerTools,
1147
1445
  resolveConfig,
1446
+ serviceStatus,
1447
+ tail,
1148
1448
  testConnection,
1149
1449
  uploadFile,
1150
1450
  writeFile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.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
  },