mikromcp 1.13.0 → 1.13.1

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
@@ -12,7 +12,7 @@
12
12
 
13
13
  [![CI](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
14
14
  [![Release](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
15
- [![Version](https://img.shields.io/badge/version-v1.13.0-2DD4BF)](package.json)
15
+ [![Version](https://img.shields.io/badge/version-v1.13.1-2DD4BF)](package.json)
16
16
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
17
17
  [![Node.js >= 22](https://img.shields.io/badge/node-%3E%3D22-339933)](package.json)
18
18
  [![RouterOS 7.x](https://img.shields.io/badge/RouterOS-7.x-293239)](https://help.mikrotik.com/docs/display/ROS/REST+API)
@@ -62,7 +62,7 @@ routers:
62
62
  # sshPort: 22 # SSH port (default 22)
63
63
  # sshUsername: "automation" # Optional SSH/SFTP username; defaults to REST username
64
64
  # sshPrivateKeyPath: "/home/user/.ssh/id_ed25519" # Absolute path; suppresses SSH/SFTP password auth
65
- # sshFingerprint: "aabbcc..." # Pin SSH host key SHA256 fingerprint (hex)
65
+ # sshFingerprint: "SHA256:..." # Pin the SSH host key: SHA256:<base64> as ssh-keygen -l prints it, or hex
66
66
  # cmdAllow: # Strict allow-list for run_command (see above)
67
67
  # - "/ip/route/print*"
68
68
  # cmdDeny: # Additional deny patterns beyond builtins
@@ -85,4 +85,4 @@ routers:
85
85
  # envPrefix: "ROUTER_EDGE02"
86
86
  # tags: ["branch-office", "edge"]
87
87
  # rosVersion: "7.12"
88
- # sshFingerprint: "aabbccddeeff..."
88
+ # sshFingerprint: "SHA256:..."
package/dist/main.js CHANGED
@@ -154,6 +154,7 @@ var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
154
154
  var TIMEOUT_CODES = /* @__PURE__ */ new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
155
155
  var SSH_AUTH_LEVEL = "client-authentication";
156
156
  var SSH_TIMEOUT_LEVEL = "client-timeout";
157
+ var SSH_HOST_KEY_MISMATCH = "SSH_HOST_KEY_MISMATCH";
157
158
  function defaultRecoverability(category) {
158
159
  switch (category) {
159
160
  case "VALIDATION" /* VALIDATION */:
@@ -294,6 +295,22 @@ function enrichError(error, context) {
294
295
  cause: error
295
296
  });
296
297
  }
298
+ if (typeof raw === "object" && raw !== null && raw.code === SSH_HOST_KEY_MISMATCH) {
299
+ return new MikroMCPError({
300
+ category: "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */,
301
+ code: SSH_HOST_KEY_MISMATCH,
302
+ message: rawMessage,
303
+ details: buildDetails(
304
+ { transport: "ssh", expected: raw.expected, actual: raw.actual },
305
+ context
306
+ ),
307
+ recoverability: {
308
+ retryable: false,
309
+ suggestedAction: "A reset, Netinstall or /ip ssh regenerate-host-key changes the router's host key; so does another device on that address, or an intercepted connection. Do not copy the presented fingerprint into routers.yaml unverified: have the operator confirm the router's key over a channel they trust, then update sshFingerprint and restart the server."
310
+ },
311
+ cause: error
312
+ });
313
+ }
297
314
  if (typeof raw === "object" && raw !== null && typeof raw.statusCode === "number") {
298
315
  const statusCode = raw.statusCode;
299
316
  let category2 = categoryFromStatus(statusCode);
@@ -535,6 +552,9 @@ import { z as z3 } from "zod";
535
552
  var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
536
553
  var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
537
554
  var FREE_TEXT_KEYS = /* @__PURE__ */ new Set(["comment", "text"]);
555
+ function isVersionKey(key2) {
556
+ return key2 === "version" || key2.endsWith("-version") || key2.endsWith("-firmware");
557
+ }
538
558
  function parseRouterOSValue(key2, value) {
539
559
  if (key2 === ".id" || FREE_TEXT_KEYS.has(key2)) {
540
560
  return value;
@@ -545,8 +565,9 @@ function parseRouterOSValue(key2, value) {
545
565
  return value;
546
566
  }
547
567
  if (NUMERIC_RE.test(value)) {
548
- const num = Number(value);
549
568
  const isInteger = !value.includes(".");
569
+ if (!isInteger && isVersionKey(key2)) return value;
570
+ const num = Number(value);
550
571
  if (isInteger ? Number.isSafeInteger(num) : Number.isFinite(num)) {
551
572
  return num;
552
573
  }
@@ -14032,9 +14053,37 @@ function getCredentials(router) {
14032
14053
  }
14033
14054
 
14034
14055
  // src/adapter/ssh-client.ts
14035
- import { createHash as createHash2 } from "crypto";
14036
14056
  import { readFileSync as readFileSync3 } from "fs";
14037
14057
  import { Client } from "ssh2";
14058
+
14059
+ // src/adapter/ssh-host-key.ts
14060
+ import { createHash as createHash2 } from "crypto";
14061
+ function pinHostKey(fingerprint2) {
14062
+ const expected = fingerprint2.toLowerCase();
14063
+ let refused;
14064
+ return {
14065
+ hostVerifier: (key2) => {
14066
+ const digest = createHash2("sha256").update(key2).digest();
14067
+ if (digest.toString("hex") === expected) return true;
14068
+ refused = digest;
14069
+ return false;
14070
+ },
14071
+ connectionError: (err) => {
14072
+ if (refused === void 0) return err;
14073
+ const actual = refused.toString("hex");
14074
+ const openssh = `SHA256:${refused.toString("base64").replace(/=+$/, "")}`;
14075
+ return Object.assign(
14076
+ new Error(
14077
+ `SSH host key does not match sshFingerprint. Expected: ${expected}, got: ${actual} (${openssh})`,
14078
+ { cause: err }
14079
+ ),
14080
+ { code: "SSH_HOST_KEY_MISMATCH", expected, actual }
14081
+ );
14082
+ }
14083
+ };
14084
+ }
14085
+
14086
+ // src/adapter/ssh-client.ts
14038
14087
  var SshClient = class {
14039
14088
  constructor(config, credentials, options) {
14040
14089
  this.config = config;
@@ -14052,6 +14101,7 @@ var SshClient = class {
14052
14101
  const privateKey = this.config.sshPrivateKeyPath ? readFileSync3(this.config.sshPrivateKeyPath) : void 0;
14053
14102
  return new Promise((resolve, reject) => {
14054
14103
  const conn = new Client();
14104
+ const pin = this.config.sshFingerprint ? pinHostKey(this.config.sshFingerprint) : void 0;
14055
14105
  const chunks = [];
14056
14106
  let outputSize = 0;
14057
14107
  let truncated = false;
@@ -14111,7 +14161,7 @@ var SshClient = class {
14111
14161
  });
14112
14162
  });
14113
14163
  });
14114
- conn.on("error", (err) => cleanup(err));
14164
+ conn.on("error", (err) => cleanup(pin ? pin.connectionError(err) : err));
14115
14165
  const connectOptions = {
14116
14166
  host: this.config.host,
14117
14167
  port: this.config.sshPort ?? 22,
@@ -14120,13 +14170,7 @@ var SshClient = class {
14120
14170
  };
14121
14171
  if (privateKey) connectOptions.privateKey = privateKey;
14122
14172
  else connectOptions.password = this.credentials.password;
14123
- if (this.config.sshFingerprint) {
14124
- const expected = this.config.sshFingerprint.toLowerCase();
14125
- connectOptions.hostVerifier = (key2) => {
14126
- const actual = createHash2("sha256").update(key2).digest("hex");
14127
- return actual === expected;
14128
- };
14129
- }
14173
+ if (pin) connectOptions.hostVerifier = pin.hostVerifier;
14130
14174
  conn.connect(connectOptions);
14131
14175
  });
14132
14176
  }
@@ -14180,7 +14224,6 @@ var FtpClient = class {
14180
14224
  };
14181
14225
 
14182
14226
  // src/adapter/sftp-client.ts
14183
- import { createHash as createHash3 } from "crypto";
14184
14227
  import { readFileSync as readFileSync4 } from "fs";
14185
14228
  import { Client as Client3 } from "ssh2";
14186
14229
  var SftpClient = class {
@@ -14194,6 +14237,7 @@ var SftpClient = class {
14194
14237
  const privateKey = this.config.sshPrivateKeyPath ? readFileSync4(this.config.sshPrivateKeyPath) : void 0;
14195
14238
  return new Promise((resolve, reject) => {
14196
14239
  const conn = new Client3();
14240
+ const pin = this.config.sshFingerprint ? pinHostKey(this.config.sshFingerprint) : void 0;
14197
14241
  let settled = false;
14198
14242
  const cleanup = (err) => {
14199
14243
  if (settled) return;
@@ -14213,7 +14257,7 @@ var SftpClient = class {
14213
14257
  });
14214
14258
  });
14215
14259
  });
14216
- conn.on("error", (err) => cleanup(err));
14260
+ conn.on("error", (err) => cleanup(pin ? pin.connectionError(err) : err));
14217
14261
  const connectOptions = {
14218
14262
  host: this.config.host,
14219
14263
  port: this.config.sshPort ?? 22,
@@ -14222,13 +14266,7 @@ var SftpClient = class {
14222
14266
  };
14223
14267
  if (privateKey) connectOptions.privateKey = privateKey;
14224
14268
  else connectOptions.password = this.credentials.password;
14225
- if (this.config.sshFingerprint) {
14226
- const expected = this.config.sshFingerprint.toLowerCase();
14227
- connectOptions.hostVerifier = (key2) => {
14228
- const actual = createHash3("sha256").update(key2).digest("hex");
14229
- return actual === expected;
14230
- };
14231
- }
14269
+ if (pin) connectOptions.hostVerifier = pin.hostVerifier;
14232
14270
  conn.connect(connectOptions);
14233
14271
  });
14234
14272
  }
@@ -14365,13 +14403,13 @@ function getStdioIdentity(stdioIdentityName, registry) {
14365
14403
  }
14366
14404
 
14367
14405
  // src/middleware/confirmation.ts
14368
- import { createHmac, createHash as createHash4, timingSafeEqual } from "crypto";
14406
+ import { createHmac, createHash as createHash3, timingSafeEqual } from "crypto";
14369
14407
  var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
14370
14408
  var BYPASS_ROLES = ["admin", "superadmin"];
14371
14409
  var usedTokens = /* @__PURE__ */ new Map();
14372
14410
  function computeParamHash(params) {
14373
14411
  const { confirmationToken: _, ...rest } = params;
14374
- return createHash4("sha256").update(JSON.stringify(rest)).digest("hex");
14412
+ return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
14375
14413
  }
14376
14414
  function sign(tool, routerId2, identityId, paramHash, expiresAtMs, secret) {
14377
14415
  const payload = `${tool}|${routerId2}|${identityId}|${paramHash}|${expiresAtMs}`;
@@ -14947,7 +14985,7 @@ async function executeToolCall(tool, args, deps) {
14947
14985
  }
14948
14986
 
14949
14987
  // src/middleware/fleet-confirmation.ts
14950
- import { createHmac as createHmac2, createHash as createHash5, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
14988
+ import { createHmac as createHmac2, createHash as createHash4, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
14951
14989
  var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
14952
14990
  var processSecret = randomBytes2(32).toString("hex");
14953
14991
  var usedTokens2 = /* @__PURE__ */ new Map();
@@ -14959,7 +14997,7 @@ function fingerprint(args) {
14959
14997
  params: args.params,
14960
14998
  identityId: args.identityId
14961
14999
  });
14962
- return createHash5("sha256").update(payload).digest("hex");
15000
+ return createHash4("sha256").update(payload).digest("hex");
14963
15001
  }
14964
15002
  function sign2(fp, expiresAtMs, secret) {
14965
15003
  const mac = createHmac2("sha256", secret).update(`${fp}|${expiresAtMs}`).digest("hex");
@@ -15577,6 +15615,14 @@ function privateKeyIsShared(path) {
15577
15615
  if (process.platform === "win32") return false;
15578
15616
  return (statSync(path).mode & 63) !== 0;
15579
15617
  }
15618
+ var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
15619
+ var OPENSSH_SHA256_RE = /^SHA256:([A-Za-z0-9+/]{43})=?$/;
15620
+ function sshFingerprintHex(value) {
15621
+ const openssh = OPENSSH_SHA256_RE.exec(value);
15622
+ if (openssh) return Buffer.from(openssh[1], "base64").toString("hex");
15623
+ const hex = value.replace(/:/g, "").toLowerCase();
15624
+ return SHA256_HEX_RE.test(hex) ? hex : null;
15625
+ }
15580
15626
 
15581
15627
  // src/config/router-registry.ts
15582
15628
  var log51 = createLogger("router-registry");
@@ -15605,7 +15651,17 @@ var RouterConfigSchema = z49.object({
15605
15651
  sshPort: z49.number().int().min(1).max(65535).optional(),
15606
15652
  sshUsername: z49.string().min(1).optional(),
15607
15653
  sshPrivateKeyPath: z49.string().min(1).refine(isAbsolute, "sshPrivateKeyPath must be absolute").optional(),
15608
- sshFingerprint: z49.string().optional(),
15654
+ // Stored as lowercase hex, the form SshClient compares. A malformed value can
15655
+ // never match a host key, so it fails here instead of on every SSH call.
15656
+ sshFingerprint: z49.string().transform((value, ctx) => {
15657
+ const hex = sshFingerprintHex(value);
15658
+ if (hex !== null) return hex;
15659
+ ctx.addIssue({
15660
+ code: z49.ZodIssueCode.custom,
15661
+ message: "sshFingerprint must be a SHA-256 host-key fingerprint: 64 hex characters (colons allowed) or SHA256:<base64> as ssh-keygen -l prints it"
15662
+ });
15663
+ return z49.NEVER;
15664
+ }).optional(),
15609
15665
  cmdAllow: z49.array(z49.string()).optional(),
15610
15666
  cmdDeny: z49.array(z49.string()).optional(),
15611
15667
  maintenanceWindows: z49.array(
@@ -15740,9 +15796,9 @@ ${keyIssues.join("\n")}`);
15740
15796
  };
15741
15797
 
15742
15798
  // src/adapter/connection-pool.ts
15743
- import { createHash as createHash6 } from "crypto";
15799
+ import { createHash as createHash5 } from "crypto";
15744
15800
  function credentialHash(credentials) {
15745
- return createHash6("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
15801
+ return createHash5("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
15746
15802
  }
15747
15803
  function createDeviceClient(config, credentials) {
15748
15804
  if (config.deviceType === "swos") {
@@ -15804,7 +15860,7 @@ var ConnectionPool = class {
15804
15860
  };
15805
15861
 
15806
15862
  // src/version.ts
15807
- var VERSION = "1.13.0";
15863
+ var VERSION = "1.13.1";
15808
15864
 
15809
15865
  // src/mcp/server.ts
15810
15866
  var SERVER_INSTRUCTIONS = [
@@ -16112,7 +16168,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
16112
16168
 
16113
16169
  // src/config/identity-registry.ts
16114
16170
  import { readFileSync as readFileSync6, existsSync as existsSync2 } from "fs";
16115
- import { createHash as createHash7 } from "crypto";
16171
+ import { createHash as createHash6 } from "crypto";
16116
16172
  import { parse as parse2 } from "yaml";
16117
16173
  import { z as z50 } from "zod";
16118
16174
  import bcrypt from "bcryptjs";
@@ -16167,7 +16223,7 @@ ${issues}`);
16167
16223
  }));
16168
16224
  }
16169
16225
  async findIdentityByToken(token) {
16170
- const cacheKey = createHash7("sha256").update(token).digest("hex");
16226
+ const cacheKey = createHash6("sha256").update(token).digest("hex");
16171
16227
  const toIdentity = (stored) => ({
16172
16228
  id: stored.id,
16173
16229
  role: stored.role,
@@ -16282,7 +16338,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
16282
16338
  import { homedir as homedir2 } from "os";
16283
16339
  import * as net from "net";
16284
16340
  import chalk from "chalk";
16285
- var localVersion = "1.13.0";
16341
+ var localVersion = "1.13.1";
16286
16342
  var results = [];
16287
16343
  function ok(msg) {
16288
16344
  console.log(chalk.green("\u2705 ") + msg);
@@ -17027,7 +17083,7 @@ async function runInit() {
17027
17083
  }
17028
17084
 
17029
17085
  // src/cli/index.ts
17030
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.13.0");
17086
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.13.1");
17031
17087
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
17032
17088
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
17033
17089
  program.command("init").description("Interactive setup wizard").action(runInit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikromcp",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "mcpName": "io.github.AliKarami/mikromcp",
5
5
  "description": "Production-grade MCP server for MikroTik RouterOS",
6
6
  "type": "module",