@yawlabs/ssh-mcp 0.17.2 → 0.17.3

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
@@ -11,7 +11,16 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
 
12
12
  // src/env.ts
13
13
  import { execFileSync as execFileSync2 } from "child_process";
14
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
14
+ import {
15
+ appendFileSync,
16
+ closeSync,
17
+ existsSync as existsSync2,
18
+ constants as fsConstants,
19
+ fstatSync,
20
+ openSync,
21
+ readdirSync as readdirSync2,
22
+ readFileSync as readFileSync3
23
+ } from "fs";
15
24
  import { homedir as homedir3 } from "os";
16
25
  import { join as join3 } from "path";
17
26
 
@@ -1083,7 +1092,7 @@ function ensureAgent() {
1083
1092
  message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
1084
1093
  };
1085
1094
  }
1086
- function detectKeyType(filePath, fileName) {
1095
+ function detectKeyType(filePath, fileName, content) {
1087
1096
  const pubPath = `${filePath}.pub`;
1088
1097
  if (existsSync2(pubPath)) {
1089
1098
  try {
@@ -1100,7 +1109,6 @@ function detectKeyType(filePath, fileName) {
1100
1109
  if (fileName.includes("ecdsa")) return "ecdsa";
1101
1110
  if (fileName.includes("dsa")) return "dsa";
1102
1111
  try {
1103
- const content = readFileSync3(filePath, "utf8");
1104
1112
  if (content.includes("RSA PRIVATE KEY")) return "rsa";
1105
1113
  if (content.includes("EC PRIVATE KEY")) return "ecdsa";
1106
1114
  if (content.includes("DSA PRIVATE KEY")) return "dsa";
@@ -1136,12 +1144,13 @@ function listSshKeysDetailed() {
1136
1144
  for (const file of files) {
1137
1145
  if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
1138
1146
  const filePath = join3(sshDir, file);
1147
+ let fd;
1139
1148
  try {
1140
- const stat = statSync(filePath);
1141
- if (!stat.isFile()) continue;
1142
- const content = readFileSync3(filePath, "utf8");
1149
+ fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
1150
+ if (!fstatSync(fd).isFile()) continue;
1151
+ const content = readFileSync3(fd, "utf8");
1143
1152
  if (!content.includes("PRIVATE KEY")) continue;
1144
- const type = detectKeyType(filePath, file);
1153
+ const type = detectKeyType(filePath, file, content);
1145
1154
  let fingerprint;
1146
1155
  const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
1147
1156
  if (fpOk) {
@@ -1151,6 +1160,8 @@ function listSshKeysDetailed() {
1151
1160
  const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
1152
1161
  keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1153
1162
  } catch {
1163
+ } finally {
1164
+ if (fd !== void 0) closeSync(fd);
1154
1165
  }
1155
1166
  }
1156
1167
  return { status: "ok", dir: sshDir, keys };
@@ -1306,7 +1317,7 @@ function testConnection(host, port = 22) {
1306
1317
  }
1307
1318
 
1308
1319
  // src/pool.ts
1309
- import { createHash } from "crypto";
1320
+ import { createHmac, randomBytes, scryptSync } from "crypto";
1310
1321
  var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
1311
1322
  var PoolFullError = class extends Error {
1312
1323
  code = POOL_FULL_ERROR_CODE;
@@ -1336,21 +1347,24 @@ function defaultMaxPoolSize() {
1336
1347
  const parsed = Number.parseInt(raw, 10);
1337
1348
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1338
1349
  }
1350
+ var AUTH_FINGERPRINT_SECRET = randomBytes(32);
1351
+ var PASSWORD_SCRYPT_COST = 1024;
1352
+ function updateField(h, tag, value) {
1353
+ const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
1354
+ const len = Buffer.alloc(4);
1355
+ len.writeUInt32BE(bytes.length);
1356
+ h.update(tag);
1357
+ h.update(len);
1358
+ h.update(bytes);
1359
+ }
1339
1360
  function authFingerprint(cc) {
1340
- const h = createHash("sha256");
1341
- if (cc.privateKey) {
1342
- h.update("k");
1343
- h.update(cc.privateKey);
1344
- }
1361
+ const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
1362
+ if (cc.privateKey) updateField(h, "k", cc.privateKey);
1345
1363
  if (cc.password !== void 0) {
1346
- h.update("p");
1347
- h.update(cc.password);
1348
- }
1349
- if (typeof cc.agent === "string") {
1350
- h.update("a");
1351
- h.update(cc.agent);
1364
+ updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
1352
1365
  }
1353
- return h.digest("hex").slice(0, 16);
1366
+ if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
1367
+ return h.digest("hex").slice(0, 32);
1354
1368
  }
1355
1369
  function resolveOrDiagnose(config) {
1356
1370
  try {
@@ -1510,7 +1524,7 @@ var ConnectionPool = class {
1510
1524
  return client;
1511
1525
  }
1512
1526
  throw new Error(
1513
- `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1527
+ `Failed to acquire SSH connection for ${cc.username}@${cc.host}:${cc.port} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1514
1528
  );
1515
1529
  }
1516
1530
  release(client) {
@@ -1743,7 +1757,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1743
1757
  }
1744
1758
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1745
1759
  var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1760
+ var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
1761
+ function isNonNegativeInteger(n) {
1762
+ return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
1763
+ }
1746
1764
  async function find(client, options, timeoutMs = 3e4) {
1765
+ if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
1766
+ throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
1767
+ }
1768
+ if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
1769
+ throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
1770
+ }
1771
+ if (options.minsize !== void 0 && typeof options.minsize !== "string") {
1772
+ throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
1773
+ }
1774
+ if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
1775
+ throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
1776
+ }
1747
1777
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1748
1778
  throw new Error(
1749
1779
  `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
@@ -1756,11 +1786,11 @@ async function find(client, options, timeoutMs = 3e4) {
1756
1786
  }
1757
1787
  const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1758
1788
  const args = [shellQuote(pathOperand)];
1759
- if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1760
- if (options.type) args.push("-type", options.type);
1789
+ if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1790
+ if (options.type) args.push("-type", shellQuote(options.type));
1761
1791
  if (options.name) args.push("-name", shellQuote(options.name));
1762
- if (options.minsize) args.push("-size", `+${options.minsize}`);
1763
- if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1792
+ if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1793
+ if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1764
1794
  if (options.newer) args.push("-newer", shellQuote(options.newer));
1765
1795
  const command = `find ${args.join(" ")}`;
1766
1796
  const result = await exec(client, command, timeoutMs);
@@ -1770,6 +1800,9 @@ async function find(client, options, timeoutMs = 3e4) {
1770
1800
  return result.stdout.split("\n").filter(Boolean);
1771
1801
  }
1772
1802
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1803
+ if (!isNonNegativeInteger(lines) || lines === 0) {
1804
+ throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
1805
+ }
1773
1806
  let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1774
1807
  if (grep) {
1775
1808
  command += ` | grep -i -e ${shellQuote(grep)}`;
@@ -2314,7 +2347,7 @@ ${result.stderr}`);
2314
2347
  path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
2315
2348
  name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
2316
2349
  type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
2317
- maxdepth: z.number().optional().describe("Maximum directory depth to search"),
2350
+ maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
2318
2351
  minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
2319
2352
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
2320
2353
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
package/dist/server.js CHANGED
@@ -299,7 +299,16 @@ function diagnose(host, port = 22) {
299
299
 
300
300
  // src/env.ts
301
301
  import { execFileSync as execFileSync2 } from "child_process";
302
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
302
+ import {
303
+ appendFileSync,
304
+ closeSync,
305
+ existsSync as existsSync2,
306
+ constants as fsConstants,
307
+ fstatSync,
308
+ openSync,
309
+ readdirSync as readdirSync2,
310
+ readFileSync as readFileSync3
311
+ } from "fs";
303
312
  import { homedir as homedir3 } from "os";
304
313
  import { join as join3 } from "path";
305
314
 
@@ -1092,7 +1101,7 @@ function ensureAgent() {
1092
1101
  message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
1093
1102
  };
1094
1103
  }
1095
- function detectKeyType(filePath, fileName) {
1104
+ function detectKeyType(filePath, fileName, content) {
1096
1105
  const pubPath = `${filePath}.pub`;
1097
1106
  if (existsSync2(pubPath)) {
1098
1107
  try {
@@ -1109,7 +1118,6 @@ function detectKeyType(filePath, fileName) {
1109
1118
  if (fileName.includes("ecdsa")) return "ecdsa";
1110
1119
  if (fileName.includes("dsa")) return "dsa";
1111
1120
  try {
1112
- const content = readFileSync3(filePath, "utf8");
1113
1121
  if (content.includes("RSA PRIVATE KEY")) return "rsa";
1114
1122
  if (content.includes("EC PRIVATE KEY")) return "ecdsa";
1115
1123
  if (content.includes("DSA PRIVATE KEY")) return "dsa";
@@ -1148,12 +1156,13 @@ function listSshKeysDetailed() {
1148
1156
  for (const file of files) {
1149
1157
  if (file.endsWith(".pub") || file.startsWith(".") || SSH_NON_KEY_FILES.has(file)) continue;
1150
1158
  const filePath = join3(sshDir, file);
1159
+ let fd;
1151
1160
  try {
1152
- const stat = statSync(filePath);
1153
- if (!stat.isFile()) continue;
1154
- const content = readFileSync3(filePath, "utf8");
1161
+ fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0));
1162
+ if (!fstatSync(fd).isFile()) continue;
1163
+ const content = readFileSync3(fd, "utf8");
1155
1164
  if (!content.includes("PRIVATE KEY")) continue;
1156
- const type = detectKeyType(filePath, file);
1165
+ const type = detectKeyType(filePath, file, content);
1157
1166
  let fingerprint;
1158
1167
  const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
1159
1168
  if (fpOk) {
@@ -1163,6 +1172,8 @@ function listSshKeysDetailed() {
1163
1172
  const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
1164
1173
  keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
1165
1174
  } catch {
1175
+ } finally {
1176
+ if (fd !== void 0) closeSync(fd);
1166
1177
  }
1167
1178
  }
1168
1179
  return { status: "ok", dir: sshDir, keys };
@@ -1318,7 +1329,7 @@ function testConnection(host, port = 22) {
1318
1329
  }
1319
1330
 
1320
1331
  // src/pool.ts
1321
- import { createHash } from "crypto";
1332
+ import { createHmac, randomBytes, scryptSync } from "crypto";
1322
1333
  var POOL_FULL_ERROR_CODE = "ERR_SSH_MCP_POOL_FULL";
1323
1334
  var PoolFullError = class extends Error {
1324
1335
  code = POOL_FULL_ERROR_CODE;
@@ -1348,21 +1359,24 @@ function defaultMaxPoolSize() {
1348
1359
  const parsed = Number.parseInt(raw, 10);
1349
1360
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
1350
1361
  }
1362
+ var AUTH_FINGERPRINT_SECRET = randomBytes(32);
1363
+ var PASSWORD_SCRYPT_COST = 1024;
1364
+ function updateField(h, tag, value) {
1365
+ const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
1366
+ const len = Buffer.alloc(4);
1367
+ len.writeUInt32BE(bytes.length);
1368
+ h.update(tag);
1369
+ h.update(len);
1370
+ h.update(bytes);
1371
+ }
1351
1372
  function authFingerprint(cc) {
1352
- const h = createHash("sha256");
1353
- if (cc.privateKey) {
1354
- h.update("k");
1355
- h.update(cc.privateKey);
1356
- }
1373
+ const h = createHmac("sha256", AUTH_FINGERPRINT_SECRET);
1374
+ if (cc.privateKey) updateField(h, "k", cc.privateKey);
1357
1375
  if (cc.password !== void 0) {
1358
- h.update("p");
1359
- h.update(cc.password);
1360
- }
1361
- if (typeof cc.agent === "string") {
1362
- h.update("a");
1363
- h.update(cc.agent);
1376
+ updateField(h, "p", scryptSync(cc.password, AUTH_FINGERPRINT_SECRET, 32, { N: PASSWORD_SCRYPT_COST }));
1364
1377
  }
1365
- return h.digest("hex").slice(0, 16);
1378
+ if (typeof cc.agent === "string") updateField(h, "a", cc.agent);
1379
+ return h.digest("hex").slice(0, 32);
1366
1380
  }
1367
1381
  function resolveOrDiagnose(config) {
1368
1382
  try {
@@ -1522,7 +1536,7 @@ var ConnectionPool = class {
1522
1536
  return client;
1523
1537
  }
1524
1538
  throw new Error(
1525
- `Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1539
+ `Failed to acquire SSH connection for ${cc.username}@${cc.host}:${cc.port} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
1526
1540
  );
1527
1541
  }
1528
1542
  release(client) {
@@ -1746,7 +1760,23 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1746
1760
  }
1747
1761
  var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1748
1762
  var FIND_EXPRESSION_TOKENS = /* @__PURE__ */ new Set(["(", ")", "!", ","]);
1763
+ var VALID_FIND_TYPES = /* @__PURE__ */ new Set(["f", "d", "l"]);
1764
+ function isNonNegativeInteger(n) {
1765
+ return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
1766
+ }
1749
1767
  async function find(client, options, timeoutMs = 3e4) {
1768
+ if (options.type !== void 0 && !VALID_FIND_TYPES.has(options.type)) {
1769
+ throw new Error(`Invalid type: "${String(options.type)}". Expected one of: f (file), d (directory), l (symlink)`);
1770
+ }
1771
+ if (options.maxdepth !== void 0 && !isNonNegativeInteger(options.maxdepth)) {
1772
+ throw new Error(`Invalid maxdepth: ${String(options.maxdepth)}. Expected a non-negative integer`);
1773
+ }
1774
+ if (options.minsize !== void 0 && typeof options.minsize !== "string") {
1775
+ throw new Error('Invalid minsize: expected a string such as "1M" or "100k"');
1776
+ }
1777
+ if (options.maxsize !== void 0 && typeof options.maxsize !== "string") {
1778
+ throw new Error('Invalid maxsize: expected a string such as "10M" or "500k"');
1779
+ }
1750
1780
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1751
1781
  throw new Error(
1752
1782
  `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
@@ -1759,11 +1789,11 @@ async function find(client, options, timeoutMs = 3e4) {
1759
1789
  }
1760
1790
  const pathOperand = options.path.startsWith("-") || FIND_EXPRESSION_TOKENS.has(options.path) ? `./${options.path}` : options.path;
1761
1791
  const args = [shellQuote(pathOperand)];
1762
- if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
1763
- if (options.type) args.push("-type", options.type);
1792
+ if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
1793
+ if (options.type) args.push("-type", shellQuote(options.type));
1764
1794
  if (options.name) args.push("-name", shellQuote(options.name));
1765
- if (options.minsize) args.push("-size", `+${options.minsize}`);
1766
- if (options.maxsize) args.push("-size", `-${options.maxsize}`);
1795
+ if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
1796
+ if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
1767
1797
  if (options.newer) args.push("-newer", shellQuote(options.newer));
1768
1798
  const command = `find ${args.join(" ")}`;
1769
1799
  const result = await exec(client, command, timeoutMs);
@@ -1773,6 +1803,9 @@ async function find(client, options, timeoutMs = 3e4) {
1773
1803
  return result.stdout.split("\n").filter(Boolean);
1774
1804
  }
1775
1805
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
1806
+ if (!isNonNegativeInteger(lines) || lines === 0) {
1807
+ throw new Error(`Invalid lines: ${String(lines)}. Expected a positive integer`);
1808
+ }
1776
1809
  let command = `tail -n ${lines} -- ${shellQuote(path)}`;
1777
1810
  if (grep) {
1778
1811
  command += ` | grep -i -e ${shellQuote(grep)}`;
@@ -2320,7 +2353,7 @@ ${result.stderr}`);
2320
2353
  path: z.string().describe("Directory to search in (e.g. /var/log, /home/user)"),
2321
2354
  name: z.string().optional().describe("Filename pattern with wildcards (e.g. '*.log', 'config.*')"),
2322
2355
  type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
2323
- maxdepth: z.number().optional().describe("Maximum directory depth to search"),
2356
+ maxdepth: z.number().int().nonnegative().optional().describe("Maximum directory depth to search (0 = the path itself only)"),
2324
2357
  minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
2325
2358
  maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
2326
2359
  newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "SSH MCP server: run remote commands, transfer files over SFTP, manage ssh-agent keys and known_hosts, and auto-diagnose SSH failures.",
6
6
  "type": "module",