@yawlabs/ssh-mcp 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -202,8 +202,9 @@ function checkSshConfig(host) {
202
202
  inHostBlock = patterns.some((p) => {
203
203
  if (p === "*") return true;
204
204
  if (p === host) return true;
205
- if (p.includes("*")) {
206
- const regex = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
205
+ if (p.includes("*") || p.includes("?")) {
206
+ const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
207
+ const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
207
208
  return regex.test(host);
208
209
  }
209
210
  return false;
@@ -339,7 +340,7 @@ function ensureAgent() {
339
340
  const result = probeAgent(sock, "ssh-agent");
340
341
  if (result) return result;
341
342
  }
342
- if (!sock && process.platform === "win32") {
343
+ if (process.platform === "win32") {
343
344
  const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
344
345
  if (result) return result;
345
346
  }
@@ -458,10 +459,10 @@ function loadKey(keyPath) {
458
459
  if (ok) {
459
460
  return { status: "ok", message: `Key loaded: ${resolved}` };
460
461
  }
461
- if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
462
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
463
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
464
- }
462
+ if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
463
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
464
+ }
465
+ if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
465
466
  return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
466
467
  }
467
468
  return { status: "error", message: `Failed to load key: ${stdout}` };
@@ -749,10 +750,16 @@ async function connectWithProxy(resolved) {
749
750
  const jumpClient = await connectWithProxy(jumpResolved);
750
751
  const targetHost = resolved.connectConfig.host;
751
752
  const targetPort = resolved.connectConfig.port;
753
+ const endJump = () => {
754
+ try {
755
+ jumpClient.end();
756
+ } catch {
757
+ }
758
+ };
752
759
  const stream = await new Promise((resolve, reject) => {
753
760
  jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
754
761
  if (err) {
755
- jumpClient.end();
762
+ endJump();
756
763
  return reject(err);
757
764
  }
758
765
  resolve(stream2);
@@ -761,10 +768,10 @@ async function connectWithProxy(resolved) {
761
768
  return new Promise((resolve, reject) => {
762
769
  const client = new Client();
763
770
  client.on("ready", () => resolve(client)).on("error", (err) => {
764
- jumpClient.end();
771
+ endJump();
765
772
  reject(err);
766
773
  }).on("close", () => {
767
- jumpClient.end();
774
+ endJump();
768
775
  }).connect({ ...resolved.connectConfig, sock: stream });
769
776
  });
770
777
  }
@@ -832,14 +839,19 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
832
839
  stderrTruncated = true;
833
840
  }
834
841
  };
835
- stream.on("close", (code) => {
842
+ stream.on("close", (code, signal) => {
836
843
  let stdout = Buffer.concat(stdoutChunks).toString("utf8");
837
844
  let stderr = Buffer.concat(stderrChunks).toString("utf8");
838
845
  if (stdoutTruncated) stdout += `
839
846
  [output truncated at ${maxBytes} bytes]`;
840
847
  if (stderrTruncated) stderr += `
841
848
  [stderr truncated at ${maxBytes} bytes]`;
842
- settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
849
+ const exitCode = typeof code === "number" ? code : -1;
850
+ const result = { stdout, stderr, code: exitCode };
851
+ if (stdoutTruncated) result.stdoutTruncated = true;
852
+ if (stderrTruncated) result.stderrTruncated = true;
853
+ if (signal) result.signal = signal;
854
+ settle(() => resolve(result));
843
855
  }).on("data", appendStdout).on("error", (err2) => {
844
856
  settle(() => reject(err2));
845
857
  });
@@ -936,6 +948,12 @@ async function listDir(client, remotePath) {
936
948
  }
937
949
 
938
950
  // src/pool.ts
951
+ function defaultMaxPoolSize() {
952
+ const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
953
+ if (!raw) return 100;
954
+ const parsed = Number.parseInt(raw, 10);
955
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
956
+ }
939
957
  var ConnectionPool = class {
940
958
  entries = /* @__PURE__ */ new Map();
941
959
  // Coalesces concurrent connect attempts for the same key so we don't open N
@@ -952,7 +970,7 @@ var ConnectionPool = class {
952
970
  drained = false;
953
971
  constructor(options) {
954
972
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
955
- this.maxPoolSize = options?.maxPoolSize ?? 100;
973
+ this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
956
974
  }
957
975
  async acquire(config) {
958
976
  const resolved = resolveConfig(config);
@@ -1523,11 +1541,12 @@ ${result.stderr}`);
1523
1541
  maxdepth: z.number().optional().describe("Maximum directory depth to search"),
1524
1542
  minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
1525
1543
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1544
+ newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
1526
1545
  timeout: TimeoutSchema
1527
1546
  },
1528
- async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
1547
+ async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
1529
1548
  return connectionPool.withConnection(conn, async (client) => {
1530
- const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1549
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
1531
1550
  if (files.length === 0) {
1532
1551
  return { content: [{ type: "text", text: "No files found." }] };
1533
1552
  }
@@ -1542,7 +1561,7 @@ ${files.join("\n")}` }] };
1542
1561
  {
1543
1562
  ...connectionParams,
1544
1563
  path: z.string().describe("Absolute path to the file to tail"),
1545
- lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
1564
+ lines: z.number().int().positive().optional().describe("Number of lines to read from the end (default: 100). Must be a positive integer."),
1546
1565
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1547
1566
  timeout: TimeoutSchema
1548
1567
  },
@@ -1582,7 +1601,7 @@ ${files.join("\n")}` }] };
1582
1601
  if (status.since) lines.push(`Since: ${status.since}`);
1583
1602
  lines.push("");
1584
1603
  lines.push(status.raw);
1585
- return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
1604
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1586
1605
  });
1587
1606
  }
1588
1607
  );
package/dist/server.d.ts CHANGED
@@ -13,6 +13,12 @@ interface ExecResult {
13
13
  stdout: string;
14
14
  stderr: string;
15
15
  code: number;
16
+ /** True when stdout was truncated at the byte cap. */
17
+ stdoutTruncated?: boolean;
18
+ /** True when stderr was truncated at the byte cap. */
19
+ stderrTruncated?: boolean;
20
+ /** Signal name (e.g. "TERM") if the remote channel closed via signal instead of exit. */
21
+ signal?: string;
16
22
  }
17
23
  interface ResolvedConfig {
18
24
  connectConfig: ConnectConfig;
@@ -34,7 +40,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
34
40
  interface PoolOptions {
35
41
  /** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
36
42
  idleTtlMs?: number;
37
- /** Maximum number of connections in the pool. Default: 100 */
43
+ /**
44
+ * Maximum number of connections in the pool. Default: 100, overridable via the
45
+ * `SSH_MCP_MAX_POOL_SIZE` env var. When at capacity, the pool first tries to evict
46
+ * an idle entry; if every entry is in use, `acquire()` rejects with
47
+ * "Connection pool is full". Bump this for fan-out workloads against many distinct
48
+ * hosts (e.g. `ssh_multi_exec` across a large fleet).
49
+ */
38
50
  maxPoolSize?: number;
39
51
  }
40
52
  declare class ConnectionPool {
package/dist/server.js CHANGED
@@ -200,8 +200,9 @@ function checkSshConfig(host) {
200
200
  inHostBlock = patterns.some((p) => {
201
201
  if (p === "*") return true;
202
202
  if (p === host) return true;
203
- if (p.includes("*")) {
204
- const regex = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
203
+ if (p.includes("*") || p.includes("?")) {
204
+ const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
205
+ const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
205
206
  return regex.test(host);
206
207
  }
207
208
  return false;
@@ -335,7 +336,7 @@ function ensureAgent() {
335
336
  const result = probeAgent(sock, "ssh-agent");
336
337
  if (result) return result;
337
338
  }
338
- if (!sock && process.platform === "win32") {
339
+ if (process.platform === "win32") {
339
340
  const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
340
341
  if (result) return result;
341
342
  }
@@ -454,10 +455,10 @@ function loadKey(keyPath) {
454
455
  if (ok) {
455
456
  return { status: "ok", message: `Key loaded: ${resolved}` };
456
457
  }
457
- if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
458
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
459
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
460
- }
458
+ if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
459
+ return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
460
+ }
461
+ if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
461
462
  return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
462
463
  }
463
464
  return { status: "error", message: `Failed to load key: ${stdout}` };
@@ -745,10 +746,16 @@ async function connectWithProxy(resolved) {
745
746
  const jumpClient = await connectWithProxy(jumpResolved);
746
747
  const targetHost = resolved.connectConfig.host;
747
748
  const targetPort = resolved.connectConfig.port;
749
+ const endJump = () => {
750
+ try {
751
+ jumpClient.end();
752
+ } catch {
753
+ }
754
+ };
748
755
  const stream = await new Promise((resolve, reject) => {
749
756
  jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
750
757
  if (err) {
751
- jumpClient.end();
758
+ endJump();
752
759
  return reject(err);
753
760
  }
754
761
  resolve(stream2);
@@ -757,10 +764,10 @@ async function connectWithProxy(resolved) {
757
764
  return new Promise((resolve, reject) => {
758
765
  const client = new Client();
759
766
  client.on("ready", () => resolve(client)).on("error", (err) => {
760
- jumpClient.end();
767
+ endJump();
761
768
  reject(err);
762
769
  }).on("close", () => {
763
- jumpClient.end();
770
+ endJump();
764
771
  }).connect({ ...resolved.connectConfig, sock: stream });
765
772
  });
766
773
  }
@@ -846,14 +853,19 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
846
853
  stderrTruncated = true;
847
854
  }
848
855
  };
849
- stream.on("close", (code) => {
856
+ stream.on("close", (code, signal) => {
850
857
  let stdout = Buffer.concat(stdoutChunks).toString("utf8");
851
858
  let stderr = Buffer.concat(stderrChunks).toString("utf8");
852
859
  if (stdoutTruncated) stdout += `
853
860
  [output truncated at ${maxBytes} bytes]`;
854
861
  if (stderrTruncated) stderr += `
855
862
  [stderr truncated at ${maxBytes} bytes]`;
856
- settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
863
+ const exitCode = typeof code === "number" ? code : -1;
864
+ const result = { stdout, stderr, code: exitCode };
865
+ if (stdoutTruncated) result.stdoutTruncated = true;
866
+ if (stderrTruncated) result.stderrTruncated = true;
867
+ if (signal) result.signal = signal;
868
+ settle(() => resolve(result));
857
869
  }).on("data", appendStdout).on("error", (err2) => {
858
870
  settle(() => reject(err2));
859
871
  });
@@ -1032,6 +1044,12 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1032
1044
  }
1033
1045
 
1034
1046
  // src/pool.ts
1047
+ function defaultMaxPoolSize() {
1048
+ const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
1049
+ if (!raw) return 100;
1050
+ const parsed = Number.parseInt(raw, 10);
1051
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1052
+ }
1035
1053
  var ConnectionPool = class {
1036
1054
  entries = /* @__PURE__ */ new Map();
1037
1055
  // Coalesces concurrent connect attempts for the same key so we don't open N
@@ -1048,7 +1066,7 @@ var ConnectionPool = class {
1048
1066
  drained = false;
1049
1067
  constructor(options) {
1050
1068
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
1051
- this.maxPoolSize = options?.maxPoolSize ?? 100;
1069
+ this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
1052
1070
  }
1053
1071
  async acquire(config) {
1054
1072
  const resolved = resolveConfig(config);
@@ -1528,11 +1546,12 @@ ${result.stderr}`);
1528
1546
  maxdepth: z.number().optional().describe("Maximum directory depth to search"),
1529
1547
  minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
1530
1548
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
1549
+ newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
1531
1550
  timeout: TimeoutSchema
1532
1551
  },
1533
- async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
1552
+ async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
1534
1553
  return connectionPool.withConnection(conn, async (client) => {
1535
- const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
1554
+ const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
1536
1555
  if (files.length === 0) {
1537
1556
  return { content: [{ type: "text", text: "No files found." }] };
1538
1557
  }
@@ -1547,7 +1566,7 @@ ${files.join("\n")}` }] };
1547
1566
  {
1548
1567
  ...connectionParams,
1549
1568
  path: z.string().describe("Absolute path to the file to tail"),
1550
- lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
1569
+ lines: z.number().int().positive().optional().describe("Number of lines to read from the end (default: 100). Must be a positive integer."),
1551
1570
  grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
1552
1571
  timeout: TimeoutSchema
1553
1572
  },
@@ -1587,7 +1606,7 @@ ${files.join("\n")}` }] };
1587
1606
  if (status.since) lines.push(`Since: ${status.since}`);
1588
1607
  lines.push("");
1589
1608
  lines.push(status.raw);
1590
- return { content: [{ type: "text", text: lines.join("\n") }], isError: !status.active };
1609
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1591
1610
  });
1592
1611
  }
1593
1612
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,15 +47,15 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
- "ssh2": "^1.16.0",
51
- "zod": "^4.3.6"
50
+ "ssh2": "^1.17.0",
51
+ "zod": "^4.4.3"
52
52
  },
53
53
  "devDependencies": {
54
- "@biomejs/biome": "^2.4.13",
55
- "@types/node": "^25.6.0",
56
- "@types/ssh2": "^1.15.4",
54
+ "@biomejs/biome": "^2.4.15",
55
+ "@types/node": "^25.7.0",
56
+ "@types/ssh2": "^1.15.5",
57
57
  "tsup": "^8.5.1",
58
58
  "typescript": "^6.0.3",
59
- "vitest": "^4.1.5"
59
+ "vitest": "^4.1.6"
60
60
  }
61
61
  }