@yawlabs/ssh-mcp 0.5.0 → 0.7.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
@@ -98,6 +98,18 @@ All connections respect your `~/.ssh/config`. Host aliases, custom ports, userna
98
98
 
99
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
100
 
101
+ ### Host key verification
102
+
103
+ All remote operations verify the server's host key against `~/.ssh/known_hosts`:
104
+
105
+ - **Known host, key matches** — accept.
106
+ - **Known host, key changed** — reject (MITM protection).
107
+ - **Unknown host** — accept on first connection (TOFU). Use `ssh_known_hosts_fix` to pin the key for future mismatch detection.
108
+
109
+ For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
110
+
111
+ The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
112
+
101
113
  ### Windows support
102
114
 
103
115
  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.
package/dist/index.js CHANGED
@@ -15,16 +15,21 @@ 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
  }
@@ -42,12 +47,10 @@ ${stdout2}` };
42
47
  message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
43
48
  };
44
49
  }
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
- }
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
+ };
51
54
  }
52
55
  if (!sock) {
53
56
  return {
@@ -111,6 +114,9 @@ function checkSshKeys() {
111
114
  return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
112
115
  }
113
116
  function checkKnownHosts(host) {
117
+ if (!isValidHostname(host)) {
118
+ return { status: "error", message: `Invalid hostname: "${host}"` };
119
+ }
114
120
  const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
115
121
  if (!existsSync(knownHostsPath)) {
116
122
  return {
@@ -118,9 +124,6 @@ function checkKnownHosts(host) {
118
124
  message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
119
125
  };
120
126
  }
121
- if (!isValidHostname(host)) {
122
- return { status: "error", message: `Invalid hostname: "${host}"` };
123
- }
124
127
  const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
125
128
  if (!ok || !stdout.trim()) {
126
129
  return {
@@ -287,14 +290,50 @@ function resolveFromSshConfig(host) {
287
290
  return null;
288
291
  }
289
292
  }
293
+ function readKnownHostsKeys(host, port) {
294
+ if (!isValidHostname(host)) return [];
295
+ const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
296
+ const keys = [];
297
+ for (const target of targets) {
298
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
299
+ if (!ok || !stdout.trim()) continue;
300
+ for (const line of stdout.split("\n")) {
301
+ const trimmed = line.trim();
302
+ if (!trimmed || trimmed.startsWith("#")) continue;
303
+ const parts = trimmed.split(/\s+/);
304
+ if (parts.length < 3) continue;
305
+ try {
306
+ keys.push(Buffer.from(parts[2], "base64"));
307
+ } catch {
308
+ }
309
+ }
310
+ }
311
+ return keys;
312
+ }
313
+ function buildHostVerifier(hosts, port) {
314
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
315
+ return (key) => {
316
+ const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
317
+ if (known.length === 0) {
318
+ return !strict;
319
+ }
320
+ return known.some((k) => k.equals(key));
321
+ };
322
+ }
290
323
  function resolveConfig(config) {
291
324
  const sshConfig = resolveFromSshConfig(config.host);
325
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
326
+ const verifierHosts = [config.host];
327
+ if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
328
+ verifierHosts.push(sshConfig.hostname);
329
+ }
292
330
  const connectConfig = {
293
331
  host: sshConfig?.hostname || config.host,
294
- port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
332
+ port,
295
333
  username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
296
334
  keepaliveInterval: 15e3,
297
- keepaliveCountMax: 3
335
+ keepaliveCountMax: 3,
336
+ hostVerifier: buildHostVerifier(verifierHosts, port)
298
337
  };
299
338
  const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
300
339
  if (agentSock) {
@@ -423,9 +462,21 @@ function getSftp(client) {
423
462
  });
424
463
  });
425
464
  }
426
- async function readFile(client, remotePath) {
465
+ var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
466
+ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
427
467
  const sftp = await getSftp(client);
428
468
  try {
469
+ const stats = await new Promise((resolve, reject) => {
470
+ sftp.stat(remotePath, (err, stats2) => {
471
+ if (err) return reject(err);
472
+ resolve(stats2);
473
+ });
474
+ });
475
+ if (stats.size > maxBytes) {
476
+ throw new Error(
477
+ `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.`
478
+ );
479
+ }
429
480
  return await new Promise((resolve, reject) => {
430
481
  sftp.readFile(remotePath, (err, data) => {
431
482
  if (err) return reject(err);
@@ -493,8 +544,10 @@ async function listDir(client, remotePath) {
493
544
  var ConnectionPool = class {
494
545
  entries = /* @__PURE__ */ new Map();
495
546
  idleTtlMs;
547
+ maxPoolSize;
496
548
  constructor(options) {
497
549
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
550
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
498
551
  }
499
552
  async acquire(config) {
500
553
  const resolved = resolveConfig(config);
@@ -512,6 +565,24 @@ var ConnectionPool = class {
512
565
  if (existing?.dead) {
513
566
  this.entries.delete(key);
514
567
  }
568
+ if (this.entries.size >= this.maxPoolSize) {
569
+ let evicted = false;
570
+ for (const [k, e] of this.entries) {
571
+ if (e.refCount === 0) {
572
+ if (e.idleTimer) clearTimeout(e.idleTimer);
573
+ try {
574
+ e.client.end();
575
+ } catch {
576
+ }
577
+ this.entries.delete(k);
578
+ evicted = true;
579
+ break;
580
+ }
581
+ }
582
+ if (!evicted) {
583
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
584
+ }
585
+ }
515
586
  try {
516
587
  const client = await connectWithProxy(resolved);
517
588
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
@@ -924,7 +995,18 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
924
995
  };
925
996
  });
926
997
  }
998
+ var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
927
999
  async function find(client, options, timeoutMs = 3e4) {
1000
+ if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1001
+ throw new Error(
1002
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
1003
+ );
1004
+ }
1005
+ if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
1006
+ throw new Error(
1007
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
1008
+ );
1009
+ }
928
1010
  const args = [shellQuote(options.path)];
929
1011
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
930
1012
  if (options.type) args.push("-type", options.type);
@@ -967,11 +1049,11 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
967
1049
 
968
1050
  // src/tools.ts
969
1051
  var HostSchema = z.string().describe("SSH hostname or IP address");
970
- var PortSchema = z.number().optional().describe("SSH port (default: 22)");
1052
+ var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
971
1053
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
972
1054
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
973
1055
  var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
974
- var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
1056
+ var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
975
1057
  var connectionParams = {
976
1058
  host: HostSchema,
977
1059
  port: PortSchema,
@@ -983,10 +1065,10 @@ function registerTools(server, pool) {
983
1065
  const connectionPool = pool ?? new ConnectionPool();
984
1066
  server.tool(
985
1067
  "ssh_exec",
986
- "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
1068
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
987
1069
  {
988
1070
  ...connectionParams,
989
- command: z.string().describe("Shell command to execute on the remote host"),
1071
+ command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
990
1072
  timeout: TimeoutSchema
991
1073
  },
992
1074
  async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
@@ -1099,7 +1181,7 @@ ${result.stderr}`);
1099
1181
  lines.push(` - ${s}`);
1100
1182
  }
1101
1183
  }
1102
- return { content: [{ type: "text", text: lines.join("\n") }] };
1184
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
1103
1185
  }
1104
1186
  );
1105
1187
  server.tool(
@@ -1236,13 +1318,14 @@ ${result.stderr}`);
1236
1318
  {
1237
1319
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1238
1320
  command: z.string().describe("Shell command to execute on all hosts"),
1321
+ port: PortSchema,
1239
1322
  username: UsernameSchema,
1240
1323
  privateKeyPath: KeyPathSchema,
1241
1324
  password: PasswordSchema,
1242
1325
  timeout: TimeoutSchema
1243
1326
  },
1244
- async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
1245
- const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
1327
+ async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1328
+ const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1246
1329
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1247
1330
  const lines = [];
1248
1331
  for (const r of results) {
@@ -1353,7 +1436,7 @@ ${files.join("\n")}` }] };
1353
1436
  function createServer(pool) {
1354
1437
  const server = new McpServer({
1355
1438
  name: "ssh-mcp",
1356
- version: "0.5.0"
1439
+ version: "0.7.0"
1357
1440
  });
1358
1441
  registerTools(server, pool);
1359
1442
  return server;
package/dist/server.d.ts CHANGED
@@ -18,13 +18,14 @@ interface ResolvedConfig {
18
18
  connectConfig: ConnectConfig;
19
19
  proxyJump?: string;
20
20
  }
21
+ declare function readKnownHostsKeys(host: string, port?: number): Buffer[];
21
22
  declare function resolveConfig(config: SSHConfig): ResolvedConfig;
22
23
  declare function formatDiagnostics(host: string): string;
23
24
  declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
24
25
  declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
25
26
  declare function connect(config: SSHConfig): Promise<Client>;
26
27
  declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
27
- declare function readFile(client: Client, remotePath: string): Promise<string>;
28
+ declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
28
29
  declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
29
30
  declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
30
31
  declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
@@ -33,10 +34,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
33
34
  interface PoolOptions {
34
35
  /** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
35
36
  idleTtlMs?: number;
37
+ /** Maximum number of connections in the pool. Default: 100 */
38
+ maxPoolSize?: number;
36
39
  }
37
40
  declare class ConnectionPool {
38
41
  private entries;
39
42
  private idleTtlMs;
43
+ private maxPoolSize;
40
44
  constructor(options?: PoolOptions);
41
45
  acquire(config: SSHConfig): Promise<Client>;
42
46
  release(client: Client): void;
@@ -161,4 +165,4 @@ declare function testConnection(host: string, port?: number): {
161
165
 
162
166
  declare function createServer(pool?: ConnectionPool): McpServer;
163
167
 
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 };
168
+ 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, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
package/dist/server.js CHANGED
@@ -10,16 +10,21 @@ 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
  }
@@ -37,12 +42,10 @@ ${stdout2}` };
37
42
  message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
38
43
  };
39
44
  }
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
- }
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
+ };
46
49
  }
47
50
  if (!sock) {
48
51
  return {
@@ -106,6 +109,9 @@ function checkSshKeys() {
106
109
  return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
107
110
  }
108
111
  function checkKnownHosts(host) {
112
+ if (!isValidHostname(host)) {
113
+ return { status: "error", message: `Invalid hostname: "${host}"` };
114
+ }
109
115
  const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
110
116
  if (!existsSync(knownHostsPath)) {
111
117
  return {
@@ -113,9 +119,6 @@ function checkKnownHosts(host) {
113
119
  message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
114
120
  };
115
121
  }
116
- if (!isValidHostname(host)) {
117
- return { status: "error", message: `Invalid hostname: "${host}"` };
118
- }
119
122
  const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
120
123
  if (!ok || !stdout.trim()) {
121
124
  return {
@@ -578,14 +581,50 @@ function resolveFromSshConfig(host) {
578
581
  return null;
579
582
  }
580
583
  }
584
+ function readKnownHostsKeys(host, port) {
585
+ if (!isValidHostname(host)) return [];
586
+ const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
587
+ const keys = [];
588
+ for (const target of targets) {
589
+ const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
590
+ if (!ok || !stdout.trim()) continue;
591
+ for (const line of stdout.split("\n")) {
592
+ const trimmed = line.trim();
593
+ if (!trimmed || trimmed.startsWith("#")) continue;
594
+ const parts = trimmed.split(/\s+/);
595
+ if (parts.length < 3) continue;
596
+ try {
597
+ keys.push(Buffer.from(parts[2], "base64"));
598
+ } catch {
599
+ }
600
+ }
601
+ }
602
+ return keys;
603
+ }
604
+ function buildHostVerifier(hosts, port) {
605
+ const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
606
+ return (key) => {
607
+ const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
608
+ if (known.length === 0) {
609
+ return !strict;
610
+ }
611
+ return known.some((k) => k.equals(key));
612
+ };
613
+ }
581
614
  function resolveConfig(config) {
582
615
  const sshConfig = resolveFromSshConfig(config.host);
616
+ const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
617
+ const verifierHosts = [config.host];
618
+ if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
619
+ verifierHosts.push(sshConfig.hostname);
620
+ }
583
621
  const connectConfig = {
584
622
  host: sshConfig?.hostname || config.host,
585
- port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
623
+ port,
586
624
  username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
587
625
  keepaliveInterval: 15e3,
588
- keepaliveCountMax: 3
626
+ keepaliveCountMax: 3,
627
+ hostVerifier: buildHostVerifier(verifierHosts, port)
589
628
  };
590
629
  const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
591
630
  if (agentSock) {
@@ -732,9 +771,21 @@ function getSftp(client) {
732
771
  });
733
772
  });
734
773
  }
735
- async function readFile(client, remotePath) {
774
+ var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
775
+ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
736
776
  const sftp = await getSftp(client);
737
777
  try {
778
+ const stats = await new Promise((resolve, reject) => {
779
+ sftp.stat(remotePath, (err, stats2) => {
780
+ if (err) return reject(err);
781
+ resolve(stats2);
782
+ });
783
+ });
784
+ if (stats.size > maxBytes) {
785
+ throw new Error(
786
+ `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.`
787
+ );
788
+ }
738
789
  return await new Promise((resolve, reject) => {
739
790
  sftp.readFile(remotePath, (err, data) => {
740
791
  if (err) return reject(err);
@@ -824,7 +875,18 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
824
875
  };
825
876
  });
826
877
  }
878
+ var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
827
879
  async function find(client, options, timeoutMs = 3e4) {
880
+ if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
881
+ throw new Error(
882
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
883
+ );
884
+ }
885
+ if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
886
+ throw new Error(
887
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
888
+ );
889
+ }
828
890
  const args = [shellQuote(options.path)];
829
891
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
830
892
  if (options.type) args.push("-type", options.type);
@@ -869,8 +931,10 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
869
931
  var ConnectionPool = class {
870
932
  entries = /* @__PURE__ */ new Map();
871
933
  idleTtlMs;
934
+ maxPoolSize;
872
935
  constructor(options) {
873
936
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
937
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
874
938
  }
875
939
  async acquire(config) {
876
940
  const resolved = resolveConfig(config);
@@ -888,6 +952,24 @@ var ConnectionPool = class {
888
952
  if (existing?.dead) {
889
953
  this.entries.delete(key);
890
954
  }
955
+ if (this.entries.size >= this.maxPoolSize) {
956
+ let evicted = false;
957
+ for (const [k, e] of this.entries) {
958
+ if (e.refCount === 0) {
959
+ if (e.idleTimer) clearTimeout(e.idleTimer);
960
+ try {
961
+ e.client.end();
962
+ } catch {
963
+ }
964
+ this.entries.delete(k);
965
+ evicted = true;
966
+ break;
967
+ }
968
+ }
969
+ if (!evicted) {
970
+ throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
971
+ }
972
+ }
891
973
  try {
892
974
  const client = await connectWithProxy(resolved);
893
975
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
@@ -978,11 +1060,11 @@ ${diag}`);
978
1060
 
979
1061
  // src/tools.ts
980
1062
  var HostSchema = z.string().describe("SSH hostname or IP address");
981
- var PortSchema = z.number().optional().describe("SSH port (default: 22)");
1063
+ var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
982
1064
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
983
1065
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
984
1066
  var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
985
- var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
1067
+ var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
986
1068
  var connectionParams = {
987
1069
  host: HostSchema,
988
1070
  port: PortSchema,
@@ -994,10 +1076,10 @@ function registerTools(server, pool) {
994
1076
  const connectionPool = pool ?? new ConnectionPool();
995
1077
  server.tool(
996
1078
  "ssh_exec",
997
- "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
1079
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
998
1080
  {
999
1081
  ...connectionParams,
1000
- command: z.string().describe("Shell command to execute on the remote host"),
1082
+ command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1001
1083
  timeout: TimeoutSchema
1002
1084
  },
1003
1085
  async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
@@ -1110,7 +1192,7 @@ ${result.stderr}`);
1110
1192
  lines.push(` - ${s}`);
1111
1193
  }
1112
1194
  }
1113
- return { content: [{ type: "text", text: lines.join("\n") }] };
1195
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
1114
1196
  }
1115
1197
  );
1116
1198
  server.tool(
@@ -1247,13 +1329,14 @@ ${result.stderr}`);
1247
1329
  {
1248
1330
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1249
1331
  command: z.string().describe("Shell command to execute on all hosts"),
1332
+ port: PortSchema,
1250
1333
  username: UsernameSchema,
1251
1334
  privateKeyPath: KeyPathSchema,
1252
1335
  password: PasswordSchema,
1253
1336
  timeout: TimeoutSchema
1254
1337
  },
1255
- async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
1256
- const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
1338
+ async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1339
+ const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1257
1340
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1258
1341
  const lines = [];
1259
1342
  for (const r of results) {
@@ -1364,7 +1447,7 @@ ${files.join("\n")}` }] };
1364
1447
  function createServer(pool) {
1365
1448
  const server = new McpServer({
1366
1449
  name: "ssh-mcp",
1367
- version: "0.5.0"
1450
+ version: "0.7.0"
1368
1451
  });
1369
1452
  registerTools(server, pool);
1370
1453
  return server;
@@ -1394,6 +1477,7 @@ export {
1394
1477
  loadKey,
1395
1478
  multiExec,
1396
1479
  readFile,
1480
+ readKnownHostsKeys,
1397
1481
  registerTools,
1398
1482
  resolveConfig,
1399
1483
  serviceStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {