@yawlabs/ssh-mcp 0.5.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/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 {
@@ -423,9 +426,21 @@ function getSftp(client) {
423
426
  });
424
427
  });
425
428
  }
426
- 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) {
427
431
  const sftp = await getSftp(client);
428
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
+ }
429
444
  return await new Promise((resolve, reject) => {
430
445
  sftp.readFile(remotePath, (err, data) => {
431
446
  if (err) return reject(err);
@@ -493,8 +508,10 @@ async function listDir(client, remotePath) {
493
508
  var ConnectionPool = class {
494
509
  entries = /* @__PURE__ */ new Map();
495
510
  idleTtlMs;
511
+ maxPoolSize;
496
512
  constructor(options) {
497
513
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
514
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
498
515
  }
499
516
  async acquire(config) {
500
517
  const resolved = resolveConfig(config);
@@ -512,6 +529,24 @@ var ConnectionPool = class {
512
529
  if (existing?.dead) {
513
530
  this.entries.delete(key);
514
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
+ }
515
550
  try {
516
551
  const client = await connectWithProxy(resolved);
517
552
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
@@ -924,7 +959,18 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
924
959
  };
925
960
  });
926
961
  }
962
+ var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
927
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
+ }
928
974
  const args = [shellQuote(options.path)];
929
975
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
930
976
  if (options.type) args.push("-type", options.type);
@@ -967,11 +1013,11 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
967
1013
 
968
1014
  // src/tools.ts
969
1015
  var HostSchema = z.string().describe("SSH hostname or IP address");
970
- 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)");
971
1017
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
972
1018
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
973
1019
  var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
974
- 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)");
975
1021
  var connectionParams = {
976
1022
  host: HostSchema,
977
1023
  port: PortSchema,
@@ -1099,7 +1145,7 @@ ${result.stderr}`);
1099
1145
  lines.push(` - ${s}`);
1100
1146
  }
1101
1147
  }
1102
- return { content: [{ type: "text", text: lines.join("\n") }] };
1148
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
1103
1149
  }
1104
1150
  );
1105
1151
  server.tool(
@@ -1236,13 +1282,14 @@ ${result.stderr}`);
1236
1282
  {
1237
1283
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1238
1284
  command: z.string().describe("Shell command to execute on all hosts"),
1285
+ port: PortSchema,
1239
1286
  username: UsernameSchema,
1240
1287
  privateKeyPath: KeyPathSchema,
1241
1288
  password: PasswordSchema,
1242
1289
  timeout: TimeoutSchema
1243
1290
  },
1244
- async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
1245
- const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
1291
+ async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1292
+ const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1246
1293
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1247
1294
  const lines = [];
1248
1295
  for (const r of results) {
@@ -1353,7 +1400,7 @@ ${files.join("\n")}` }] };
1353
1400
  function createServer(pool) {
1354
1401
  const server = new McpServer({
1355
1402
  name: "ssh-mcp",
1356
- version: "0.5.0"
1403
+ version: "0.6.0"
1357
1404
  });
1358
1405
  registerTools(server, pool);
1359
1406
  return server;
package/dist/server.d.ts CHANGED
@@ -24,7 +24,7 @@ declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
24
24
  declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
25
25
  declare function connect(config: SSHConfig): Promise<Client>;
26
26
  declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
27
- declare function readFile(client: Client, remotePath: string): Promise<string>;
27
+ declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
28
28
  declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
29
29
  declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
30
30
  declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
@@ -33,10 +33,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
33
33
  interface PoolOptions {
34
34
  /** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
35
35
  idleTtlMs?: number;
36
+ /** Maximum number of connections in the pool. Default: 100 */
37
+ maxPoolSize?: number;
36
38
  }
37
39
  declare class ConnectionPool {
38
40
  private entries;
39
41
  private idleTtlMs;
42
+ private maxPoolSize;
40
43
  constructor(options?: PoolOptions);
41
44
  acquire(config: SSHConfig): Promise<Client>;
42
45
  release(client: Client): void;
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 {
@@ -732,9 +735,21 @@ function getSftp(client) {
732
735
  });
733
736
  });
734
737
  }
735
- 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) {
736
740
  const sftp = await getSftp(client);
737
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
+ }
738
753
  return await new Promise((resolve, reject) => {
739
754
  sftp.readFile(remotePath, (err, data) => {
740
755
  if (err) return reject(err);
@@ -824,7 +839,18 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
824
839
  };
825
840
  });
826
841
  }
842
+ var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
827
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
+ }
828
854
  const args = [shellQuote(options.path)];
829
855
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
830
856
  if (options.type) args.push("-type", options.type);
@@ -869,8 +895,10 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
869
895
  var ConnectionPool = class {
870
896
  entries = /* @__PURE__ */ new Map();
871
897
  idleTtlMs;
898
+ maxPoolSize;
872
899
  constructor(options) {
873
900
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
901
+ this.maxPoolSize = options?.maxPoolSize ?? 100;
874
902
  }
875
903
  async acquire(config) {
876
904
  const resolved = resolveConfig(config);
@@ -888,6 +916,24 @@ var ConnectionPool = class {
888
916
  if (existing?.dead) {
889
917
  this.entries.delete(key);
890
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
+ }
891
937
  try {
892
938
  const client = await connectWithProxy(resolved);
893
939
  const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
@@ -978,11 +1024,11 @@ ${diag}`);
978
1024
 
979
1025
  // src/tools.ts
980
1026
  var HostSchema = z.string().describe("SSH hostname or IP address");
981
- 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)");
982
1028
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
983
1029
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
984
1030
  var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
985
- 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)");
986
1032
  var connectionParams = {
987
1033
  host: HostSchema,
988
1034
  port: PortSchema,
@@ -1110,7 +1156,7 @@ ${result.stderr}`);
1110
1156
  lines.push(` - ${s}`);
1111
1157
  }
1112
1158
  }
1113
- return { content: [{ type: "text", text: lines.join("\n") }] };
1159
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
1114
1160
  }
1115
1161
  );
1116
1162
  server.tool(
@@ -1247,13 +1293,14 @@ ${result.stderr}`);
1247
1293
  {
1248
1294
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1249
1295
  command: z.string().describe("Shell command to execute on all hosts"),
1296
+ port: PortSchema,
1250
1297
  username: UsernameSchema,
1251
1298
  privateKeyPath: KeyPathSchema,
1252
1299
  password: PasswordSchema,
1253
1300
  timeout: TimeoutSchema
1254
1301
  },
1255
- async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
1256
- const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
1302
+ async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1303
+ const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1257
1304
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1258
1305
  const lines = [];
1259
1306
  for (const r of results) {
@@ -1364,7 +1411,7 @@ ${files.join("\n")}` }] };
1364
1411
  function createServer(pool) {
1365
1412
  const server = new McpServer({
1366
1413
  name: "ssh-mcp",
1367
- version: "0.5.0"
1414
+ version: "0.6.0"
1368
1415
  });
1369
1416
  registerTools(server, pool);
1370
1417
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.5.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": {