@yawlabs/ssh-mcp 0.4.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 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
@@ -30,6 +30,25 @@ function runArgs(cmd, args) {
30
30
  }
31
31
  function checkSshAgent() {
32
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
+ if (!stdout2.includes("Error connecting") && !stdout2.includes("unable to")) {
46
+ return {
47
+ status: "warning",
48
+ message: "Windows OpenSSH Authentication Agent may not be running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
49
+ };
50
+ }
51
+ }
33
52
  if (!sock) {
34
53
  return {
35
54
  status: "error",
@@ -261,7 +280,8 @@ function resolveFromSshConfig(host) {
261
280
  hostname: config.hostname || host,
262
281
  user: config.user || "",
263
282
  port: config.port || "22",
264
- identityFiles
283
+ identityFiles,
284
+ proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
265
285
  };
266
286
  } catch {
267
287
  return null;
@@ -276,7 +296,7 @@ function resolveConfig(config) {
276
296
  keepaliveInterval: 15e3,
277
297
  keepaliveCountMax: 3
278
298
  };
279
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
299
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
280
300
  if (agentSock) {
281
301
  connectConfig.agent = agentSock;
282
302
  }
@@ -296,7 +316,7 @@ function resolveConfig(config) {
296
316
  }
297
317
  }
298
318
  }
299
- return connectConfig;
319
+ return { connectConfig, proxyJump: sshConfig?.proxyJump };
300
320
  }
301
321
  function formatDiagnostics(host) {
302
322
  try {
@@ -334,6 +354,33 @@ function connectRaw(connectConfig) {
334
354
  client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
335
355
  });
336
356
  }
357
+ async function connectWithProxy(resolved) {
358
+ if (!resolved.proxyJump) {
359
+ return connectRaw(resolved.connectConfig);
360
+ }
361
+ const jumpResolved = resolveConfig({ host: resolved.proxyJump });
362
+ const jumpClient = await connectWithProxy(jumpResolved);
363
+ const targetHost = resolved.connectConfig.host;
364
+ const targetPort = resolved.connectConfig.port;
365
+ const stream = await new Promise((resolve, reject) => {
366
+ jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
367
+ if (err) {
368
+ jumpClient.end();
369
+ return reject(err);
370
+ }
371
+ resolve(stream2);
372
+ });
373
+ });
374
+ return new Promise((resolve, reject) => {
375
+ const client = new Client();
376
+ client.on("ready", () => resolve(client)).on("error", (err) => {
377
+ jumpClient.end();
378
+ reject(err);
379
+ }).on("close", () => {
380
+ jumpClient.end();
381
+ }).connect({ ...resolved.connectConfig, sock: stream });
382
+ });
383
+ }
337
384
  function exec(client, command, timeoutMs = 3e4) {
338
385
  return new Promise((resolve, reject) => {
339
386
  let settled = false;
@@ -450,8 +497,9 @@ var ConnectionPool = class {
450
497
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
451
498
  }
452
499
  async acquire(config) {
453
- const connectConfig = resolveConfig(config);
454
- const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
500
+ const resolved = resolveConfig(config);
501
+ const cc = resolved.connectConfig;
502
+ const key = `${cc.username}@${cc.host}:${cc.port}`;
455
503
  const existing = this.entries.get(key);
456
504
  if (existing && !existing.dead) {
457
505
  existing.refCount++;
@@ -465,7 +513,7 @@ var ConnectionPool = class {
465
513
  this.entries.delete(key);
466
514
  }
467
515
  try {
468
- const client = await connectRaw(connectConfig);
516
+ const client = await connectWithProxy(resolved);
469
517
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
470
518
  const markDead = () => {
471
519
  entry.dead = true;
@@ -579,6 +627,21 @@ function ensureAgent() {
579
627
  };
580
628
  }
581
629
  }
630
+ if (!sock && process.platform === "win32") {
631
+ const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
632
+ const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
633
+ if (ok2 || noIdentities) {
634
+ const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
635
+ return {
636
+ running: true,
637
+ reachable: true,
638
+ socket: "\\\\.\\pipe\\openssh-ssh-agent",
639
+ keys,
640
+ started: false,
641
+ 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."
642
+ };
643
+ }
644
+ }
582
645
  const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
583
646
  if (ok) {
584
647
  const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
@@ -602,7 +665,7 @@ function ensureAgent() {
602
665
  reachable: false,
603
666
  keys: [],
604
667
  started: false,
605
- message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
668
+ 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
669
  };
607
670
  }
608
671
  function detectKeyType(filePath, fileName) {
@@ -835,6 +898,73 @@ function testConnection(host, port = 22) {
835
898
  return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
836
899
  }
837
900
 
901
+ // src/ops.ts
902
+ function shellQuote(s) {
903
+ return `'${s.replace(/'/g, "'\\''")}'`;
904
+ }
905
+ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
906
+ const results = await Promise.allSettled(
907
+ hosts.map(async (hostConfig) => {
908
+ return pool.withConnection(hostConfig, async (client) => {
909
+ const result = await exec(client, command, timeoutMs);
910
+ return { host: hostConfig.host, ...result };
911
+ });
912
+ })
913
+ );
914
+ return results.map((result, i) => {
915
+ if (result.status === "fulfilled") {
916
+ return result.value;
917
+ }
918
+ return {
919
+ host: hosts[i].host,
920
+ stdout: "",
921
+ stderr: "",
922
+ code: -1,
923
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
924
+ };
925
+ });
926
+ }
927
+ async function find(client, options, timeoutMs = 3e4) {
928
+ const args = [shellQuote(options.path)];
929
+ if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
930
+ if (options.type) args.push("-type", options.type);
931
+ if (options.name) args.push("-name", shellQuote(options.name));
932
+ if (options.minsize) args.push("-size", `+${options.minsize}`);
933
+ if (options.maxsize) args.push("-size", `-${options.maxsize}`);
934
+ if (options.newer) args.push("-newer", shellQuote(options.newer));
935
+ const command = `find ${args.join(" ")} 2>/dev/null`;
936
+ const result = await exec(client, command, timeoutMs);
937
+ return result.stdout.split("\n").filter(Boolean);
938
+ }
939
+ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
940
+ let command = `tail -n ${lines} ${shellQuote(path)}`;
941
+ if (grep) {
942
+ command += ` | grep -i ${shellQuote(grep)}`;
943
+ }
944
+ const result = await exec(client, command, timeoutMs);
945
+ if (result.code !== 0 && result.stderr && !grep) {
946
+ throw new Error(result.stderr.trim());
947
+ }
948
+ return result.stdout;
949
+ }
950
+ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
951
+ const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
952
+ const raw = result.stdout;
953
+ const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
954
+ const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
955
+ const pidMatch = raw.match(/Main PID:\s+(\d+)/);
956
+ const sinceMatch = raw.match(/since\s+(.+?);/);
957
+ return {
958
+ name: serviceName,
959
+ active: activeMatch?.[1] === "active",
960
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
961
+ description: descMatch?.[1]?.trim(),
962
+ since: sinceMatch?.[1]?.trim(),
963
+ pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
964
+ raw
965
+ };
966
+ }
967
+
838
968
  // src/tools.ts
839
969
  var HostSchema = z.string().describe("SSH hostname or IP address");
840
970
  var PortSchema = z.number().optional().describe("SSH port (default: 22)");
@@ -1100,13 +1230,130 @@ ${result.stderr}`);
1100
1230
  return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
1101
1231
  }
1102
1232
  );
1233
+ server.tool(
1234
+ "ssh_multi_exec",
1235
+ "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.",
1236
+ {
1237
+ hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1238
+ command: z.string().describe("Shell command to execute on all hosts"),
1239
+ username: UsernameSchema,
1240
+ privateKeyPath: KeyPathSchema,
1241
+ password: PasswordSchema,
1242
+ timeout: TimeoutSchema
1243
+ },
1244
+ async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
1245
+ const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
1246
+ const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1247
+ const lines = [];
1248
+ for (const r of results) {
1249
+ lines.push(`--- ${r.host} ---`);
1250
+ if (r.error) {
1251
+ lines.push(`[ERROR] ${r.error}`);
1252
+ } else {
1253
+ if (r.stdout) lines.push(r.stdout);
1254
+ if (r.stderr) lines.push(`[stderr] ${r.stderr}`);
1255
+ lines.push(`[exit code: ${r.code}]`);
1256
+ }
1257
+ lines.push("");
1258
+ }
1259
+ const hasErrors = results.some((r) => r.error || r.code !== 0);
1260
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: hasErrors };
1261
+ }
1262
+ );
1263
+ server.tool(
1264
+ "ssh_find",
1265
+ "Search for files on a remote host. Wraps the find command with structured parameters so you don't have to construct find syntax manually.",
1266
+ {
1267
+ ...connectionParams,
1268
+ path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
1269
+ name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
1270
+ type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
1271
+ maxdepth: z.number().optional().describe("Maximum directory depth to search"),
1272
+ minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
1273
+ maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1274
+ timeout: TimeoutSchema
1275
+ },
1276
+ async ({
1277
+ host,
1278
+ port,
1279
+ username,
1280
+ privateKeyPath,
1281
+ password,
1282
+ path,
1283
+ name,
1284
+ type,
1285
+ maxdepth,
1286
+ minsize,
1287
+ maxsize,
1288
+ timeout
1289
+ }) => {
1290
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1291
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1292
+ if (files.length === 0) {
1293
+ return { content: [{ type: "text", text: "No files found." }] };
1294
+ }
1295
+ return { content: [{ type: "text", text: `Found ${files.length} result(s):
1296
+ ${files.join("\n")}` }] };
1297
+ });
1298
+ }
1299
+ );
1300
+ server.tool(
1301
+ "ssh_tail",
1302
+ "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.",
1303
+ {
1304
+ ...connectionParams,
1305
+ path: z.string().describe("Absolute path to the file to tail"),
1306
+ lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
1307
+ grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1308
+ timeout: TimeoutSchema
1309
+ },
1310
+ async ({ host, port, username, privateKeyPath, password, path, lines, grep, timeout }) => {
1311
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1312
+ const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
1313
+ if (!output.trim()) {
1314
+ return {
1315
+ content: [
1316
+ {
1317
+ type: "text",
1318
+ text: grep ? `No lines matching "${grep}" in last ${lines || 100} lines.` : "File is empty or does not exist."
1319
+ }
1320
+ ]
1321
+ };
1322
+ }
1323
+ return { content: [{ type: "text", text: output }] };
1324
+ });
1325
+ }
1326
+ );
1327
+ server.tool(
1328
+ "ssh_service_status",
1329
+ "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.",
1330
+ {
1331
+ ...connectionParams,
1332
+ service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
1333
+ timeout: TimeoutSchema
1334
+ },
1335
+ async ({ host, port, username, privateKeyPath, password, service, timeout }) => {
1336
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
1337
+ const status = await serviceStatus(client, service, timeout || 3e4);
1338
+ const lines = [];
1339
+ lines.push(`Service: ${status.name}`);
1340
+ lines.push(`Status: ${status.status}`);
1341
+ if (status.description) lines.push(`Description: ${status.description}`);
1342
+ if (status.pid) lines.push(`PID: ${status.pid}`);
1343
+ if (status.since) lines.push(`Since: ${status.since}`);
1344
+ lines.push("");
1345
+ lines.push(status.raw);
1346
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
1347
+ });
1348
+ }
1349
+ );
1103
1350
  }
1104
1351
 
1105
1352
  // src/server.ts
1106
1353
  function createServer(pool) {
1107
1354
  const server = new McpServer({
1108
1355
  name: "ssh-mcp",
1109
- version: "0.4.0"
1356
+ version: "0.5.0"
1110
1357
  });
1111
1358
  registerTools(server, pool);
1112
1359
  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,8 +14,14 @@ 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
27
  declare function readFile(client: Client, remotePath: string): Promise<string>;
@@ -63,6 +69,43 @@ declare function checkConnectivity(host: string, port?: number): DiagnosticResul
63
69
  declare function checkSshConfig(host: string): DiagnosticResult;
64
70
  declare function diagnose(host: string, port?: number): DiagnosticReport;
65
71
 
72
+ interface MultiExecResult {
73
+ host: string;
74
+ stdout: string;
75
+ stderr: string;
76
+ code: number;
77
+ error?: string;
78
+ }
79
+ interface MultiExecHost {
80
+ host: string;
81
+ port?: number;
82
+ username?: string;
83
+ privateKeyPath?: string;
84
+ password?: string;
85
+ }
86
+ declare function multiExec(pool: ConnectionPool, hosts: MultiExecHost[], command: string, timeoutMs?: number): Promise<MultiExecResult[]>;
87
+ interface FindOptions {
88
+ path: string;
89
+ name?: string;
90
+ type?: "f" | "d" | "l";
91
+ maxdepth?: number;
92
+ minsize?: string;
93
+ maxsize?: string;
94
+ newer?: string;
95
+ }
96
+ declare function find(client: Client, options: FindOptions, timeoutMs?: number): Promise<string[]>;
97
+ declare function tail(client: Client, path: string, lines?: number, grep?: string, timeoutMs?: number): Promise<string>;
98
+ interface ServiceStatus {
99
+ name: string;
100
+ active: boolean;
101
+ status: string;
102
+ description?: string;
103
+ since?: string;
104
+ pid?: number;
105
+ raw: string;
106
+ }
107
+ declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
108
+
66
109
  interface KeyInfo {
67
110
  name: string;
68
111
  path: string;
@@ -118,4 +161,4 @@ declare function testConnection(host: string, port?: number): {
118
161
 
119
162
  declare function createServer(pool?: ConnectionPool): McpServer;
120
163
 
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 };
164
+ 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
@@ -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) {
@@ -537,7 +571,8 @@ function resolveFromSshConfig(host) {
537
571
  hostname: config.hostname || host,
538
572
  user: config.user || "",
539
573
  port: config.port || "22",
540
- identityFiles
574
+ identityFiles,
575
+ proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
541
576
  };
542
577
  } catch {
543
578
  return null;
@@ -552,7 +587,7 @@ function resolveConfig(config) {
552
587
  keepaliveInterval: 15e3,
553
588
  keepaliveCountMax: 3
554
589
  };
555
- const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
590
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
556
591
  if (agentSock) {
557
592
  connectConfig.agent = agentSock;
558
593
  }
@@ -572,7 +607,7 @@ function resolveConfig(config) {
572
607
  }
573
608
  }
574
609
  }
575
- return connectConfig;
610
+ return { connectConfig, proxyJump: sshConfig?.proxyJump };
576
611
  }
577
612
  function formatDiagnostics(host) {
578
613
  try {
@@ -610,10 +645,37 @@ function connectRaw(connectConfig) {
610
645
  client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
611
646
  });
612
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
+ });
665
+ return new Promise((resolve, reject) => {
666
+ const client = new Client();
667
+ client.on("ready", () => resolve(client)).on("error", (err) => {
668
+ jumpClient.end();
669
+ reject(err);
670
+ }).on("close", () => {
671
+ jumpClient.end();
672
+ }).connect({ ...resolved.connectConfig, sock: stream });
673
+ });
674
+ }
613
675
  async function connect(config) {
614
- const connectConfig = resolveConfig(config);
676
+ const resolved = resolveConfig(config);
615
677
  try {
616
- return await connectRaw(connectConfig);
678
+ return await connectWithProxy(resolved);
617
679
  } catch (err) {
618
680
  const diag = formatDiagnostics(config.host);
619
681
  if (diag) {
@@ -736,6 +798,73 @@ async function listDir(client, remotePath) {
736
798
  }
737
799
  }
738
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
+
739
868
  // src/pool.ts
740
869
  var ConnectionPool = class {
741
870
  entries = /* @__PURE__ */ new Map();
@@ -744,8 +873,9 @@ var ConnectionPool = class {
744
873
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
745
874
  }
746
875
  async acquire(config) {
747
- const connectConfig = resolveConfig(config);
748
- const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
876
+ const resolved = resolveConfig(config);
877
+ const cc = resolved.connectConfig;
878
+ const key = `${cc.username}@${cc.host}:${cc.port}`;
749
879
  const existing = this.entries.get(key);
750
880
  if (existing && !existing.dead) {
751
881
  existing.refCount++;
@@ -759,7 +889,7 @@ var ConnectionPool = class {
759
889
  this.entries.delete(key);
760
890
  }
761
891
  try {
762
- const client = await connectRaw(connectConfig);
892
+ const client = await connectWithProxy(resolved);
763
893
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
764
894
  const markDead = () => {
765
895
  entry.dead = true;
@@ -1111,13 +1241,130 @@ ${result.stderr}`);
1111
1241
  return { content: [{ type: "text", text: lines.join("\n") }], isError: result.status === "error" };
1112
1242
  }
1113
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
+ );
1114
1361
  }
1115
1362
 
1116
1363
  // src/server.ts
1117
1364
  function createServer(pool) {
1118
1365
  const server = new McpServer({
1119
1366
  name: "ssh-mcp",
1120
- version: "0.4.0"
1367
+ version: "0.5.0"
1121
1368
  });
1122
1369
  registerTools(server, pool);
1123
1370
  return server;
@@ -1133,18 +1380,24 @@ export {
1133
1380
  configLookup,
1134
1381
  connect,
1135
1382
  connectRaw,
1383
+ connectWithProxy,
1136
1384
  createServer,
1137
1385
  diagnose,
1138
1386
  downloadFile,
1139
1387
  ensureAgent,
1140
1388
  exec,
1389
+ find,
1141
1390
  fixKnownHosts,
1391
+ formatDiagnostics,
1142
1392
  listDir,
1143
1393
  listSshKeys,
1144
1394
  loadKey,
1395
+ multiExec,
1145
1396
  readFile,
1146
1397
  registerTools,
1147
1398
  resolveConfig,
1399
+ serviceStatus,
1400
+ tail,
1148
1401
  testConnection,
1149
1402
  uploadFile,
1150
1403
  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.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
  },