mikromcp 1.7.0 → 1.8.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/main.js +1591 -1219
  3. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -12,15 +12,80 @@ import { spawnSync } from "child_process";
12
12
  // src/config/app-config.ts
13
13
  import { join } from "path";
14
14
  import { homedir } from "os";
15
+
16
+ // src/domain/errors/error-types.ts
17
+ var MikroMCPError = class extends Error {
18
+ category;
19
+ code;
20
+ details;
21
+ recoverability;
22
+ constructor(options) {
23
+ super(options.message);
24
+ this.name = "MikroMCPError";
25
+ this.category = options.category;
26
+ this.code = options.code;
27
+ this.details = options.details;
28
+ this.recoverability = options.recoverability;
29
+ if (options.cause !== void 0) {
30
+ this.cause = options.cause;
31
+ }
32
+ }
33
+ /** Serialize to a plain JSON-safe object. */
34
+ toJSON() {
35
+ return {
36
+ name: this.name,
37
+ category: this.category,
38
+ code: this.code,
39
+ message: this.message,
40
+ details: this.details,
41
+ recoverability: this.recoverability
42
+ };
43
+ }
44
+ /** Convert to the wire-format error data used in DomainResult. */
45
+ toMikroMCPErrorData() {
46
+ return {
47
+ category: this.category,
48
+ code: this.code,
49
+ message: this.message,
50
+ details: this.details,
51
+ recoverability: {
52
+ retryable: this.recoverability.retryable,
53
+ retryAfterMs: this.recoverability.retryAfterMs,
54
+ suggestedAction: this.recoverability.suggestedAction,
55
+ alternativeTools: this.recoverability.alternativeTools
56
+ }
57
+ };
58
+ }
59
+ };
60
+
61
+ // src/config/app-config.ts
15
62
  function mikromcpDir() {
16
63
  return join(homedir(), ".mikromcp");
17
64
  }
65
+ function intEnv(name, fallback) {
66
+ const raw = process.env[name];
67
+ if (raw === void 0 || raw === "") return fallback;
68
+ const value = Number(raw);
69
+ if (!Number.isInteger(value)) {
70
+ throw new MikroMCPError({
71
+ category: "CONFIGURATION" /* CONFIGURATION */,
72
+ code: "INVALID_ENV_INT",
73
+ message: `Environment variable ${name} must be an integer, got "${raw}".`,
74
+ details: { variable: name, value: raw },
75
+ recoverability: {
76
+ retryable: false,
77
+ suggestedAction: `Set ${name} to an integer, or unset it to use the default (${fallback}).`
78
+ }
79
+ });
80
+ }
81
+ return value;
82
+ }
18
83
  function loadAppConfig() {
19
84
  const env = process.env;
20
85
  const dataDir = env.MIKROMCP_DATA_DIR ?? join(mikromcpDir(), "data");
21
86
  return {
22
87
  transport: env.MIKROMCP_TRANSPORT === "http" ? "http" : "stdio",
23
- port: parseInt(env.MIKROMCP_PORT ?? "3000", 10),
88
+ port: intEnv("MIKROMCP_PORT", 3e3),
24
89
  bindHost: env.MIKROMCP_BIND_HOST ?? "127.0.0.1",
25
90
  logLevel: env.MIKROMCP_LOG_LEVEL ?? "info",
26
91
  configPath: env.MIKROMCP_CONFIG_PATH ?? join(mikromcpDir(), "routers.yaml"),
@@ -35,12 +100,12 @@ function loadAppConfig() {
35
100
  confirmationSecret: env.MIKROMCP_CONFIRMATION_SECRET || void 0,
36
101
  auditLogPath: env.MIKROMCP_AUDIT_LOG_PATH || void 0,
37
102
  http: {
38
- maxBodyBytes: parseInt(env.MIKROMCP_HTTP_MAX_BODY_BYTES ?? String(1024 * 1024), 10),
39
- rateLimitRpm: parseInt(env.MIKROMCP_HTTP_RATE_LIMIT_RPM ?? "60", 10)
103
+ maxBodyBytes: intEnv("MIKROMCP_HTTP_MAX_BODY_BYTES", 1024 * 1024),
104
+ rateLimitRpm: intEnv("MIKROMCP_HTTP_RATE_LIMIT_RPM", 60)
40
105
  },
41
106
  ssh: {
42
- commandTimeoutMs: parseInt(env.MIKROMCP_SSH_COMMAND_TIMEOUT_MS ?? "30000", 10),
43
- maxOutputBytes: parseInt(env.MIKROMCP_SSH_MAX_OUTPUT_BYTES ?? String(512 * 1024), 10)
107
+ commandTimeoutMs: intEnv("MIKROMCP_SSH_COMMAND_TIMEOUT_MS", 3e4),
108
+ maxOutputBytes: intEnv("MIKROMCP_SSH_MAX_OUTPUT_BYTES", 512 * 1024)
44
109
  },
45
110
  retry: {
46
111
  maxRetries: 3,
@@ -52,7 +117,7 @@ function loadAppConfig() {
52
117
  cooldownMs: 3e4
53
118
  },
54
119
  retention: {
55
- snapshotMaxAgeDays: parseInt(env.MIKROMCP_SNAPSHOT_RETENTION_DAYS ?? "30", 10)
120
+ snapshotMaxAgeDays: intEnv("MIKROMCP_SNAPSHOT_RETENTION_DAYS", 30)
56
121
  }
57
122
  };
58
123
  }
@@ -75,53 +140,6 @@ var limit = z.number().int().min(1).max(500).default(100).describe("Max results
75
140
 
76
141
  // src/domain/errors/error-enricher.ts
77
142
  import { ZodError } from "zod";
78
-
79
- // src/domain/errors/error-types.ts
80
- var MikroMCPError = class extends Error {
81
- category;
82
- code;
83
- details;
84
- recoverability;
85
- constructor(options) {
86
- super(options.message);
87
- this.name = "MikroMCPError";
88
- this.category = options.category;
89
- this.code = options.code;
90
- this.details = options.details;
91
- this.recoverability = options.recoverability;
92
- if (options.cause !== void 0) {
93
- this.cause = options.cause;
94
- }
95
- }
96
- /** Serialize to a plain JSON-safe object. */
97
- toJSON() {
98
- return {
99
- name: this.name,
100
- category: this.category,
101
- code: this.code,
102
- message: this.message,
103
- details: this.details,
104
- recoverability: this.recoverability
105
- };
106
- }
107
- /** Convert to the wire-format error data used in DomainResult. */
108
- toMikroMCPErrorData() {
109
- return {
110
- category: this.category,
111
- code: this.code,
112
- message: this.message,
113
- details: this.details,
114
- recoverability: {
115
- retryable: this.recoverability.retryable,
116
- retryAfterMs: this.recoverability.retryAfterMs,
117
- suggestedAction: this.recoverability.suggestedAction,
118
- alternativeTools: this.recoverability.alternativeTools
119
- }
120
- };
121
- }
122
- };
123
-
124
- // src/domain/errors/error-enricher.ts
125
143
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
126
144
  "ECONNREFUSED",
127
145
  "ECONNRESET",
@@ -441,6 +459,41 @@ var systemTools = [getSystemStatusTool];
441
459
  // src/domain/tools/interface-tools.ts
442
460
  import { z as z3 } from "zod";
443
461
 
462
+ // src/adapter/response-parser.ts
463
+ var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
464
+ var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
465
+ function parseRouterOSValue(key2, value) {
466
+ if (key2 === ".id") {
467
+ return value;
468
+ }
469
+ if (value === "true") return true;
470
+ if (value === "false") return false;
471
+ if (DURATION_RE.test(value)) {
472
+ return value;
473
+ }
474
+ if (NUMERIC_RE.test(value)) {
475
+ const num = Number(value);
476
+ const isInteger = !value.includes(".");
477
+ if (isInteger ? Number.isSafeInteger(num) : Number.isFinite(num)) {
478
+ return num;
479
+ }
480
+ }
481
+ return value;
482
+ }
483
+ function isTrue(v) {
484
+ return v === true || v === "true" || v === "yes";
485
+ }
486
+ function parseRecord(raw) {
487
+ const parsed = {};
488
+ for (const [key2, value] of Object.entries(raw)) {
489
+ parsed[key2] = parseRouterOSValue(key2, value);
490
+ }
491
+ return parsed;
492
+ }
493
+ function parseRecords(raw) {
494
+ return raw.map((r) => parseRecord(r));
495
+ }
496
+
444
497
  // src/domain/tools/pagination.ts
445
498
  function paginate(items, offset2, limit2) {
446
499
  const total = items.length;
@@ -528,7 +581,7 @@ var listInterfacesTool = {
528
581
  if (parsed.status !== "all") {
529
582
  interfaces = interfaces.filter((iface) => {
530
583
  const running = iface.running;
531
- const isUp = running === true || running === "true";
584
+ const isUp = isTrue(running);
532
585
  if (parsed.status === "up") return isUp;
533
586
  if (parsed.status === "down") return !isUp;
534
587
  return true;
@@ -537,8 +590,8 @@ var listInterfacesTool = {
537
590
  const { items: paginated, total, hasMore } = paginate(interfaces, parsed.offset, parsed.limit);
538
591
  const results2 = paginated.map((iface) => {
539
592
  const rec = iface;
540
- const isRunning2 = rec.running === true || rec.running === "true";
541
- const isDisabled = rec.disabled === true || rec.disabled === "true";
593
+ const isRunning2 = isTrue(rec.running);
594
+ const isDisabled = isTrue(rec.disabled);
542
595
  const enriched = { status: isDisabled ? "disabled" : isRunning2 ? "up" : "down" };
543
596
  for (const [key2, value] of Object.entries(rec)) {
544
597
  if (parsed.includeCounters || !COUNTER_PROPS.includes(key2)) {
@@ -660,7 +713,7 @@ var manageIpAddressTool = {
660
713
  if (parsed.action === "add") {
661
714
  if (existing) {
662
715
  const rec = existing;
663
- const sameDisabled = rec.disabled === "true" === parsed.disabled;
716
+ const sameDisabled = isTrue(rec.disabled) === parsed.disabled;
664
717
  const sameComment = (rec.comment ?? "") === (comment ?? "");
665
718
  const sameNetwork = !parsed.network || rec.network === parsed.network;
666
719
  if (sameDisabled && sameComment && sameNetwork) {
@@ -887,7 +940,7 @@ var listDhcpLeasesTool = {
887
940
  if (parsed.leaseType !== "all") {
888
941
  leases = leases.filter((lease) => {
889
942
  const rec = lease;
890
- const isDynamic = rec.dynamic === true || rec.dynamic === "true";
943
+ const isDynamic = isTrue(rec.dynamic);
891
944
  if (parsed.leaseType === "dynamic") return isDynamic;
892
945
  return !isDynamic;
893
946
  });
@@ -966,7 +1019,7 @@ var manageDhcpLeaseTool = {
966
1019
  });
967
1020
  }
968
1021
  const existingDynamic = existing.dynamic;
969
- const isAlreadyStatic = existingDynamic === "false" || existingDynamic === false;
1022
+ const isAlreadyStatic = !isTrue(existingDynamic);
970
1023
  if (isAlreadyStatic) {
971
1024
  return {
972
1025
  content: `Lease for ${upperMac} (${existing.address ?? "unknown"}) is already static. No changes made.`,
@@ -1059,13 +1112,13 @@ var listRoutesTool = {
1059
1112
  if (parsed.activeOnly) {
1060
1113
  routes = routes.filter((route) => {
1061
1114
  const rec = route;
1062
- return rec.active === true || rec.active === "true";
1115
+ return isTrue(rec.active);
1063
1116
  });
1064
1117
  }
1065
1118
  if (parsed.staticOnly) {
1066
1119
  routes = routes.filter((route) => {
1067
1120
  const rec = route;
1068
- return rec.dynamic !== true && rec.dynamic !== "true";
1121
+ return !isTrue(rec.dynamic);
1069
1122
  });
1070
1123
  }
1071
1124
  const { items: paginated, total, hasMore } = paginate(routes, parsed.offset, parsed.limit);
@@ -1154,7 +1207,7 @@ var manageRouteTool = {
1154
1207
  if (existing) {
1155
1208
  const rec = existing;
1156
1209
  const existingDistance = rec.distance ?? "1";
1157
- const existingDisabled = rec.disabled === "true" || rec.disabled === true;
1210
+ const existingDisabled = isTrue(rec.disabled);
1158
1211
  const sameDistance = existingDistance === String(parsed.distance);
1159
1212
  const sameDisabled = existingDisabled === parsed.disabled;
1160
1213
  if (sameDistance && sameDisabled) {
@@ -1328,7 +1381,7 @@ var listFirewallRulesTool = {
1328
1381
  if (parsed.disabled !== "all") {
1329
1382
  rules = rules.filter((r) => {
1330
1383
  const rec = r;
1331
- const isDisabled = rec.disabled === true || rec.disabled === "true";
1384
+ const isDisabled = isTrue(rec.disabled);
1332
1385
  return isDisabled === (parsed.disabled === "true");
1333
1386
  });
1334
1387
  }
@@ -1572,7 +1625,7 @@ var manageFirewallRuleTool = {
1572
1625
  });
1573
1626
  }
1574
1627
  const id = existing[".id"];
1575
- const isDisabled = existing.disabled === "true";
1628
+ const isDisabled = isTrue(existing.disabled);
1576
1629
  if (isDisabled === wantDisabled) {
1577
1630
  return {
1578
1631
  content: `Firewall ${parsed.table} rule with comment "${comment}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
@@ -1948,6 +2001,12 @@ var diagnosticTools = [pingTool, tracerouteTool, torchTool, getLogTool];
1948
2001
  // src/domain/tools/system-ops-tools.ts
1949
2002
  import { z as z10 } from "zod";
1950
2003
 
2004
+ // src/util/glob.ts
2005
+ function globMatch(pattern, value) {
2006
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
2007
+ return new RegExp(`^${escaped}$`, "i").test(value);
2008
+ }
2009
+
1951
2010
  // src/domain/tools/command-guard.ts
1952
2011
  var BUILTIN_DENY_PATTERNS = [
1953
2012
  "/system shutdown*",
@@ -1959,14 +2018,20 @@ var BUILTIN_DENY_PATTERNS = [
1959
2018
  "/ip service set*",
1960
2019
  "/certificate*private-key*",
1961
2020
  "/system package uninstall*",
1962
- "/system routerboard upgrade*"
2021
+ "/system routerboard upgrade*",
2022
+ // Block indirection primitives that would otherwise smuggle a denied command
2023
+ // past a segment-wise check.
2024
+ "*:execute*",
2025
+ "*:parse*"
1963
2026
  ];
1964
2027
  var DENIED_TOOL_SUGGESTIONS = {
1965
2028
  "/system reboot*": "reboot"
1966
2029
  };
1967
- function globMatch(pattern, value) {
1968
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1969
- return new RegExp(`^${escaped}$`, "i").test(value);
2030
+ function normalizeCommand(raw) {
2031
+ return raw.replace(/\//g, " ").replace(/\s+/g, " ").trim();
2032
+ }
2033
+ function segments(raw) {
2034
+ return raw.split(/[;\n]/).map((s) => normalizeCommand(s)).filter(Boolean);
1970
2035
  }
1971
2036
  function resolveCommandPolicy(routerConfig, globalAllow, globalDeny) {
1972
2037
  const routerDeny = routerConfig.cmdDeny ?? [];
@@ -1977,8 +2042,10 @@ function resolveCommandPolicy(routerConfig, globalAllow, globalDeny) {
1977
2042
  };
1978
2043
  }
1979
2044
  function checkCommand(command, policy) {
2045
+ const normSegs = segments(command);
1980
2046
  for (const pattern of policy.deny) {
1981
- if (globMatch(pattern, command)) {
2047
+ const normPattern = normalizeCommand(pattern);
2048
+ if (normSegs.some((seg) => globMatch(normPattern, seg))) {
1982
2049
  const suggestedTool = DENIED_TOOL_SUGGESTIONS[pattern];
1983
2050
  throw new MikroMCPError({
1984
2051
  category: "VALIDATION" /* VALIDATION */,
@@ -1993,17 +2060,23 @@ function checkCommand(command, policy) {
1993
2060
  });
1994
2061
  }
1995
2062
  }
1996
- if (policy.allow.length > 0 && !policy.allow.some((p) => globMatch(p, command))) {
1997
- throw new MikroMCPError({
1998
- category: "VALIDATION" /* VALIDATION */,
1999
- code: "COMMAND_NOT_ALLOWED",
2000
- message: "Command not in allow list",
2001
- details: { command, allowPatterns: policy.allow },
2002
- recoverability: {
2003
- retryable: false,
2004
- suggestedAction: "The command does not match any pattern in the configured allow list."
2005
- }
2006
- });
2063
+ if (policy.allow.length > 0) {
2064
+ const normAllow = policy.allow.map((p) => normalizeCommand(p));
2065
+ const everySegmentAllowed = normSegs.every(
2066
+ (seg) => normAllow.some((p) => globMatch(p, seg))
2067
+ );
2068
+ if (!everySegmentAllowed) {
2069
+ throw new MikroMCPError({
2070
+ category: "VALIDATION" /* VALIDATION */,
2071
+ code: "COMMAND_NOT_ALLOWED",
2072
+ message: "Command not in allow list",
2073
+ details: { command, allowPatterns: policy.allow },
2074
+ recoverability: {
2075
+ retryable: false,
2076
+ suggestedAction: "The command does not match any pattern in the configured allow list."
2077
+ }
2078
+ });
2079
+ }
2007
2080
  }
2008
2081
  }
2009
2082
 
@@ -2599,7 +2672,7 @@ var manageWifiTool = {
2599
2672
  inputSchema: manageWifiInputSchema,
2600
2673
  annotations: {
2601
2674
  readOnlyHint: false,
2602
- destructiveHint: false,
2675
+ destructiveHint: true,
2603
2676
  idempotentHint: true,
2604
2677
  openWorldHint: false
2605
2678
  },
@@ -2890,7 +2963,7 @@ var manageWgIfaceTool = {
2890
2963
  inputSchema: manageWgIfaceInputSchema,
2891
2964
  annotations: {
2892
2965
  readOnlyHint: false,
2893
- destructiveHint: false,
2966
+ destructiveHint: true,
2894
2967
  idempotentHint: true,
2895
2968
  openWorldHint: false
2896
2969
  },
@@ -3267,7 +3340,7 @@ var manageDnsSettingsTool = {
3267
3340
  inputSchema: manageDnsSettingsInputSchema,
3268
3341
  annotations: {
3269
3342
  readOnlyHint: false,
3270
- destructiveHint: false,
3343
+ destructiveHint: true,
3271
3344
  idempotentHint: true,
3272
3345
  openWorldHint: false
3273
3346
  },
@@ -3379,7 +3452,7 @@ var listMangleRulesTool = {
3379
3452
  if (parsed.disabled !== void 0) {
3380
3453
  rules = rules.filter((r) => {
3381
3454
  const rec = r;
3382
- const isDisabled = rec.disabled === true || rec.disabled === "true";
3455
+ const isDisabled = isTrue(rec.disabled);
3383
3456
  return isDisabled === parsed.disabled;
3384
3457
  });
3385
3458
  }
@@ -3435,7 +3508,7 @@ var manageMangleRuleTool = {
3435
3508
  inputSchema: manageMangleRuleInputSchema,
3436
3509
  annotations: {
3437
3510
  readOnlyHint: false,
3438
- destructiveHint: false,
3511
+ destructiveHint: true,
3439
3512
  idempotentHint: true,
3440
3513
  openWorldHint: false
3441
3514
  },
@@ -3576,7 +3649,7 @@ var manageMangleRuleTool = {
3576
3649
  });
3577
3650
  }
3578
3651
  const id = existing[".id"];
3579
- const isDisabled = existing.disabled === "true";
3652
+ const isDisabled = isTrue(existing.disabled);
3580
3653
  if (isDisabled === wantDisabled) {
3581
3654
  return {
3582
3655
  content: `Mangle rule with comment "${parsed.comment}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
@@ -3681,7 +3754,7 @@ var manageAddressListTool = {
3681
3754
  inputSchema: manageAddressListInputSchema,
3682
3755
  annotations: {
3683
3756
  readOnlyHint: false,
3684
- destructiveHint: false,
3757
+ destructiveHint: true,
3685
3758
  idempotentHint: true,
3686
3759
  openWorldHint: false
3687
3760
  },
@@ -3806,7 +3879,7 @@ var listRoutingRulesTool = {
3806
3879
  if (parsed.disabled !== void 0) {
3807
3880
  rules = rules.filter((r) => {
3808
3881
  const rec = r;
3809
- const isDisabled = rec.disabled === true || rec.disabled === "true";
3882
+ const isDisabled = isTrue(rec.disabled);
3810
3883
  return isDisabled === parsed.disabled;
3811
3884
  });
3812
3885
  }
@@ -3851,7 +3924,7 @@ var manageRoutingRuleTool = {
3851
3924
  inputSchema: manageRoutingRuleInputSchema,
3852
3925
  annotations: {
3853
3926
  readOnlyHint: false,
3854
- destructiveHint: false,
3927
+ destructiveHint: true,
3855
3928
  idempotentHint: true,
3856
3929
  openWorldHint: false
3857
3930
  },
@@ -3957,7 +4030,7 @@ var manageRoutingRuleTool = {
3957
4030
  });
3958
4031
  }
3959
4032
  const id = existing[".id"];
3960
- const isDisabled = existing.disabled === "true";
4033
+ const isDisabled = isTrue(existing.disabled);
3961
4034
  if (isDisabled === wantDisabled) {
3962
4035
  return {
3963
4036
  content: `Routing rule is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
@@ -4048,7 +4121,7 @@ var manageRoutingTableTool = {
4048
4121
  inputSchema: manageRoutingTableInputSchema,
4049
4122
  annotations: {
4050
4123
  readOnlyHint: false,
4051
- destructiveHint: false,
4124
+ destructiveHint: true,
4052
4125
  idempotentHint: true,
4053
4126
  openWorldHint: false
4054
4127
  },
@@ -4286,7 +4359,7 @@ var manageScriptTool = {
4286
4359
  inputSchema: manageScriptInputSchema,
4287
4360
  annotations: {
4288
4361
  readOnlyHint: false,
4289
- destructiveHint: false,
4362
+ destructiveHint: true,
4290
4363
  idempotentHint: true,
4291
4364
  openWorldHint: false
4292
4365
  },
@@ -4431,7 +4504,7 @@ var runScriptTool = {
4431
4504
  inputSchema: runScriptInputSchema,
4432
4505
  annotations: {
4433
4506
  readOnlyHint: false,
4434
- destructiveHint: false,
4507
+ destructiveHint: true,
4435
4508
  idempotentHint: false,
4436
4509
  openWorldHint: false
4437
4510
  },
@@ -4678,7 +4751,7 @@ var manageScheduledJobTool = {
4678
4751
  });
4679
4752
  }
4680
4753
  const id = existing[".id"];
4681
- const isDisabled = existing.disabled === "true";
4754
+ const isDisabled = isTrue(existing.disabled);
4682
4755
  if (isDisabled === wantDisabled) {
4683
4756
  return {
4684
4757
  content: `Scheduled job "${parsed.name}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
@@ -4782,7 +4855,7 @@ var managePackageTool = {
4782
4855
  inputSchema: managePackageInputSchema,
4783
4856
  annotations: {
4784
4857
  readOnlyHint: false,
4785
- destructiveHint: false,
4858
+ destructiveHint: true,
4786
4859
  idempotentHint: true,
4787
4860
  openWorldHint: false
4788
4861
  },
@@ -4813,7 +4886,7 @@ var managePackageTool = {
4813
4886
  });
4814
4887
  }
4815
4888
  const wantDisabled = parsed.action === "disable";
4816
- const isDisabled = pkg.disabled === "true";
4889
+ const isDisabled = isTrue(pkg.disabled);
4817
4890
  const id = pkg[".id"];
4818
4891
  if (isDisabled === wantDisabled) {
4819
4892
  return {
@@ -4849,6 +4922,7 @@ var packagesTools = [listPackagesTool, managePackageTool];
4849
4922
  import { z as z22 } from "zod";
4850
4923
  var log20 = createLogger("files-tools");
4851
4924
  var FILE_PATH = "file";
4925
+ var CONTENT_CAP = 65536;
4852
4926
  var listFilesInputSchema = z22.object({
4853
4927
  routerId,
4854
4928
  name: z22.string().optional().describe("Filter by file name (substring match)"),
@@ -4937,11 +5011,20 @@ var getFileContentTool = {
4937
5011
  }
4938
5012
  const id = file[".id"];
4939
5013
  const full = await context.routerClient.getOne(FILE_PATH, id);
4940
- const contents = full.contents ?? "";
5014
+ const rawContents = full.contents ?? "";
5015
+ const truncated = rawContents.length > CONTENT_CAP;
5016
+ const contents = truncated ? `${rawContents.slice(0, CONTENT_CAP)}
5017
+ [TRUNCATED at ${CONTENT_CAP} chars \u2014 file is ${rawContents.length} chars]` : rawContents;
4941
5018
  return {
4942
5019
  content: `Contents of "${parsed.name}" on ${context.routerId}:
4943
5020
  ${contents}`,
4944
- structuredContent: { routerId: context.routerId, name: parsed.name, contents }
5021
+ structuredContent: {
5022
+ routerId: context.routerId,
5023
+ name: parsed.name,
5024
+ contents,
5025
+ truncated,
5026
+ totalLength: rawContents.length
5027
+ }
4945
5028
  };
4946
5029
  } catch (err) {
4947
5030
  throw toolError(err, context, "get_file_content");
@@ -4957,7 +5040,7 @@ var uploadFileInputSchema = z22.object({
4957
5040
  var uploadFileTool = {
4958
5041
  name: "upload_file",
4959
5042
  title: "Upload File",
4960
- description: "Upload a text file to a router via FTP using the router's credentials, overwriting any existing file of the same name. Requires FTP permission on the router user (see config/routers.example.yaml). Dry-run tests FTP connectivity only.",
5043
+ description: "Upload a text file to a router, overwriting any existing file of the same name. Prefers SFTP (encrypted, over SSH) and falls back to plaintext FTP if SFTP is unavailable. Requires SSH (or FTP) access for the router user. Dry-run tests connectivity only.",
4961
5044
  inputSchema: uploadFileInputSchema,
4962
5045
  annotations: {
4963
5046
  readOnlyHint: false,
@@ -4969,30 +5052,50 @@ var uploadFileTool = {
4969
5052
  async handler(params, context) {
4970
5053
  const parsed = uploadFileInputSchema.parse(params);
4971
5054
  log20.info({ routerId: context.routerId, name: parsed.name }, "Uploading file");
5055
+ const ftpPlaintextNote = " (via FTP, plaintext \u2014 enable SSH on the router for encrypted transfer)";
4972
5056
  try {
4973
5057
  if (parsed.dryRun) {
4974
- await context.ftpClient.connect();
5058
+ try {
5059
+ await context.sftpClient.upload(`${parsed.name}.mikromcp-probe`, "");
5060
+ return {
5061
+ content: `Dry run: SFTP connectivity to ${context.routerId} verified. Would upload "${parsed.name}".`,
5062
+ structuredContent: { action: "dry_run", transport: "sftp", name: parsed.name, routerId: context.routerId }
5063
+ };
5064
+ } catch (sftpErr) {
5065
+ log20.warn({ err: sftpErr, routerId: context.routerId }, "SFTP dry-run probe failed \u2014 falling back to FTP");
5066
+ await context.ftpClient.connect();
5067
+ return {
5068
+ content: `Dry run: FTP connectivity to ${context.routerId} verified${ftpPlaintextNote}. Would upload "${parsed.name}".`,
5069
+ structuredContent: { action: "dry_run", transport: "ftp", name: parsed.name, routerId: context.routerId }
5070
+ };
5071
+ }
5072
+ }
5073
+ try {
5074
+ await context.sftpClient.upload(parsed.name, parsed.content);
5075
+ log20.info({ name: parsed.name }, "File uploaded via SFTP");
4975
5076
  return {
4976
- content: `Dry run: FTP connectivity to ${context.routerId} verified. Would upload "${parsed.name}".`,
4977
- structuredContent: { action: "dry_run", name: parsed.name, routerId: context.routerId }
5077
+ content: `Uploaded "${parsed.name}" to ${context.routerId} via SFTP.`,
5078
+ structuredContent: { action: "uploaded", transport: "sftp", name: parsed.name, routerId: context.routerId }
5079
+ };
5080
+ } catch (sftpErr) {
5081
+ log20.warn({ err: sftpErr, routerId: context.routerId }, "SFTP upload failed \u2014 falling back to FTP");
5082
+ await context.ftpClient.upload(parsed.name, parsed.content);
5083
+ log20.info({ name: parsed.name }, "File uploaded via FTP");
5084
+ return {
5085
+ content: `Uploaded "${parsed.name}" to ${context.routerId}${ftpPlaintextNote}.`,
5086
+ structuredContent: { action: "uploaded", transport: "ftp", name: parsed.name, routerId: context.routerId }
4978
5087
  };
4979
5088
  }
4980
- await context.ftpClient.upload(parsed.name, parsed.content);
4981
- log20.info({ name: parsed.name }, "File uploaded via FTP");
4982
- return {
4983
- content: `Uploaded "${parsed.name}" to ${context.routerId}.`,
4984
- structuredContent: { action: "uploaded", name: parsed.name, routerId: context.routerId }
4985
- };
4986
5089
  } catch (err) {
4987
5090
  if (err instanceof Error && err.code === "ECONNREFUSED") {
4988
5091
  throw new MikroMCPError({
4989
5092
  category: "CONFIGURATION" /* CONFIGURATION */,
4990
- code: "FTP_SERVICE_UNAVAILABLE",
4991
- message: `FTP service is not running on ${context.routerConfig.host}:21. Enable it on the router and ensure the user has the 'ftp' policy.`,
4992
- details: { host: context.routerConfig.host, port: 21, routerId: context.routerId },
5093
+ code: "FILE_TRANSFER_UNAVAILABLE",
5094
+ message: `Neither SFTP (SSH) nor FTP is reachable on ${context.routerConfig.host}. Enable SSH (recommended) or FTP on the router.`,
5095
+ details: { host: context.routerConfig.host, routerId: context.routerId },
4993
5096
  recoverability: {
4994
5097
  retryable: false,
4995
- suggestedAction: "Run on the router: /ip service enable ftp \u2014 then add 'ftp' to the user group policy: /user group set <group> policy=...,ftp,..."
5098
+ suggestedAction: "Enable SSH: /ip service enable ssh and ensure the user has the 'ssh'/'ftp' policy. FTP fallback needs: /ip service enable ftp plus the 'ftp' policy."
4996
5099
  }
4997
5100
  });
4998
5101
  }
@@ -5127,7 +5230,7 @@ var manageContainerTool = {
5127
5230
  inputSchema: manageContainerInputSchema,
5128
5231
  annotations: {
5129
5232
  readOnlyHint: false,
5130
- destructiveHint: false,
5233
+ destructiveHint: true,
5131
5234
  idempotentHint: true,
5132
5235
  openWorldHint: false
5133
5236
  },
@@ -5301,28 +5404,49 @@ import { readFileSync } from "fs";
5301
5404
  import { mkdir, writeFile, readFile } from "fs/promises";
5302
5405
  import { join as join2 } from "path";
5303
5406
  import { nanoid } from "nanoid";
5304
- function pathToSlug(rosPath) {
5305
- return rosPath.replace(/\//g, "-");
5306
- }
5307
- function timestampPrefix() {
5308
- return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
5309
- }
5310
- async function takeSnapshot(client, routerId2, path, snapshotDir) {
5311
- const records = await client.get(path, {});
5312
- const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
5313
- const dir = join2(snapshotDir, routerId2);
5314
- const filePath = join2(dir, `${id}.json`);
5315
- const ts = (/* @__PURE__ */ new Date()).toISOString();
5316
- await mkdir(dir, { recursive: true });
5317
- const stored = { id, routerId: routerId2, path, ts, records };
5318
- await writeFile(filePath, JSON.stringify(stored, null, 2));
5319
- return { id, routerId: routerId2, path, ts, filePath, recordCount: records.length };
5320
- }
5321
- async function loadSnapshot(filePath) {
5322
- return JSON.parse(await readFile(filePath, "utf-8"));
5323
- }
5324
5407
 
5325
5408
  // src/domain/snapshot/diff-engine.ts
5409
+ var RUNTIME_FIELDS = /* @__PURE__ */ new Set([
5410
+ "bytes",
5411
+ "packets",
5412
+ "dynamic",
5413
+ "invalid",
5414
+ "dead",
5415
+ "expired",
5416
+ "active",
5417
+ "running",
5418
+ "about",
5419
+ ".about",
5420
+ ".nextid",
5421
+ "creation-time",
5422
+ "last-logged-in",
5423
+ "last-link-up-time",
5424
+ "last-link-down-time",
5425
+ "link-downs",
5426
+ "rx-byte",
5427
+ "tx-byte",
5428
+ "rx-packet",
5429
+ "tx-packet",
5430
+ "uptime"
5431
+ ]);
5432
+ var ORDER_SENSITIVE_PATHS = /* @__PURE__ */ new Set([
5433
+ "ip/firewall/filter",
5434
+ "ip/firewall/nat",
5435
+ "ip/firewall/mangle",
5436
+ "routing/rule"
5437
+ ]);
5438
+ function normalizeForDiff(records) {
5439
+ const out = [];
5440
+ for (const r of records) {
5441
+ if (isTrue(r.dynamic)) continue;
5442
+ const clean = {};
5443
+ for (const [k, v] of Object.entries(r)) {
5444
+ if (!RUNTIME_FIELDS.has(k)) clean[k] = v;
5445
+ }
5446
+ out.push(clean);
5447
+ }
5448
+ return out;
5449
+ }
5326
5450
  var SEMANTIC_KEYS = {
5327
5451
  "ip/route": ["dst-address", "gateway", "routing-table"],
5328
5452
  "ip/firewall/filter": ["comment"],
@@ -5351,9 +5475,9 @@ var SEMANTIC_KEYS = {
5351
5475
  "user": ["name"]
5352
5476
  };
5353
5477
  function normalizeValue(v) {
5354
- if (v === "true" || v === "yes") return "true";
5355
- if (v === "false" || v === "no") return "false";
5356
- return v;
5478
+ if (v === true || v === "true" || v === "yes") return "true";
5479
+ if (v === false || v === "false" || v === "no") return "false";
5480
+ return String(v ?? "");
5357
5481
  }
5358
5482
  function semanticKey(record, keys) {
5359
5483
  return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
@@ -5367,17 +5491,45 @@ function recordsAreEqual(a, b) {
5367
5491
  if (keysA.length !== keysB.length) return false;
5368
5492
  return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
5369
5493
  }
5370
- function computeRestorePlan(path, before, current) {
5494
+ function hasDuplicateKeys(records, keys) {
5495
+ const seen = /* @__PURE__ */ new Set();
5496
+ for (const r of records) {
5497
+ const k = semanticKey(r, keys);
5498
+ if (seen.has(k)) return true;
5499
+ seen.add(k);
5500
+ }
5501
+ return false;
5502
+ }
5503
+ function applyUserRestriction(path, plan) {
5504
+ if (path === "user" && plan.toCreate.length > 0) {
5505
+ plan.warnings.push(
5506
+ `Refusing to recreate ${plan.toCreate.length} deleted user(s): passwords are not stored in snapshots and recreation would produce password-less logins. Recreate manually with manage_user.`
5507
+ );
5508
+ plan.toCreate = [];
5509
+ }
5510
+ return plan;
5511
+ }
5512
+ function computeRestorePlan(path, beforeRaw, currentRaw) {
5513
+ const before = normalizeForDiff(beforeRaw);
5514
+ const current = normalizeForDiff(currentRaw);
5515
+ const warnings = [];
5516
+ if (ORDER_SENSITIVE_PATHS.has(path)) {
5517
+ warnings.push(
5518
+ `Restored entries for ${path} are re-created at the end of the list \u2014 rule ORDER is not restored. Review ordering manually after rollback.`
5519
+ );
5520
+ }
5371
5521
  const keys = SEMANTIC_KEYS[path];
5372
- if (!keys) {
5522
+ const canUseKeys = keys !== void 0 && !hasDuplicateKeys(before, keys) && !hasDuplicateKeys(current, keys);
5523
+ if (!canUseKeys) {
5373
5524
  const beforeSigs = new Set(before.map(recordSignature));
5374
5525
  const currentSigs = new Set(current.map(recordSignature));
5375
- return {
5526
+ return applyUserRestriction(path, {
5376
5527
  path,
5377
5528
  toCreate: before.filter((r) => !currentSigs.has(recordSignature(r))),
5378
5529
  toRemove: current.filter((r) => !beforeSigs.has(recordSignature(r))).map((r) => r[".id"]),
5379
- toUpdate: []
5380
- };
5530
+ toUpdate: [],
5531
+ warnings
5532
+ });
5381
5533
  }
5382
5534
  const currentByKey = new Map(current.map((r) => [semanticKey(r, keys), r]));
5383
5535
  const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
@@ -5393,7 +5545,7 @@ function computeRestorePlan(path, before, current) {
5393
5545
  }
5394
5546
  }
5395
5547
  const toRemove = current.filter((r) => !beforeByKey.has(semanticKey(r, keys))).map((r) => r[".id"]);
5396
- return { path, toCreate, toRemove, toUpdate };
5548
+ return applyUserRestriction(path, { path, toCreate, toRemove, toUpdate, warnings });
5397
5549
  }
5398
5550
  async function applyRestorePlan(plan, client) {
5399
5551
  for (const id of plan.toRemove) {
@@ -5408,6 +5560,28 @@ async function applyRestorePlan(plan, client) {
5408
5560
  }
5409
5561
  }
5410
5562
 
5563
+ // src/domain/snapshot/snapshot-engine.ts
5564
+ function pathToSlug(rosPath) {
5565
+ return rosPath.replace(/\//g, "-");
5566
+ }
5567
+ function timestampPrefix() {
5568
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
5569
+ }
5570
+ async function takeSnapshot(client, routerId2, path, snapshotDir) {
5571
+ const records = normalizeForDiff(await client.get(path, {}));
5572
+ const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
5573
+ const dir = join2(snapshotDir, routerId2);
5574
+ const filePath = join2(dir, `${id}.json`);
5575
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
5576
+ await mkdir(dir, { recursive: true });
5577
+ const stored = { id, routerId: routerId2, path, ts, records };
5578
+ await writeFile(filePath, JSON.stringify(stored, null, 2));
5579
+ return { id, routerId: routerId2, path, ts, filePath, recordCount: records.length };
5580
+ }
5581
+ async function loadSnapshot(filePath) {
5582
+ return JSON.parse(await readFile(filePath, "utf-8"));
5583
+ }
5584
+
5411
5585
  // src/domain/snapshot/write-journal.ts
5412
5586
  import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
5413
5587
  import { dirname } from "path";
@@ -5715,18 +5889,22 @@ function createChangeManagementTools(baseTools2) {
5715
5889
  (sum, p) => sum + p.toCreate.length + p.toRemove.length + p.toUpdate.length,
5716
5890
  0
5717
5891
  );
5892
+ const warnings = restorePlans.flatMap((p) => p.warnings);
5893
+ const warningSuffix = warnings.length > 0 ? `
5894
+ Warnings:
5895
+ ${warnings.map((w) => ` - ${w}`).join("\n")}` : "";
5718
5896
  if (parsed.dryRun) {
5719
5897
  return {
5720
- content: `Rollback dry run for journal entry "${parsed.journalId}": ${totalOps} operation(s) would be applied across ${restorePlans.length} path(s).`,
5721
- structuredContent: { action: "dry_run", journalId: parsed.journalId, restorePlans }
5898
+ content: `Rollback dry run for journal entry "${parsed.journalId}": ${totalOps} operation(s) would be applied across ${restorePlans.length} path(s).${warningSuffix}`,
5899
+ structuredContent: { action: "dry_run", journalId: parsed.journalId, restorePlans, warnings }
5722
5900
  };
5723
5901
  }
5724
5902
  for (const plan of restorePlans) {
5725
5903
  await applyRestorePlan(plan, context.routerClient);
5726
5904
  }
5727
5905
  return {
5728
- content: `Rolled back journal entry "${parsed.journalId}": ${totalOps} operation(s) applied across ${restorePlans.length} path(s).`,
5729
- structuredContent: { action: "rolled_back", journalId: parsed.journalId, restorePlans }
5906
+ content: `Rolled back journal entry "${parsed.journalId}": ${totalOps} operation(s) applied across ${restorePlans.length} path(s).${warningSuffix}`,
5907
+ structuredContent: { action: "rolled_back", journalId: parsed.journalId, restorePlans, warnings }
5730
5908
  };
5731
5909
  }
5732
5910
  };
@@ -6019,7 +6197,7 @@ var manageIpsecPolicyTool = {
6019
6197
  inputSchema: manageIpsecPolicyInputSchema,
6020
6198
  annotations: {
6021
6199
  readOnlyHint: false,
6022
- destructiveHint: false,
6200
+ destructiveHint: true,
6023
6201
  idempotentHint: true,
6024
6202
  openWorldHint: false
6025
6203
  },
@@ -6401,7 +6579,7 @@ var manageUserTool = {
6401
6579
  inputSchema: manageUserInputSchema,
6402
6580
  annotations: {
6403
6581
  readOnlyHint: false,
6404
- destructiveHint: false,
6582
+ destructiveHint: true,
6405
6583
  idempotentHint: true,
6406
6584
  openWorldHint: false
6407
6585
  },
@@ -6681,7 +6859,7 @@ var manageUserGroupTool = {
6681
6859
  inputSchema: manageUserGroupInputSchema,
6682
6860
  annotations: {
6683
6861
  readOnlyHint: false,
6684
- destructiveHint: false,
6862
+ destructiveHint: true,
6685
6863
  idempotentHint: true,
6686
6864
  openWorldHint: false
6687
6865
  },
@@ -6904,7 +7082,7 @@ var manageDhcpServerTool = {
6904
7082
  inputSchema: manageDhcpServerInputSchema,
6905
7083
  annotations: {
6906
7084
  readOnlyHint: false,
6907
- destructiveHint: false,
7085
+ destructiveHint: true,
6908
7086
  idempotentHint: true,
6909
7087
  openWorldHint: false
6910
7088
  },
@@ -7516,7 +7694,7 @@ var manageVrrpInstanceTool = {
7516
7694
  inputSchema: manageVrrpInstanceInputSchema,
7517
7695
  annotations: {
7518
7696
  readOnlyHint: false,
7519
- destructiveHint: false,
7697
+ destructiveHint: true,
7520
7698
  idempotentHint: true,
7521
7699
  openWorldHint: false
7522
7700
  },
@@ -8551,7 +8729,7 @@ var listIpServicesTool = {
8551
8729
  }
8552
8730
  if (parsed.enabled !== void 0) {
8553
8731
  filtered = filtered.filter((s) => {
8554
- const isDisabled = s.disabled === true || s.disabled === "true";
8732
+ const isDisabled = isTrue(s.disabled);
8555
8733
  return parsed.enabled ? !isDisabled : isDisabled;
8556
8734
  });
8557
8735
  }
@@ -8616,7 +8794,7 @@ var manageIpServiceTool = {
8616
8794
  }
8617
8795
  });
8618
8796
  }
8619
- const isCurrentlyDisabled = existing.disabled === "true" || existing.disabled === true;
8797
+ const isCurrentlyDisabled = isTrue(existing.disabled);
8620
8798
  const wouldBeDisabled = parsed.action === "disable";
8621
8799
  if (isCurrentlyDisabled === wouldBeDisabled) {
8622
8800
  const alreadyState = parsed.action === "disable" ? "disabled" : "enabled";
@@ -8667,7 +8845,7 @@ var listPppoeClientsInputSchema = z37.object({
8667
8845
  offset
8668
8846
  }).strict();
8669
8847
  function isRunning(record) {
8670
- return record.running === true || record.running === "true";
8848
+ return isTrue(record.running);
8671
8849
  }
8672
8850
  var listPppoeClientsTool = {
8673
8851
  name: "list_pppoe_clients",
@@ -9689,8 +9867,9 @@ var exportConfigTool = {
9689
9867
  title: "Export Config",
9690
9868
  description: "Export the router configuration as a RouterOS script. When no file is specified, returns the script text inline. When a file is specified, saves it as <file>.rsc on the router's filesystem. Supports compact mode to show only non-default values.",
9691
9869
  inputSchema: exportConfigInputSchema,
9870
+ // Not read-only: with a `file` argument it writes a file on the router.
9692
9871
  annotations: {
9693
- readOnlyHint: true,
9872
+ readOnlyHint: false,
9694
9873
  destructiveHint: false,
9695
9874
  idempotentHint: true,
9696
9875
  openWorldHint: false
@@ -10131,14 +10310,17 @@ var bandwidthTestInputSchema = z43.object({
10131
10310
  address: z43.string().describe("Remote host running RouterOS btest server"),
10132
10311
  protocol: z43.enum(["tcp", "udp"]).default("tcp").describe("Test protocol"),
10133
10312
  direction: z43.enum(["send", "receive", "both"]).default("both").describe("Test direction"),
10134
- duration: z43.number().int().min(1).max(30).default(5).describe("Test duration in seconds (max 30)")
10313
+ duration: z43.number().int().min(1).max(20).default(5).describe("Test duration in seconds (max 20)")
10135
10314
  }).strict();
10136
10315
  var bandwidthTestTool = {
10137
10316
  name: "bandwidth_test",
10138
10317
  title: "Bandwidth Test",
10139
- description: "Run a RouterOS bandwidth test from the router to a remote host running a RouterOS btest server. Returns TX and RX throughput in Mbps. Duration capped at 30 seconds.",
10318
+ description: "Run a RouterOS bandwidth test from the router to a remote host running a RouterOS btest server. Returns TX and RX throughput in Mbps. Duration capped at 20 seconds. Saturates the link \u2014 not auto-retried.",
10140
10319
  inputSchema: bandwidthTestInputSchema,
10141
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
10320
+ // Reaches an external host and saturates the link; a silent retry would
10321
+ // double the load, so opt out of auto-retry.
10322
+ retryable: false,
10323
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
10142
10324
  async handler(params, context) {
10143
10325
  const parsed = bandwidthTestInputSchema.parse(params);
10144
10326
  log43.info({ routerId: context.routerId, address: parsed.address }, "Running bandwidth test");
@@ -10180,9 +10362,11 @@ var fetchUrlInputSchema = z43.object({
10180
10362
  var fetchUrlTool = {
10181
10363
  name: "fetch_url",
10182
10364
  title: "Fetch URL",
10183
- description: "Send an HTTP/HTTPS request from the router using /tool/fetch. Response body is returned inline (capped at 64 KB with [TRUNCATED] marker). Use outputFile to save to router filesystem instead.",
10365
+ description: "Send an HTTP/HTTPS request from the router using /tool/fetch. Response body is returned inline (capped at 64 KB with [TRUNCATED] marker). Use outputFile to save to router filesystem instead. Not read-only: POSTs have side effects and outputFile writes to the router.",
10184
10366
  inputSchema: fetchUrlInputSchema,
10185
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
10367
+ // Not read-only: a POST has external side effects and outputFile writes a
10368
+ // file on the router. openWorld: reaches arbitrary external hosts.
10369
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
10186
10370
  async handler(params, context) {
10187
10371
  const parsed = fetchUrlInputSchema.parse(params);
10188
10372
  log43.info({ routerId: context.routerId, url: parsed.url, method: parsed.method }, "Fetching URL");
@@ -10235,6 +10419,14 @@ var listConnectionsTool = {
10235
10419
  log43.info({ routerId: context.routerId }, "Listing connections");
10236
10420
  try {
10237
10421
  const all = await context.routerClient.get("ip/firewall/connection", {
10422
+ proplist: [
10423
+ "protocol",
10424
+ "src-address",
10425
+ "dst-address",
10426
+ "tcp-state",
10427
+ "connection-mark",
10428
+ "timeout"
10429
+ ],
10238
10430
  limit: void 0,
10239
10431
  offset: void 0
10240
10432
  });
@@ -10695,7 +10887,7 @@ var manageInterfaceListTool = {
10695
10887
  title: "Manage Interface List",
10696
10888
  description: "Add or remove an interface list. Idempotent by name. Removing a list that has members is blocked by RouterOS \u2014 the error is surfaced as-is.",
10697
10889
  inputSchema: manageInterfaceListInputSchema,
10698
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10890
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
10699
10891
  async handler(params, context) {
10700
10892
  const parsed = manageInterfaceListInputSchema.parse(params);
10701
10893
  log45.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing interface list");
@@ -10769,7 +10961,7 @@ var manageInterfaceListMemberTool = {
10769
10961
  title: "Manage Interface List Member",
10770
10962
  description: "Add or remove an interface from an interface list. Idempotent by list+interface composite key. add returns already_exists if the membership exists. remove returns not_found gracefully.",
10771
10963
  inputSchema: manageInterfaceListMemberInputSchema,
10772
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10964
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
10773
10965
  async handler(params, context) {
10774
10966
  const parsed = manageInterfaceListMemberInputSchema.parse(params);
10775
10967
  log45.info(
@@ -10851,9 +11043,7 @@ import { z as z46 } from "zod";
10851
11043
 
10852
11044
  // src/middleware/authz.ts
10853
11045
  function matchesPattern(name, pattern) {
10854
- if (!pattern.includes("*")) return name === pattern;
10855
- const prefix = pattern.slice(0, pattern.indexOf("*"));
10856
- return name.startsWith(prefix);
11046
+ return globMatch(pattern, name);
10857
11047
  }
10858
11048
  function checkAuthz(identity, toolName, routerId2) {
10859
11049
  if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
@@ -10949,11 +11139,16 @@ var SshClient = class {
10949
11139
  const maxOutputBytes = this.maxOutputBytes;
10950
11140
  return new Promise((resolve, reject) => {
10951
11141
  const conn = new Client();
10952
- let output = "";
11142
+ const chunks = [];
10953
11143
  let outputSize = 0;
10954
11144
  let truncated = false;
11145
+ let timedOut = false;
10955
11146
  let settled = false;
10956
11147
  let timer;
11148
+ const decodeOutput = () => {
11149
+ const text = Buffer.concat(chunks).toString("utf-8");
11150
+ return truncated ? text + "\n[OUTPUT TRUNCATED]" : text;
11151
+ };
10957
11152
  const cleanup = (err) => {
10958
11153
  if (settled) return;
10959
11154
  settled = true;
@@ -10962,7 +11157,7 @@ var SshClient = class {
10962
11157
  if (err) {
10963
11158
  reject(err);
10964
11159
  } else {
10965
- resolve(truncated ? output + "\n[OUTPUT TRUNCATED]" : output);
11160
+ resolve(decodeOutput());
10966
11161
  }
10967
11162
  };
10968
11163
  conn.on("ready", () => {
@@ -10972,24 +11167,35 @@ var SshClient = class {
10972
11167
  return;
10973
11168
  }
10974
11169
  timer = setTimeout(() => {
11170
+ timedOut = true;
10975
11171
  stream.close();
10976
11172
  }, timeoutMs);
10977
11173
  const appendOutput = (data) => {
10978
11174
  if (truncated) return;
10979
11175
  const remaining = maxOutputBytes - outputSize;
10980
11176
  if (data.length >= remaining) {
10981
- output += data.slice(0, remaining).toString();
11177
+ chunks.push(data.subarray(0, remaining));
10982
11178
  outputSize = maxOutputBytes;
10983
11179
  truncated = true;
10984
11180
  stream.close();
10985
11181
  return;
10986
11182
  }
10987
- output += data.toString();
11183
+ chunks.push(data);
10988
11184
  outputSize += data.length;
10989
11185
  };
10990
11186
  stream.on("data", appendOutput);
10991
11187
  stream.stderr.on("data", appendOutput);
10992
- stream.on("close", () => cleanup());
11188
+ stream.on("close", () => {
11189
+ if (timedOut) {
11190
+ const err2 = new Error(
11191
+ `SSH command timed out after ${timeoutMs}ms (${outputSize} bytes of partial output discarded)`
11192
+ );
11193
+ err2.code = "ETIMEDOUT";
11194
+ cleanup(err2);
11195
+ } else {
11196
+ cleanup();
11197
+ }
11198
+ });
10993
11199
  });
10994
11200
  });
10995
11201
  conn.on("error", (err) => cleanup(err));
@@ -11059,6 +11265,58 @@ var FtpClient = class {
11059
11265
  }
11060
11266
  };
11061
11267
 
11268
+ // src/adapter/sftp-client.ts
11269
+ import { createHash as createHash2 } from "crypto";
11270
+ import { Client as Client3 } from "ssh2";
11271
+ var SftpClient = class {
11272
+ constructor(config, credentials) {
11273
+ this.config = config;
11274
+ this.credentials = credentials;
11275
+ }
11276
+ config;
11277
+ credentials;
11278
+ async upload(remoteName, content) {
11279
+ return new Promise((resolve, reject) => {
11280
+ const conn = new Client3();
11281
+ let settled = false;
11282
+ const cleanup = (err) => {
11283
+ if (settled) return;
11284
+ settled = true;
11285
+ conn.end();
11286
+ if (err) reject(err);
11287
+ else resolve();
11288
+ };
11289
+ conn.on("ready", () => {
11290
+ conn.sftp((err, sftp) => {
11291
+ if (err) {
11292
+ cleanup(err);
11293
+ return;
11294
+ }
11295
+ sftp.writeFile(remoteName, Buffer.from(content, "utf-8"), (writeErr) => {
11296
+ cleanup(writeErr ?? void 0);
11297
+ });
11298
+ });
11299
+ });
11300
+ conn.on("error", (err) => cleanup(err));
11301
+ const connectOptions = {
11302
+ host: this.config.host,
11303
+ port: this.config.sshPort ?? 22,
11304
+ username: this.credentials.username,
11305
+ password: this.credentials.password,
11306
+ readyTimeout: 1e4
11307
+ };
11308
+ if (this.config.sshFingerprint) {
11309
+ const expected = this.config.sshFingerprint.toLowerCase();
11310
+ connectOptions.hostVerifier = (key2) => {
11311
+ const actual = createHash2("sha256").update(key2).digest("hex");
11312
+ return actual === expected;
11313
+ };
11314
+ }
11315
+ conn.connect(connectOptions);
11316
+ });
11317
+ }
11318
+ };
11319
+
11062
11320
  // src/adapter/adapter-factory.ts
11063
11321
  function createSshClient(routerConfig, options) {
11064
11322
  const creds = getCredentials(routerConfig);
@@ -11068,6 +11326,10 @@ function createFtpClient(routerConfig) {
11068
11326
  const creds = getCredentials(routerConfig);
11069
11327
  return new FtpClient(routerConfig.host, 21, creds.username, creds.password);
11070
11328
  }
11329
+ function createSftpClient(routerConfig) {
11330
+ const creds = getCredentials(routerConfig);
11331
+ return new SftpClient(routerConfig, creds);
11332
+ }
11071
11333
 
11072
11334
  // src/mcp/tool-context.ts
11073
11335
  function buildRouterToolContext(args) {
@@ -11080,6 +11342,7 @@ function buildRouterToolContext(args) {
11080
11342
  routerConfig,
11081
11343
  sshClient: createSshClient(routerConfig, config.ssh),
11082
11344
  ftpClient: createFtpClient(routerConfig),
11345
+ sftpClient: createSftpClient(routerConfig),
11083
11346
  identity,
11084
11347
  routerRegistry: registry,
11085
11348
  connectionPool: pool,
@@ -11087,1082 +11350,1136 @@ function buildRouterToolContext(args) {
11087
11350
  };
11088
11351
  }
11089
11352
 
11090
- // src/middleware/fleet-confirmation.ts
11091
- import { createHmac, createHash as createHash2 } from "crypto";
11353
+ // src/mcp/tool-executor.ts
11354
+ import { nanoid as nanoid4 } from "nanoid";
11355
+
11356
+ // src/observability/correlation.ts
11357
+ import { AsyncLocalStorage } from "async_hooks";
11358
+ import { nanoid as nanoid3 } from "nanoid";
11359
+ var requestContext = new AsyncLocalStorage();
11360
+ function withContext(ctx, fn) {
11361
+ const fullContext = {
11362
+ correlationId: ctx.correlationId ?? nanoid3(),
11363
+ routerId: ctx.routerId,
11364
+ tool: ctx.tool,
11365
+ identity: ctx.identity
11366
+ };
11367
+ return requestContext.run(fullContext, fn);
11368
+ }
11369
+
11370
+ // src/middleware/auth.ts
11371
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
11372
+ var identityStorage = new AsyncLocalStorage2();
11373
+ function getCurrentIdentity() {
11374
+ return identityStorage.getStore();
11375
+ }
11376
+ function withIdentity(identity, fn) {
11377
+ return identityStorage.run(identity, fn);
11378
+ }
11379
+ function extractBearerToken(authHeader) {
11380
+ if (!authHeader) return null;
11381
+ const match = /^Bearer (.+)$/i.exec(authHeader);
11382
+ return match ? match[1] : null;
11383
+ }
11384
+ async function authenticateHttp(req, registry) {
11385
+ const token = extractBearerToken(req.headers.authorization);
11386
+ if (!token) {
11387
+ throw new MikroMCPError({
11388
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11389
+ code: "INVALID_TOKEN",
11390
+ message: "Missing or malformed Authorization header. Use: Authorization: Bearer <token>",
11391
+ recoverability: {
11392
+ retryable: false,
11393
+ suggestedAction: "Provide a valid bearer token in the Authorization header."
11394
+ }
11395
+ });
11396
+ }
11397
+ const identity = await registry.findIdentityByToken(token);
11398
+ if (!identity) {
11399
+ throw new MikroMCPError({
11400
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11401
+ code: "INVALID_TOKEN",
11402
+ message: "Token does not match any configured identity.",
11403
+ recoverability: {
11404
+ retryable: false,
11405
+ suggestedAction: "Verify the bearer token matches a configured identity in identities.yaml."
11406
+ }
11407
+ });
11408
+ }
11409
+ return identity;
11410
+ }
11411
+ var BUILTIN_SUPERADMIN = {
11412
+ id: "superadmin-builtin",
11413
+ role: "superadmin",
11414
+ allowedRouters: [],
11415
+ allowedToolPatterns: []
11416
+ };
11417
+ function getStdioIdentity(stdioIdentityName, registry) {
11418
+ if (!stdioIdentityName) {
11419
+ return BUILTIN_SUPERADMIN;
11420
+ }
11421
+ const found = registry.getIdentities().find((i) => i.id === stdioIdentityName);
11422
+ if (!found) {
11423
+ throw new MikroMCPError({
11424
+ category: "CONFIGURATION" /* CONFIGURATION */,
11425
+ code: "STDIO_IDENTITY_NOT_FOUND",
11426
+ message: `MIKROMCP_STDIO_IDENTITY="${stdioIdentityName}" was not found in identities.yaml`,
11427
+ details: { stdioIdentityName },
11428
+ recoverability: {
11429
+ retryable: false,
11430
+ suggestedAction: `Add an identity named "${stdioIdentityName}" to ~/.mikromcp/identities.yaml or unset MIKROMCP_STDIO_IDENTITY.`
11431
+ }
11432
+ });
11433
+ }
11434
+ return found;
11435
+ }
11436
+
11437
+ // src/middleware/confirmation.ts
11438
+ import { createHmac, createHash as createHash3, timingSafeEqual } from "crypto";
11092
11439
  var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
11093
- var pending = /* @__PURE__ */ new Map();
11094
- function fingerprint(args) {
11095
- const sortedRouters = [...args.routerIds].sort();
11096
- const payload = JSON.stringify({
11097
- toolName: args.toolName,
11098
- routerIds: sortedRouters,
11099
- params: args.params,
11100
- identityId: args.identityId
11101
- });
11102
- return createHash2("sha256").update(payload).digest("hex");
11440
+ var BYPASS_ROLES = ["admin", "superadmin"];
11441
+ var usedTokens = /* @__PURE__ */ new Map();
11442
+ function computeParamHash(params) {
11443
+ const { confirmationToken: _, ...rest } = params;
11444
+ return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
11445
+ }
11446
+ function sign(tool, routerId2, identityId, paramHash, expiresAtMs, secret) {
11447
+ const payload = `${tool}|${routerId2}|${identityId}|${paramHash}|${expiresAtMs}`;
11448
+ const mac = createHmac("sha256", secret).update(payload).digest("hex");
11449
+ return `${expiresAtMs}.${mac}`;
11103
11450
  }
11104
- function evictExpired() {
11105
- const now = /* @__PURE__ */ new Date();
11106
- for (const [token, entry] of pending) {
11107
- if (entry.expiresAt <= now) pending.delete(token);
11451
+ function tokensEqual(a, b) {
11452
+ const bufA = Buffer.from(a, "utf-8");
11453
+ const bufB = Buffer.from(b, "utf-8");
11454
+ if (bufA.length !== bufB.length) return false;
11455
+ return timingSafeEqual(bufA, bufB);
11456
+ }
11457
+ function sweepUsed(now) {
11458
+ for (const [token, expiresAtMs] of usedTokens) {
11459
+ if (expiresAtMs <= now) usedTokens.delete(token);
11108
11460
  }
11109
11461
  }
11110
- function checkFleetConfirmation(args, secret) {
11111
- evictExpired();
11112
- const fp = fingerprint(args);
11113
- if (args.submittedToken) {
11114
- const entry = pending.get(args.submittedToken);
11115
- if (!entry || entry.expiresAt <= /* @__PURE__ */ new Date() || entry.fingerprint !== fp) {
11462
+ async function checkConfirmation(toolName, routerId2, params, identity, secret) {
11463
+ if (BYPASS_ROLES.includes(identity.role)) return;
11464
+ const now = Date.now();
11465
+ sweepUsed(now);
11466
+ const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
11467
+ const paramHash = computeParamHash(params);
11468
+ if (submittedToken) {
11469
+ const dot = submittedToken.indexOf(".");
11470
+ const expiresAtMs2 = dot > 0 ? Number(submittedToken.slice(0, dot)) : NaN;
11471
+ if (!Number.isFinite(expiresAtMs2) || expiresAtMs2 <= now || usedTokens.has(submittedToken)) {
11116
11472
  throw new MikroMCPError({
11117
11473
  category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11118
- code: "FLEET_CONFIRMATION_MISMATCH",
11119
- message: "Fleet confirmation token is expired, already used, or does not match the current toolName/routerIds/params.",
11474
+ code: "CONFIRMATION_EXPIRED",
11475
+ message: "Confirmation token has expired or was already used. Re-submit without confirmationToken to get a fresh token.",
11120
11476
  recoverability: {
11121
11477
  retryable: true,
11122
- suggestedAction: "Re-submit bulk_execute without confirmationToken to get a fresh token."
11478
+ suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
11479
+ }
11480
+ });
11481
+ }
11482
+ const expected = sign(toolName, routerId2, identity.id, paramHash, expiresAtMs2, secret);
11483
+ if (!tokensEqual(submittedToken, expected)) {
11484
+ throw new MikroMCPError({
11485
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11486
+ code: "CONFIRMATION_MISMATCH",
11487
+ message: "Confirmation token does not match the current call parameters.",
11488
+ recoverability: {
11489
+ retryable: true,
11490
+ suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
11123
11491
  }
11124
11492
  });
11125
11493
  }
11126
- pending.delete(args.submittedToken);
11494
+ usedTokens.set(submittedToken, expiresAtMs2);
11127
11495
  return;
11128
11496
  }
11129
- const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS);
11130
- const token = createHmac("sha256", secret).update(`${fp}:${expiresAt.toISOString()}`).digest("hex");
11131
- pending.set(token, { expiresAt, fingerprint: fp });
11497
+ const expiresAtMs = now + CONFIRMATION_TTL_MS;
11498
+ const token = sign(toolName, routerId2, identity.id, paramHash, expiresAtMs, secret);
11132
11499
  throw new MikroMCPError({
11133
11500
  category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
11134
- code: "FLEET_CONFIRMATION_REQUIRED",
11135
- message: `This will run destructive tool "${args.toolName}" across ${args.routerIds.length} router(s). Re-submit with confirmationToken to proceed.`,
11501
+ code: "CONFIRMATION_REQUIRED",
11502
+ message: "This action is destructive. Re-submit with confirmationToken to proceed.",
11136
11503
  details: {
11137
11504
  confirmationToken: token,
11138
- expiresAt: expiresAt.toISOString(),
11139
- routerCount: args.routerIds.length
11505
+ expiresAt: new Date(expiresAtMs).toISOString(),
11506
+ tool: toolName,
11507
+ routerId: routerId2
11140
11508
  },
11141
11509
  recoverability: {
11142
11510
  retryable: true,
11143
- suggestedAction: "Re-submit the identical bulk_execute call with details.confirmationToken."
11511
+ suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
11144
11512
  }
11145
11513
  });
11146
11514
  }
11147
11515
 
11148
- // src/domain/tools/fleet-tools.ts
11149
- var log47 = createLogger("fleet-tools");
11150
- var checkHealthInputSchema = z46.object({
11151
- routerId
11152
- }).strict();
11153
- var bulkExecuteInputSchema = z46.object({
11154
- toolName: z46.string().describe("Name of the tool to fan out (must be a single-router tool)"),
11155
- routerIds: z46.array(z46.string()).optional().describe("Explicit list of router IDs to target"),
11156
- tags: z46.array(z46.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
11157
- params: z46.record(z46.string(), z46.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
11158
- concurrency: z46.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
11159
- confirmationToken: z46.string().optional().describe(
11160
- "Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
11161
- )
11162
- }).strict();
11163
- var listRoutersInputSchema = z46.object({
11164
- tags: z46.array(z46.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
11165
- }).strict();
11166
- var listRoutersTool = {
11167
- name: "list_routers",
11168
- title: "List Routers",
11169
- description: "List the routers configured in the registry (routers.yaml): id, host, port, TLS status, tags, ROS version, and which is the default. Read-only reflection of local config \u2014 no RouterOS API call, no credentials in the response. Use it to discover valid routerId values and tags for targeting other tools (including bulk_execute).",
11170
- inputSchema: listRoutersInputSchema,
11171
- annotations: {
11172
- readOnlyHint: true,
11173
- destructiveHint: false,
11174
- idempotentHint: true,
11175
- openWorldHint: false
11176
- },
11177
- skipRouterContext: true,
11178
- async handler(params, context) {
11179
- const parsed = listRoutersInputSchema.parse(params);
11180
- const { identity, appConfig } = context;
11181
- const defaultRouter = appConfig.defaultRouter;
11182
- log47.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
11183
- let routers = context.routerRegistry.listRouters(parsed.tags);
11184
- if (identity.allowedRouters.length > 0) {
11185
- routers = routers.filter((r) => identity.allowedRouters.includes(r.id));
11516
+ // src/adapter/retry-engine.ts
11517
+ var DEFAULTS = {
11518
+ maxRetries: 3,
11519
+ baseDelayMs: 200,
11520
+ maxDelayMs: 5e3
11521
+ };
11522
+ var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
11523
+ function isRetryable(error) {
11524
+ if (error instanceof MikroMCPError) {
11525
+ return error.recoverability.retryable;
11526
+ }
11527
+ if (error instanceof Error) {
11528
+ const code = error.code;
11529
+ if (code && RETRYABLE_CODES.has(code)) {
11530
+ return true;
11186
11531
  }
11187
- const soleDefaultId = defaultRouter === void 0 && routers.length === 1 ? routers[0].id : void 0;
11188
- const rows = routers.map((r) => ({
11189
- id: r.id,
11190
- host: r.host,
11191
- port: r.port,
11192
- tlsEnabled: r.tls.enabled,
11193
- tags: r.tags,
11194
- rosVersion: r.rosVersion,
11195
- isDefault: defaultRouter !== void 0 ? r.id === defaultRouter : r.id === soleDefaultId
11196
- }));
11197
- const total = rows.length;
11198
- const header = `Routers: ${total === 0 ? "none" : `1-${total} of ${total}`}.`;
11199
- const lines = rows.map(
11200
- (r) => ` ${compactFields(
11201
- { ...r, tags: r.tags.join(",") },
11202
- ["id", "host", "port", "tlsEnabled", "tags", "rosVersion", "isDefault"]
11203
- )}`
11204
- );
11205
- return {
11206
- content: total === 0 ? header : [header, ...lines].join("\n"),
11207
- structuredContent: { routers: rows, total, returned: total }
11208
- };
11209
- }
11210
- };
11211
- function createFleetTools(baseTools2) {
11212
- const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
11213
- const checkRouterHealthTool = {
11214
- name: "check_router_health",
11215
- title: "Check Router Health",
11216
- description: "Probe a router by fetching system/resource. Returns health status, ROS version, uptime, CPU load, and memory info. Unlike other tools, this never throws \u2014 unreachable routers are reported as healthy=false.",
11217
- inputSchema: checkHealthInputSchema,
11218
- annotations: {
11219
- readOnlyHint: true,
11220
- destructiveHint: false,
11221
- idempotentHint: true,
11222
- openWorldHint: false
11223
- },
11224
- async handler(params, context) {
11225
- checkHealthInputSchema.parse(params);
11226
- log47.info({ routerId: context.routerId }, "Checking router health");
11227
- const startMs = Date.now();
11228
- try {
11229
- const resources = await context.routerClient.get("system/resource");
11230
- const resource = resources[0];
11231
- const latencyMs = Date.now() - startMs;
11232
- const result = {
11233
- routerId: context.routerId,
11234
- healthy: true,
11235
- rosVersion: resource?.["version"],
11236
- uptime: resource?.["uptime"],
11237
- cpuLoad: resource?.["cpu-load"],
11238
- freeMemory: resource?.["free-memory"],
11239
- totalMemory: resource?.["total-memory"],
11240
- latencyMs
11241
- };
11242
- return {
11243
- content: `Router ${context.routerId} is healthy (${latencyMs}ms)`,
11244
- structuredContent: result
11245
- };
11246
- } catch (err) {
11247
- const latencyMs = Date.now() - startMs;
11248
- const message = err instanceof Error ? err.message : String(err);
11249
- return {
11250
- content: `Router ${context.routerId} is unhealthy: ${message}`,
11251
- structuredContent: {
11252
- routerId: context.routerId,
11253
- healthy: false,
11254
- latencyMs,
11255
- error: message
11256
- }
11257
- };
11258
- }
11532
+ const statusCode = error.statusCode;
11533
+ if (typeof statusCode === "number") {
11534
+ return statusCode >= 500;
11259
11535
  }
11260
- };
11261
- const bulkExecuteTool = {
11262
- name: "bulk_execute",
11263
- title: "Bulk Execute",
11264
- description: "Fan out a single-router tool to many routers in parallel (up to `concurrency`), targeted by routerIds or tag. Destructive tools need two-step confirmation: call without `confirmationToken` to get a fleet token (needs MIKROMCP_CONFIRMATION_SECRET), then re-call with it. Writes snapshot+journal each router for rollback. Returns per-router results with succeeded/failed counts.",
11265
- inputSchema: bulkExecuteInputSchema,
11266
- annotations: {
11267
- readOnlyHint: false,
11268
- destructiveHint: false,
11269
- idempotentHint: false,
11270
- openWorldHint: false
11271
- },
11272
- skipRouterContext: true,
11273
- async handler(params, context) {
11274
- const parsed = bulkExecuteInputSchema.parse(params);
11275
- const hasRouterIds = Array.isArray(parsed.routerIds) && parsed.routerIds.length > 0;
11276
- const hasTags = Array.isArray(parsed.tags) && parsed.tags.length > 0;
11277
- if (hasRouterIds === hasTags) {
11278
- throw new MikroMCPError({
11279
- category: "VALIDATION" /* VALIDATION */,
11280
- code: "BULK_TARGET_REQUIRED",
11281
- message: "Provide exactly one of routerIds or tags, not both and not neither.",
11282
- recoverability: {
11283
- retryable: false,
11284
- suggestedAction: "Supply either routerIds (array of IDs) or tags (array of tag strings)."
11285
- }
11286
- });
11287
- }
11288
- log47.info(
11289
- { toolName: parsed.toolName, concurrency: parsed.concurrency },
11290
- "bulk_execute invoked"
11291
- );
11292
- auditLog({
11293
- type: "audit",
11294
- ts: (/* @__PURE__ */ new Date()).toISOString(),
11295
- correlationId: context.correlationId,
11296
- identityId: context.identity.id,
11297
- role: context.identity.role,
11298
- tool: "bulk_execute",
11299
- routerId: "(fleet)",
11300
- phase: "attempt",
11301
- params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
11302
- }, context.appConfig.auditLogPath);
11303
- if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
11304
- throw new MikroMCPError({
11305
- category: "VALIDATION" /* VALIDATION */,
11306
- code: "BULK_SELF_REFERENCE",
11307
- message: `Cannot use bulk_execute to fan out fleet tools ("${parsed.toolName}").`,
11308
- recoverability: {
11309
- retryable: false,
11310
- suggestedAction: "Choose a single-router tool as the toolName."
11311
- }
11312
- });
11313
- }
11314
- const foundTool = toolMap.get(parsed.toolName);
11315
- if (!foundTool) {
11316
- throw new MikroMCPError({
11317
- category: "NOT_FOUND" /* NOT_FOUND */,
11318
- code: "TOOL_NOT_FOUND",
11319
- message: `Tool "${parsed.toolName}" not found. Available tools: ${[...toolMap.keys()].join(", ")}`,
11320
- recoverability: {
11321
- retryable: false,
11322
- suggestedAction: "Check the tool name and try again."
11323
- }
11324
- });
11325
- }
11326
- const targetTool = foundTool;
11327
- const preErrors = [];
11328
- let routers;
11329
- if (hasRouterIds) {
11330
- const resolved = [];
11331
- for (const id of parsed.routerIds) {
11332
- try {
11333
- resolved.push(context.routerRegistry.getRouter(id));
11334
- } catch {
11335
- log47.warn({ routerId: id }, "bulk_execute: router not found");
11336
- preErrors.push({
11337
- routerId: id,
11338
- status: "error",
11339
- error: `Router "${id}" not found in registry`,
11340
- durationMs: 0
11341
- });
11342
- }
11343
- }
11344
- routers = resolved;
11345
- } else {
11346
- routers = context.routerRegistry.listRouters(parsed.tags);
11347
- }
11348
- if (targetTool.annotations.destructiveHint) {
11349
- const secret = context.appConfig.confirmationSecret;
11350
- if (!secret) {
11351
- throw new MikroMCPError({
11352
- category: "CONFIGURATION" /* CONFIGURATION */,
11353
- code: "FLEET_CONFIRMATION_UNAVAILABLE",
11354
- message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
11355
- recoverability: {
11356
- retryable: false,
11357
- suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
11358
- }
11359
- });
11360
- }
11361
- const routerIdList = routers.map((r) => r.id);
11362
- checkFleetConfirmation(
11363
- {
11364
- toolName: parsed.toolName,
11365
- routerIds: routerIdList,
11366
- params: parsed.params,
11367
- identityId: context.identity.id,
11368
- submittedToken: parsed.confirmationToken
11369
- },
11370
- secret
11371
- );
11372
- }
11373
- if (routers.length === 0 && preErrors.length === 0) {
11374
- auditLog({
11375
- type: "audit",
11376
- ts: (/* @__PURE__ */ new Date()).toISOString(),
11377
- correlationId: context.correlationId,
11378
- identityId: context.identity.id,
11379
- role: context.identity.role,
11380
- tool: "bulk_execute",
11381
- routerId: "(fleet)",
11382
- phase: "success",
11383
- params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
11384
- }, context.appConfig.auditLogPath);
11385
- return {
11386
- content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
11387
- structuredContent: {
11388
- toolName: parsed.toolName,
11389
- totalRouters: 0,
11390
- succeeded: 0,
11391
- failed: 0,
11392
- results: []
11393
- }
11394
- };
11395
- }
11396
- const isWrite = !targetTool.annotations.readOnlyHint;
11397
- const snapshotDir = context.appConfig.snapshotDir;
11398
- const journalPath = context.appConfig.journalPath;
11399
- async function runForRouter(router) {
11400
- const start = Date.now();
11401
- let journalId;
11402
- try {
11403
- checkAuthz(context.identity, parsed.toolName, router.id);
11404
- const routerContext = buildRouterToolContext({
11405
- routerConfig: router,
11406
- correlationId: context.correlationId,
11407
- identity: context.identity,
11408
- pool: context.connectionPool,
11409
- config: context.appConfig,
11410
- registry: context.routerRegistry
11411
- });
11412
- const toolParams = { ...parsed.params, routerId: router.id };
11413
- const snapshotIds = [];
11414
- if (isWrite && targetTool.snapshotPaths && targetTool.snapshotPaths.length > 0) {
11415
- for (const path of targetTool.snapshotPaths) {
11416
- try {
11417
- const meta = await takeSnapshot(
11418
- routerContext.routerClient,
11419
- router.id,
11420
- path,
11421
- snapshotDir
11422
- );
11423
- snapshotIds.push(meta.id);
11424
- } catch (err) {
11425
- log47.warn(
11426
- { err, path, routerId: router.id },
11427
- "bulk_execute snapshot failed \u2014 proceeding without snapshot"
11428
- );
11429
- }
11430
- }
11431
- }
11432
- if (isWrite && journalPath) {
11433
- journalId = recordAttempt({
11434
- journalPath,
11435
- identityId: context.identity.id,
11436
- role: context.identity.role,
11437
- tool: parsed.toolName,
11438
- routerId: router.id,
11439
- params: toolParams,
11440
- snapshotIds
11441
- });
11442
- }
11443
- const result = await targetTool.handler(
11444
- toolParams,
11445
- routerContext
11446
- );
11447
- const elapsed = Date.now() - start;
11448
- if (journalId) {
11449
- recordOutcome({ journalPath, journalId, phase: "success", durationMs: elapsed });
11450
- }
11451
- auditLog({
11452
- type: "audit",
11453
- ts: (/* @__PURE__ */ new Date()).toISOString(),
11454
- correlationId: context.correlationId,
11455
- identityId: context.identity.id,
11456
- role: context.identity.role,
11457
- tool: parsed.toolName,
11458
- routerId: router.id,
11459
- phase: "success",
11460
- params: parsed.params,
11461
- durationMs: elapsed
11462
- }, context.appConfig.auditLogPath);
11463
- return { routerId: router.id, status: "ok", result, durationMs: elapsed };
11464
- } catch (err) {
11465
- const elapsed = Date.now() - start;
11466
- const message = err instanceof Error ? err.message : String(err);
11467
- if (journalId) {
11468
- recordOutcome({ journalPath, journalId, phase: "failure", outcome: message, durationMs: elapsed });
11469
- }
11470
- auditLog({
11471
- type: "audit",
11472
- ts: (/* @__PURE__ */ new Date()).toISOString(),
11473
- correlationId: context.correlationId,
11474
- identityId: context.identity.id,
11475
- role: context.identity.role,
11476
- tool: parsed.toolName,
11477
- routerId: router.id,
11478
- phase: "failure",
11479
- params: parsed.params,
11480
- outcome: message,
11481
- durationMs: elapsed
11482
- }, context.appConfig.auditLogPath);
11483
- return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
11484
- }
11485
- }
11486
- const results2 = [...preErrors];
11487
- for (let i = 0; i < routers.length; i += parsed.concurrency) {
11488
- const batch = routers.slice(i, i + parsed.concurrency);
11489
- const batchResults = await Promise.all(batch.map(runForRouter));
11490
- results2.push(...batchResults);
11536
+ }
11537
+ return false;
11538
+ }
11539
+ async function withRetry(fn, options) {
11540
+ const opts = { ...DEFAULTS, ...options };
11541
+ let lastError;
11542
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
11543
+ try {
11544
+ return await fn();
11545
+ } catch (error) {
11546
+ lastError = error;
11547
+ if (attempt >= opts.maxRetries || !isRetryable(error)) {
11548
+ throw error;
11491
11549
  }
11492
- const succeeded = results2.filter((r) => r.status === "ok").length;
11493
- const failed = results2.filter((r) => r.status === "error").length;
11494
- auditLog({
11495
- type: "audit",
11496
- ts: (/* @__PURE__ */ new Date()).toISOString(),
11497
- correlationId: context.correlationId,
11498
- identityId: context.identity.id,
11499
- role: context.identity.role,
11500
- tool: "bulk_execute",
11501
- routerId: "(fleet)",
11502
- phase: "success",
11503
- params: { toolName: parsed.toolName, succeeded, failed }
11504
- }, context.appConfig.auditLogPath);
11505
- return {
11506
- content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
11507
- structuredContent: {
11508
- toolName: parsed.toolName,
11509
- totalRouters: results2.length,
11510
- succeeded,
11511
- failed,
11512
- results: results2
11513
- }
11514
- };
11550
+ const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
11551
+ const jitter = Math.random() * opts.baseDelayMs;
11552
+ const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
11553
+ await new Promise((resolve) => setTimeout(resolve, delay));
11515
11554
  }
11516
- };
11517
- return [checkRouterHealthTool, bulkExecuteTool, listRoutersTool];
11555
+ }
11556
+ throw lastError;
11518
11557
  }
11519
11558
 
11520
- // src/domain/tools/index.ts
11521
- var baseTools = [
11522
- ...systemTools,
11523
- ...interfaceTools,
11524
- ...ipTools,
11525
- ...dhcpTools,
11526
- ...routeTools,
11527
- ...firewallTools,
11528
- ...diagnosticTools,
11529
- ...systemOpsTools,
11530
- ...bridgeTools,
11531
- ...wifiTools,
11532
- ...wireguardTools,
11533
- ...dnsTools,
11534
- ...mangleTools,
11535
- ...addressListTools,
11536
- ...policyRoutingTools,
11537
- ...routingProtocolTools,
11538
- ...scriptsTools,
11539
- ...schedulerTools,
11540
- ...packagesTools,
11541
- ...filesTools,
11542
- ...containerTools,
11543
- ...ipsecTools,
11544
- ...certificateTools,
11545
- ...userTools,
11546
- ...userGroupTools,
11547
- ...dhcpServerTools,
11548
- ...ipPoolTools,
11549
- ...queueTools,
11550
- ...vrrpTools,
11551
- ...networkServicesTools,
11552
- ...vlanTools,
11553
- ...dhcpClientTools,
11554
- ...ipServiceTools,
11555
- ...pppoeTools,
11556
- ...ovpnTools,
11557
- ...pppTools,
11558
- ...upgradeTools,
11559
- ...backupTools,
11560
- ...logTools,
11561
- ...networkTestTools,
11562
- ...containerConfigTools,
11563
- ...interfaceListTools
11564
- ];
11565
- var allTools = [
11566
- ...baseTools,
11567
- ...createChangeManagementTools(baseTools),
11568
- ...createFleetTools(baseTools)
11569
- ];
11570
-
11571
- // src/mcp/tool-executor.ts
11572
- import { nanoid as nanoid4 } from "nanoid";
11559
+ // src/config/maintenance-window.ts
11560
+ var DAY_ABBREVS = {
11561
+ Sun: "Sun",
11562
+ Mon: "Mon",
11563
+ Tue: "Tue",
11564
+ Wed: "Wed",
11565
+ Thu: "Thu",
11566
+ Fri: "Fri",
11567
+ Sat: "Sat"
11568
+ };
11569
+ var DAY_ORDER = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
11570
+ function isWithinMaintenanceWindow(windows, now) {
11571
+ if (windows.length === 0) return true;
11572
+ return windows.some((w) => {
11573
+ const parts = new Intl.DateTimeFormat("en-US", {
11574
+ timeZone: w.timezone,
11575
+ weekday: "short",
11576
+ hour: "2-digit",
11577
+ minute: "2-digit",
11578
+ hour12: false
11579
+ }).formatToParts(now);
11580
+ const rawDay = parts.find((p) => p.type === "weekday")?.value ?? "";
11581
+ const day = DAY_ABBREVS[rawDay];
11582
+ if (!day) return false;
11583
+ const hour = parts.find((p) => p.type === "hour")?.value?.padStart(2, "0") ?? "00";
11584
+ const minute = parts.find((p) => p.type === "minute")?.value?.padStart(2, "0") ?? "00";
11585
+ const currentTime = `${hour}:${minute}`;
11586
+ if (w.startTime === w.endTime) return false;
11587
+ if (w.startTime < w.endTime) {
11588
+ return w.days.includes(day) && currentTime >= w.startTime && currentTime <= w.endTime;
11589
+ }
11590
+ const prevDay = DAY_ORDER[(DAY_ORDER.indexOf(day) + 6) % 7];
11591
+ return w.days.includes(day) && currentTime >= w.startTime || w.days.includes(prevDay) && currentTime <= w.endTime;
11592
+ });
11593
+ }
11573
11594
 
11574
- // src/observability/correlation.ts
11575
- import { AsyncLocalStorage } from "async_hooks";
11576
- import { nanoid as nanoid3 } from "nanoid";
11577
- var requestContext = new AsyncLocalStorage();
11578
- function withContext(ctx, fn) {
11579
- const fullContext = {
11580
- correlationId: ctx.correlationId ?? nanoid3(),
11581
- routerId: ctx.routerId,
11582
- tool: ctx.tool,
11583
- identity: ctx.identity
11595
+ // src/mcp/response-formatter.ts
11596
+ function formatToolResult(result) {
11597
+ return {
11598
+ content: [{ type: "text", text: result.content }],
11599
+ structuredContent: result.structuredContent,
11600
+ ...result.isError ? { isError: true } : {}
11584
11601
  };
11585
- return requestContext.run(fullContext, fn);
11586
11602
  }
11587
-
11588
- // src/middleware/auth.ts
11589
- import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
11590
- var identityStorage = new AsyncLocalStorage2();
11591
- function getCurrentIdentity() {
11592
- return identityStorage.getStore();
11603
+ function formatError(error) {
11604
+ const hint = error.recoverability.suggestedAction;
11605
+ const text = `Error [${error.category}]: ${error.message}${hint ? `
11606
+ Suggested action: ${hint}` : ""}`;
11607
+ return {
11608
+ content: [{ type: "text", text }],
11609
+ structuredContent: error.toJSON(),
11610
+ isError: true
11611
+ };
11593
11612
  }
11594
- function withIdentity(identity, fn) {
11595
- return identityStorage.run(identity, fn);
11613
+
11614
+ // src/observability/metrics.ts
11615
+ var toolCalls = /* @__PURE__ */ new Map();
11616
+ function key(tool, status) {
11617
+ return `${tool}\0${status}`;
11596
11618
  }
11597
- function extractBearerToken(authHeader) {
11598
- if (!authHeader) return null;
11599
- const match = /^Bearer (.+)$/i.exec(authHeader);
11600
- return match ? match[1] : null;
11619
+ function recordToolCall(tool, status) {
11620
+ const k = key(tool, status);
11621
+ toolCalls.set(k, (toolCalls.get(k) ?? 0) + 1);
11601
11622
  }
11602
- async function authenticateHttp(req, registry) {
11603
- const token = extractBearerToken(req.headers.authorization);
11604
- if (!token) {
11605
- throw new MikroMCPError({
11606
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11607
- code: "INVALID_TOKEN",
11608
- message: "Missing or malformed Authorization header. Use: Authorization: Bearer <token>",
11609
- recoverability: {
11610
- retryable: false,
11611
- suggestedAction: "Provide a valid bearer token in the Authorization header."
11612
- }
11613
- });
11623
+ function renderPrometheus() {
11624
+ const lines = [
11625
+ "# HELP mikromcp_tool_calls_total Total MCP tool calls by tool and status.",
11626
+ "# TYPE mikromcp_tool_calls_total counter"
11627
+ ];
11628
+ for (const [k, count] of toolCalls) {
11629
+ const [tool, status] = k.split("\0");
11630
+ lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
11614
11631
  }
11615
- const identity = await registry.findIdentityByToken(token);
11616
- if (!identity) {
11617
- throw new MikroMCPError({
11618
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11619
- code: "INVALID_TOKEN",
11620
- message: "Token does not match any configured identity.",
11621
- recoverability: {
11622
- retryable: false,
11623
- suggestedAction: "Verify the bearer token matches a configured identity in identities.yaml."
11624
- }
11625
- });
11632
+ return lines.join("\n") + "\n";
11633
+ }
11634
+
11635
+ // src/adapter/circuit-breaker.ts
11636
+ var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
11637
+ "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
11638
+ "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
11639
+ "ROUTER_ERROR" /* ROUTER_ERROR */,
11640
+ "ROUTER_BUSY" /* ROUTER_BUSY */
11641
+ ]);
11642
+ function isTransientFailure(error) {
11643
+ if (!(error instanceof MikroMCPError)) {
11644
+ return false;
11626
11645
  }
11627
- return identity;
11646
+ return TRANSIENT_CATEGORIES.has(error.category);
11628
11647
  }
11629
- var BUILTIN_SUPERADMIN = {
11630
- id: "superadmin-builtin",
11631
- role: "superadmin",
11632
- allowedRouters: [],
11633
- allowedToolPatterns: []
11648
+ var DEFAULTS2 = {
11649
+ failureThreshold: 5,
11650
+ cooldownMs: 3e4
11634
11651
  };
11635
- function getStdioIdentity(stdioIdentityName, registry) {
11636
- if (!stdioIdentityName) {
11637
- return BUILTIN_SUPERADMIN;
11652
+ var CircuitBreaker = class {
11653
+ constructor(routerId2, options) {
11654
+ this.routerId = routerId2;
11655
+ this.options = { ...DEFAULTS2, ...options };
11638
11656
  }
11639
- const found = registry.getIdentities().find((i) => i.id === stdioIdentityName);
11640
- if (!found) {
11641
- throw new MikroMCPError({
11642
- category: "CONFIGURATION" /* CONFIGURATION */,
11643
- code: "STDIO_IDENTITY_NOT_FOUND",
11644
- message: `MIKROMCP_STDIO_IDENTITY="${stdioIdentityName}" was not found in identities.yaml`,
11645
- details: { stdioIdentityName },
11646
- recoverability: {
11647
- retryable: false,
11648
- suggestedAction: `Add an identity named "${stdioIdentityName}" to ~/.mikromcp/identities.yaml or unset MIKROMCP_STDIO_IDENTITY.`
11649
- }
11650
- });
11657
+ routerId;
11658
+ options;
11659
+ currentState = "closed";
11660
+ consecutiveFailures = 0;
11661
+ lastFailureTime = 0;
11662
+ probeInFlight = false;
11663
+ /** Effective state, accounting for an elapsed cooldown. Pure — does not mutate. */
11664
+ get state() {
11665
+ if (this.currentState === "open" && this.cooldownElapsed()) {
11666
+ return "half-open";
11667
+ }
11668
+ return this.currentState;
11651
11669
  }
11652
- return found;
11653
- }
11654
-
11655
- // src/middleware/confirmation.ts
11656
- import { createHmac as createHmac2, createHash as createHash3 } from "crypto";
11657
- var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
11658
- var BYPASS_ROLES = ["admin", "superadmin"];
11659
- var pendingConfirmations = /* @__PURE__ */ new Map();
11660
- function computeParamHash(params) {
11661
- const { confirmationToken: _, ...rest } = params;
11662
- return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
11663
- }
11664
- function computeToken(tool, routerId2, identityId, paramHash, expiresAt, secret) {
11665
- const payload = JSON.stringify({ tool, routerId: routerId2, paramHash, identityId, expiresAt });
11666
- return createHmac2("sha256", secret).update(payload).digest("hex");
11667
- }
11668
- function evictExpired2() {
11669
- const now = /* @__PURE__ */ new Date();
11670
- for (const [token, entry] of pendingConfirmations) {
11671
- if (entry.expiresAt <= now) pendingConfirmations.delete(token);
11670
+ cooldownElapsed() {
11671
+ return Date.now() - this.lastFailureTime >= this.options.cooldownMs;
11672
11672
  }
11673
- }
11674
- async function checkConfirmation(toolName, routerId2, params, identity, secret) {
11675
- if (BYPASS_ROLES.includes(identity.role)) return;
11676
- evictExpired2();
11677
- const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
11678
- if (submittedToken) {
11679
- const entry = pendingConfirmations.get(submittedToken);
11680
- if (!entry || entry.expiresAt <= /* @__PURE__ */ new Date()) {
11681
- throw new MikroMCPError({
11682
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11683
- code: "CONFIRMATION_EXPIRED",
11684
- message: "Confirmation token has expired or was already used. Re-submit without confirmationToken to get a fresh token.",
11685
- recoverability: {
11686
- retryable: true,
11687
- suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
11688
- }
11689
- });
11673
+ /** Apply the time-based open→half-open transition. Call before acting on state. */
11674
+ transitionIfCooldownElapsed() {
11675
+ if (this.currentState === "open" && this.cooldownElapsed()) {
11676
+ this.currentState = "half-open";
11690
11677
  }
11691
- const currentParamHash = computeParamHash(params);
11692
- if (entry.tool !== toolName || entry.routerId !== routerId2 || entry.identityId !== identity.id || entry.paramHash !== currentParamHash) {
11678
+ }
11679
+ /**
11680
+ * Execute `fn` through the circuit breaker.
11681
+ *
11682
+ * - **closed**: execute normally; track consecutive failures.
11683
+ * - **open**: reject immediately with `ROUTER_BUSY`.
11684
+ * - **half-open**: execute as a probe; success closes, failure reopens.
11685
+ */
11686
+ async execute(fn) {
11687
+ this.transitionIfCooldownElapsed();
11688
+ const currentState = this.currentState;
11689
+ if (currentState === "open") {
11693
11690
  throw new MikroMCPError({
11694
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11695
- code: "CONFIRMATION_MISMATCH",
11696
- message: "Confirmation token does not match the current call parameters.",
11691
+ category: "ROUTER_BUSY" /* ROUTER_BUSY */,
11692
+ code: "CIRCUIT_OPEN",
11693
+ message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
11694
+ details: {
11695
+ routerId: this.routerId,
11696
+ consecutiveFailures: this.consecutiveFailures,
11697
+ cooldownMs: this.options.cooldownMs
11698
+ },
11697
11699
  recoverability: {
11698
11700
  retryable: true,
11699
- suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
11701
+ retryAfterMs: this.options.cooldownMs,
11702
+ suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
11700
11703
  }
11701
11704
  });
11702
11705
  }
11703
- pendingConfirmations.delete(submittedToken);
11704
- return;
11706
+ if (currentState === "half-open") {
11707
+ if (this.probeInFlight) {
11708
+ throw new MikroMCPError({
11709
+ category: "ROUTER_BUSY" /* ROUTER_BUSY */,
11710
+ code: "CIRCUIT_PROBE_IN_FLIGHT",
11711
+ message: `Circuit breaker for router "${this.routerId}" is half-open and a recovery probe is already in flight.`,
11712
+ details: { routerId: this.routerId },
11713
+ recoverability: {
11714
+ retryable: true,
11715
+ retryAfterMs: 1e3,
11716
+ suggestedAction: "Wait for the in-flight probe to complete, then retry."
11717
+ }
11718
+ });
11719
+ }
11720
+ this.probeInFlight = true;
11721
+ }
11722
+ try {
11723
+ const result = await fn();
11724
+ this.onSuccess();
11725
+ return result;
11726
+ } catch (error) {
11727
+ if (isTransientFailure(error)) {
11728
+ this.onFailure();
11729
+ }
11730
+ throw error;
11731
+ } finally {
11732
+ this.probeInFlight = false;
11733
+ }
11705
11734
  }
11706
- const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS2).toISOString();
11707
- const paramHash = computeParamHash(params);
11708
- const token = computeToken(toolName, routerId2, identity.id, paramHash, expiresAt, secret);
11709
- pendingConfirmations.set(token, {
11710
- expiresAt: new Date(expiresAt),
11711
- tool: toolName,
11712
- routerId: routerId2,
11713
- identityId: identity.id,
11714
- paramHash
11715
- });
11735
+ /** Force the circuit back to the closed state. */
11736
+ reset() {
11737
+ this.currentState = "closed";
11738
+ this.consecutiveFailures = 0;
11739
+ this.lastFailureTime = 0;
11740
+ this.probeInFlight = false;
11741
+ }
11742
+ // ---------- internal helpers ----------
11743
+ onSuccess() {
11744
+ this.consecutiveFailures = 0;
11745
+ this.currentState = "closed";
11746
+ }
11747
+ onFailure() {
11748
+ this.consecutiveFailures++;
11749
+ this.lastFailureTime = Date.now();
11750
+ if (this.currentState === "half-open") {
11751
+ this.currentState = "open";
11752
+ } else if (this.consecutiveFailures >= this.options.failureThreshold) {
11753
+ this.currentState = "open";
11754
+ }
11755
+ }
11756
+ };
11757
+
11758
+ // src/mcp/tool-executor.ts
11759
+ var log47 = createLogger("tool-executor");
11760
+ var AMBIGUOUS_WRITE_CATEGORIES = /* @__PURE__ */ new Set([
11761
+ "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
11762
+ "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */
11763
+ ]);
11764
+ var VERIFY_STATE_PREFIX = "The write may already have been applied \u2014 verify router state before retrying. ";
11765
+ function resolveRouterId(explicit, registry, config) {
11766
+ if (explicit) {
11767
+ return explicit;
11768
+ }
11769
+ const fallback = config.defaultRouter ?? registry.soleRouterId();
11770
+ if (fallback) {
11771
+ return fallback;
11772
+ }
11773
+ const available = registry.routerIds();
11716
11774
  throw new MikroMCPError({
11717
- category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
11718
- code: "CONFIRMATION_REQUIRED",
11719
- message: "This action is destructive. Re-submit with confirmationToken to proceed.",
11720
- details: {
11721
- confirmationToken: token,
11722
- expiresAt,
11723
- tool: toolName,
11724
- routerId: routerId2
11725
- },
11775
+ category: "VALIDATION" /* VALIDATION */,
11776
+ code: "MISSING_ROUTER_ID",
11777
+ message: available.length > 0 ? `routerId is required: more than one router is configured. Available: ${available.join(", ")}.` : "routerId is required, but no routers are configured.",
11778
+ details: { availableRouters: available },
11726
11779
  recoverability: {
11727
- retryable: true,
11728
- suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
11780
+ retryable: false,
11781
+ suggestedAction: available.length > 0 ? "Provide a routerId, or set MIKROMCP_DEFAULT_ROUTER to choose a default." : "Configure at least one router in routers.yaml."
11729
11782
  }
11730
11783
  });
11731
11784
  }
11732
-
11733
- // src/adapter/retry-engine.ts
11734
- var DEFAULTS = {
11735
- maxRetries: 3,
11736
- baseDelayMs: 200,
11737
- maxDelayMs: 5e3
11738
- };
11739
- var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
11740
- function isRetryable(error) {
11741
- if (error instanceof MikroMCPError) {
11742
- return error.recoverability.retryable;
11743
- }
11744
- if (error instanceof Error) {
11745
- const code = error.code;
11746
- if (code && RETRYABLE_CODES.has(code)) {
11747
- return true;
11748
- }
11749
- const statusCode = error.statusCode;
11750
- if (typeof statusCode === "number") {
11751
- return statusCode >= 500;
11752
- }
11785
+ function getOrCreateBreaker(circuitBreakers, routerId2, config) {
11786
+ let cb = circuitBreakers.get(routerId2);
11787
+ if (!cb) {
11788
+ cb = new CircuitBreaker(routerId2, {
11789
+ failureThreshold: config.circuitBreaker.failureThreshold,
11790
+ cooldownMs: config.circuitBreaker.cooldownMs
11791
+ });
11792
+ circuitBreakers.set(routerId2, cb);
11753
11793
  }
11754
- return false;
11794
+ return cb;
11755
11795
  }
11756
- async function withRetry(fn, options) {
11757
- const opts = { ...DEFAULTS, ...options };
11758
- let lastError;
11759
- for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
11760
- try {
11761
- return await fn();
11762
- } catch (error) {
11763
- lastError = error;
11764
- if (attempt >= opts.maxRetries || !isRetryable(error)) {
11765
- throw error;
11796
+ function assertMaintenanceWindow(isDestructive, routerConfig, routerId2) {
11797
+ if (!isDestructive) return;
11798
+ const windows = routerConfig.maintenanceWindows;
11799
+ if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
11800
+ throw new MikroMCPError({
11801
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
11802
+ code: "OUTSIDE_MAINTENANCE_WINDOW",
11803
+ message: `Router "${routerId2}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
11804
+ details: { maintenanceWindows: windows },
11805
+ recoverability: {
11806
+ retryable: true,
11807
+ suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
11766
11808
  }
11767
- const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
11768
- const jitter = Math.random() * opts.baseDelayMs;
11769
- const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
11770
- await new Promise((resolve) => setTimeout(resolve, delay));
11771
- }
11809
+ });
11772
11810
  }
11773
- throw lastError;
11774
11811
  }
11775
-
11776
- // src/config/maintenance-window.ts
11777
- var DAY_ABBREVS = {
11778
- Sun: "Sun",
11779
- Mon: "Mon",
11780
- Tue: "Tue",
11781
- Wed: "Wed",
11782
- Thu: "Thu",
11783
- Fri: "Fri",
11784
- Sat: "Sat"
11785
- };
11786
- function isWithinMaintenanceWindow(windows, now) {
11787
- if (windows.length === 0) return true;
11788
- return windows.some((w) => {
11789
- const parts = new Intl.DateTimeFormat("en-US", {
11790
- timeZone: w.timezone,
11791
- weekday: "short",
11792
- hour: "2-digit",
11793
- minute: "2-digit",
11794
- hour12: false
11795
- }).formatToParts(now);
11796
- const rawDay = parts.find((p) => p.type === "weekday")?.value ?? "";
11797
- const day = DAY_ABBREVS[rawDay];
11798
- if (!day || !w.days.includes(day)) return false;
11799
- const hour = parts.find((p) => p.type === "hour")?.value?.padStart(2, "0") ?? "00";
11800
- const minute = parts.find((p) => p.type === "minute")?.value?.padStart(2, "0") ?? "00";
11801
- const currentTime = `${hour}:${minute}`;
11802
- return w.startTime !== w.endTime && currentTime >= w.startTime && currentTime <= w.endTime;
11812
+ function fleetUnavailable(what) {
11813
+ return new Proxy({}, {
11814
+ get() {
11815
+ throw new MikroMCPError({
11816
+ category: "INTERNAL" /* INTERNAL */,
11817
+ code: "FLEET_CONTEXT_UNAVAILABLE",
11818
+ message: `${what} is not available in a fleet-tool context (skipRouterContext). Target a specific router instead.`,
11819
+ recoverability: {
11820
+ retryable: false,
11821
+ suggestedAction: "Use a router-scoped tool, or pass a routerId to operate on one router."
11822
+ }
11823
+ });
11824
+ }
11803
11825
  });
11804
11826
  }
11805
-
11806
- // src/mcp/response-formatter.ts
11807
- function formatToolResult(result) {
11808
- return {
11809
- content: [{ type: "text", text: result.content }],
11810
- structuredContent: result.structuredContent,
11811
- ...result.isError ? { isError: true } : {}
11812
- };
11813
- }
11814
- function formatError(error) {
11815
- const hint = error.recoverability.suggestedAction;
11816
- const text = `Error [${error.category}]: ${error.message}${hint ? `
11817
- Suggested action: ${hint}` : ""}`;
11818
- return {
11819
- content: [{ type: "text", text }],
11820
- structuredContent: error.toJSON(),
11821
- isError: true
11822
- };
11827
+ async function executeToolCall(tool, args, deps) {
11828
+ const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
11829
+ const correlationId = nanoid4();
11830
+ return withContext({ correlationId, tool: tool.name }, async () => {
11831
+ const startMs = Date.now();
11832
+ let journalId;
11833
+ try {
11834
+ const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
11835
+ if (tool.skipRouterContext) {
11836
+ const fleetContext = {
11837
+ routerClient: fleetUnavailable("routerClient"),
11838
+ routerId: "",
11839
+ correlationId,
11840
+ routerConfig: fleetUnavailable("routerConfig"),
11841
+ sshClient: fleetUnavailable("sshClient"),
11842
+ ftpClient: fleetUnavailable("ftpClient"),
11843
+ sftpClient: fleetUnavailable("sftpClient"),
11844
+ identity,
11845
+ routerRegistry: registry,
11846
+ connectionPool: pool,
11847
+ circuitBreakers,
11848
+ appConfig: config
11849
+ };
11850
+ const fleetResult = await tool.handler(args, fleetContext);
11851
+ recordToolCall(tool.name, "success");
11852
+ return formatToolResult(fleetResult);
11853
+ }
11854
+ const routerId2 = resolveRouterId(args.routerId, registry, config);
11855
+ args.routerId = routerId2;
11856
+ checkAuthz(identity, tool.name, routerId2);
11857
+ const routerConfig = registry.getRouter(routerId2);
11858
+ assertMaintenanceWindow(tool.annotations.destructiveHint, routerConfig, routerId2);
11859
+ if (tool.annotations.destructiveHint && config.confirmationSecret) {
11860
+ await checkConfirmation(tool.name, routerId2, args, identity, config.confirmationSecret);
11861
+ }
11862
+ const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
11863
+ if (shouldAudit) {
11864
+ auditLog({
11865
+ type: "audit",
11866
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11867
+ correlationId,
11868
+ identityId: identity.id,
11869
+ role: identity.role,
11870
+ tool: tool.name,
11871
+ routerId: routerId2,
11872
+ phase: "attempt",
11873
+ params: args
11874
+ }, config.auditLogPath);
11875
+ }
11876
+ const toolContext = buildRouterToolContext({
11877
+ routerConfig,
11878
+ correlationId,
11879
+ identity,
11880
+ pool,
11881
+ config,
11882
+ registry
11883
+ });
11884
+ const cb = getOrCreateBreaker(circuitBreakers, routerId2, config);
11885
+ toolContext.circuitBreaker = cb;
11886
+ const { confirmationToken: _ct, ...handlerArgs } = args;
11887
+ const snapshotIds = [];
11888
+ if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
11889
+ for (const path of tool.snapshotPaths) {
11890
+ try {
11891
+ const meta = await takeSnapshot(toolContext.routerClient, routerId2, path, config.snapshotDir);
11892
+ snapshotIds.push(meta.id);
11893
+ log47.debug({ snapshotId: meta.id, path }, "Snapshot taken");
11894
+ } catch (err) {
11895
+ log47.warn({ err, path, routerId: routerId2 }, "Snapshot failed \u2014 proceeding without snapshot");
11896
+ }
11897
+ }
11898
+ }
11899
+ if (!tool.annotations.readOnlyHint && config.journalPath) {
11900
+ journalId = recordAttempt({
11901
+ journalPath: config.journalPath,
11902
+ identityId: identity.id,
11903
+ role: identity.role,
11904
+ tool: tool.name,
11905
+ routerId: routerId2,
11906
+ params: handlerArgs,
11907
+ snapshotIds
11908
+ });
11909
+ }
11910
+ const runHandler = () => tool.handler(handlerArgs, toolContext);
11911
+ const shouldRetry = tool.annotations.readOnlyHint && tool.retryable !== false;
11912
+ const executeHandler = () => cb.execute(shouldRetry ? () => withRetry(runHandler, config.retry) : runHandler);
11913
+ const result = await executeHandler();
11914
+ if (journalId) {
11915
+ recordOutcome({
11916
+ journalPath: config.journalPath,
11917
+ journalId,
11918
+ phase: "success",
11919
+ durationMs: Date.now() - startMs
11920
+ });
11921
+ }
11922
+ if (shouldAudit) {
11923
+ auditLog({
11924
+ type: "audit",
11925
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11926
+ correlationId,
11927
+ identityId: identity.id,
11928
+ role: identity.role,
11929
+ tool: tool.name,
11930
+ routerId: routerId2,
11931
+ phase: "success",
11932
+ params: args,
11933
+ durationMs: Date.now() - startMs
11934
+ }, config.auditLogPath);
11935
+ }
11936
+ log47.info({ tool: tool.name, routerId: routerId2, correlationId }, "Tool executed successfully");
11937
+ recordToolCall(tool.name, "success");
11938
+ return formatToolResult(result);
11939
+ } catch (err) {
11940
+ const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
11941
+ if (!tool.annotations.readOnlyHint && AMBIGUOUS_WRITE_CATEGORIES.has(error.category) && !error.recoverability.suggestedAction.startsWith(VERIFY_STATE_PREFIX)) {
11942
+ error.recoverability.suggestedAction = `${VERIFY_STATE_PREFIX}${error.recoverability.suggestedAction}`;
11943
+ }
11944
+ if (error.category === "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */) {
11945
+ const failedRouterId = args.routerId;
11946
+ if (failedRouterId) {
11947
+ pool.removeClient(failedRouterId);
11948
+ log47.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
11949
+ }
11950
+ }
11951
+ if (journalId) {
11952
+ recordOutcome({
11953
+ journalPath: config.journalPath,
11954
+ journalId,
11955
+ phase: "failure",
11956
+ outcome: error.code,
11957
+ durationMs: Date.now() - startMs
11958
+ });
11959
+ }
11960
+ const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
11961
+ if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
11962
+ const routerId2 = args.routerId ?? "unknown";
11963
+ const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
11964
+ auditLog({
11965
+ type: "audit",
11966
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11967
+ correlationId,
11968
+ identityId: identity.id,
11969
+ role: identity.role,
11970
+ tool: tool.name,
11971
+ routerId: routerId2,
11972
+ phase: "failure",
11973
+ params: args,
11974
+ outcome: error.code,
11975
+ durationMs: Date.now() - startMs
11976
+ }, config.auditLogPath);
11977
+ }
11978
+ log47.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
11979
+ recordToolCall(tool.name, "error");
11980
+ return formatError(error);
11981
+ }
11982
+ });
11823
11983
  }
11824
11984
 
11825
- // src/observability/metrics.ts
11826
- var toolCalls = /* @__PURE__ */ new Map();
11827
- function key(tool, status) {
11828
- return `${tool}\0${status}`;
11985
+ // src/middleware/fleet-confirmation.ts
11986
+ import { createHmac as createHmac2, createHash as createHash4, timingSafeEqual as timingSafeEqual2 } from "crypto";
11987
+ var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
11988
+ var usedTokens2 = /* @__PURE__ */ new Map();
11989
+ function fingerprint(args) {
11990
+ const sortedRouters = [...args.routerIds].sort();
11991
+ const payload = JSON.stringify({
11992
+ toolName: args.toolName,
11993
+ routerIds: sortedRouters,
11994
+ params: args.params,
11995
+ identityId: args.identityId
11996
+ });
11997
+ return createHash4("sha256").update(payload).digest("hex");
11829
11998
  }
11830
- function recordToolCall(tool, status) {
11831
- const k = key(tool, status);
11832
- toolCalls.set(k, (toolCalls.get(k) ?? 0) + 1);
11999
+ function sign2(fp, expiresAtMs, secret) {
12000
+ const mac = createHmac2("sha256", secret).update(`${fp}|${expiresAtMs}`).digest("hex");
12001
+ return `${expiresAtMs}.${mac}`;
11833
12002
  }
11834
- function renderPrometheus() {
11835
- const lines = [
11836
- "# HELP mikromcp_tool_calls_total Total MCP tool calls by tool and status.",
11837
- "# TYPE mikromcp_tool_calls_total counter"
11838
- ];
11839
- for (const [k, count] of toolCalls) {
11840
- const [tool, status] = k.split("\0");
11841
- lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
11842
- }
11843
- return lines.join("\n") + "\n";
12003
+ function tokensEqual2(a, b) {
12004
+ const bufA = Buffer.from(a, "utf-8");
12005
+ const bufB = Buffer.from(b, "utf-8");
12006
+ if (bufA.length !== bufB.length) return false;
12007
+ return timingSafeEqual2(bufA, bufB);
11844
12008
  }
11845
-
11846
- // src/adapter/circuit-breaker.ts
11847
- var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
11848
- "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
11849
- "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
11850
- "ROUTER_ERROR" /* ROUTER_ERROR */,
11851
- "ROUTER_BUSY" /* ROUTER_BUSY */
11852
- ]);
11853
- function isTransientFailure(error) {
11854
- if (!(error instanceof MikroMCPError)) {
11855
- return false;
12009
+ function sweepUsed2(now) {
12010
+ for (const [token, expiresAtMs] of usedTokens2) {
12011
+ if (expiresAtMs <= now) usedTokens2.delete(token);
11856
12012
  }
11857
- return TRANSIENT_CATEGORIES.has(error.category);
11858
12013
  }
11859
- var DEFAULTS2 = {
11860
- failureThreshold: 5,
11861
- cooldownMs: 3e4
11862
- };
11863
- var CircuitBreaker = class {
11864
- constructor(routerId2, options) {
11865
- this.routerId = routerId2;
11866
- this.options = { ...DEFAULTS2, ...options };
11867
- }
11868
- routerId;
11869
- options;
11870
- currentState = "closed";
11871
- consecutiveFailures = 0;
11872
- lastFailureTime = 0;
11873
- probeInFlight = false;
11874
- /** Effective state, accounting for an elapsed cooldown. Pure — does not mutate. */
11875
- get state() {
11876
- if (this.currentState === "open" && this.cooldownElapsed()) {
11877
- return "half-open";
11878
- }
11879
- return this.currentState;
11880
- }
11881
- cooldownElapsed() {
11882
- return Date.now() - this.lastFailureTime >= this.options.cooldownMs;
11883
- }
11884
- /** Apply the time-based open→half-open transition. Call before acting on state. */
11885
- transitionIfCooldownElapsed() {
11886
- if (this.currentState === "open" && this.cooldownElapsed()) {
11887
- this.currentState = "half-open";
11888
- }
11889
- }
11890
- /**
11891
- * Execute `fn` through the circuit breaker.
11892
- *
11893
- * - **closed**: execute normally; track consecutive failures.
11894
- * - **open**: reject immediately with `ROUTER_BUSY`.
11895
- * - **half-open**: execute as a probe; success closes, failure reopens.
11896
- */
11897
- async execute(fn) {
11898
- this.transitionIfCooldownElapsed();
11899
- const currentState = this.currentState;
11900
- if (currentState === "open") {
12014
+ function checkFleetConfirmation(args, secret) {
12015
+ const now = Date.now();
12016
+ sweepUsed2(now);
12017
+ const fp = fingerprint(args);
12018
+ if (args.submittedToken) {
12019
+ const token2 = args.submittedToken;
12020
+ const dot = token2.indexOf(".");
12021
+ const expiresAtMs2 = dot > 0 ? Number(token2.slice(0, dot)) : NaN;
12022
+ const expected = Number.isFinite(expiresAtMs2) ? sign2(fp, expiresAtMs2, secret) : "";
12023
+ if (!Number.isFinite(expiresAtMs2) || expiresAtMs2 <= now || usedTokens2.has(token2) || !tokensEqual2(token2, expected)) {
11901
12024
  throw new MikroMCPError({
11902
- category: "ROUTER_BUSY" /* ROUTER_BUSY */,
11903
- code: "CIRCUIT_OPEN",
11904
- message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
11905
- details: {
11906
- routerId: this.routerId,
11907
- consecutiveFailures: this.consecutiveFailures,
11908
- cooldownMs: this.options.cooldownMs
11909
- },
12025
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
12026
+ code: "FLEET_CONFIRMATION_MISMATCH",
12027
+ message: "Fleet confirmation token is expired, already used, or does not match the current toolName/routerIds/params.",
11910
12028
  recoverability: {
11911
12029
  retryable: true,
11912
- retryAfterMs: this.options.cooldownMs,
11913
- suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
12030
+ suggestedAction: "Re-submit bulk_execute without confirmationToken to get a fresh token."
11914
12031
  }
11915
12032
  });
11916
12033
  }
11917
- if (currentState === "half-open") {
11918
- if (this.probeInFlight) {
12034
+ usedTokens2.set(token2, expiresAtMs2);
12035
+ return;
12036
+ }
12037
+ const expiresAtMs = now + CONFIRMATION_TTL_MS2;
12038
+ const token = sign2(fp, expiresAtMs, secret);
12039
+ throw new MikroMCPError({
12040
+ category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
12041
+ code: "FLEET_CONFIRMATION_REQUIRED",
12042
+ message: `This will run destructive tool "${args.toolName}" across ${args.routerIds.length} router(s). Re-submit with confirmationToken to proceed.`,
12043
+ details: {
12044
+ confirmationToken: token,
12045
+ expiresAt: new Date(expiresAtMs).toISOString(),
12046
+ routerCount: args.routerIds.length
12047
+ },
12048
+ recoverability: {
12049
+ retryable: true,
12050
+ suggestedAction: "Re-submit the identical bulk_execute call with details.confirmationToken."
12051
+ }
12052
+ });
12053
+ }
12054
+
12055
+ // src/domain/tools/fleet-tools.ts
12056
+ var log48 = createLogger("fleet-tools");
12057
+ var checkHealthInputSchema = z46.object({
12058
+ routerId
12059
+ }).strict();
12060
+ var bulkExecuteInputSchema = z46.object({
12061
+ toolName: z46.string().describe("Name of the tool to fan out (must be a single-router tool)"),
12062
+ routerIds: z46.array(z46.string()).optional().describe("Explicit list of router IDs to target"),
12063
+ tags: z46.array(z46.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
12064
+ params: z46.record(z46.string(), z46.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
12065
+ concurrency: z46.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
12066
+ confirmationToken: z46.string().optional().describe(
12067
+ "Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
12068
+ )
12069
+ }).strict();
12070
+ var listRoutersInputSchema = z46.object({
12071
+ tags: z46.array(z46.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
12072
+ }).strict();
12073
+ var listRoutersTool = {
12074
+ name: "list_routers",
12075
+ title: "List Routers",
12076
+ description: "List the routers configured in the registry (routers.yaml): id, host, port, TLS status, tags, ROS version, and which is the default. Read-only reflection of local config \u2014 no RouterOS API call, no credentials in the response. Use it to discover valid routerId values and tags for targeting other tools (including bulk_execute).",
12077
+ inputSchema: listRoutersInputSchema,
12078
+ annotations: {
12079
+ readOnlyHint: true,
12080
+ destructiveHint: false,
12081
+ idempotentHint: true,
12082
+ openWorldHint: false
12083
+ },
12084
+ skipRouterContext: true,
12085
+ async handler(params, context) {
12086
+ const parsed = listRoutersInputSchema.parse(params);
12087
+ const { identity, appConfig } = context;
12088
+ const defaultRouter = appConfig.defaultRouter;
12089
+ log48.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
12090
+ let routers = context.routerRegistry.listRouters(parsed.tags);
12091
+ if (identity.allowedRouters.length > 0) {
12092
+ routers = routers.filter((r) => identity.allowedRouters.includes(r.id));
12093
+ }
12094
+ const soleDefaultId = defaultRouter === void 0 && routers.length === 1 ? routers[0].id : void 0;
12095
+ const rows = routers.map((r) => ({
12096
+ id: r.id,
12097
+ host: r.host,
12098
+ port: r.port,
12099
+ tlsEnabled: r.tls.enabled,
12100
+ tags: r.tags,
12101
+ rosVersion: r.rosVersion,
12102
+ isDefault: defaultRouter !== void 0 ? r.id === defaultRouter : r.id === soleDefaultId
12103
+ }));
12104
+ const total = rows.length;
12105
+ const header = `Routers: ${total === 0 ? "none" : `1-${total} of ${total}`}.`;
12106
+ const lines = rows.map(
12107
+ (r) => ` ${compactFields(
12108
+ { ...r, tags: r.tags.join(",") },
12109
+ ["id", "host", "port", "tlsEnabled", "tags", "rosVersion", "isDefault"]
12110
+ )}`
12111
+ );
12112
+ return {
12113
+ content: total === 0 ? header : [header, ...lines].join("\n"),
12114
+ structuredContent: { routers: rows, total, returned: total }
12115
+ };
12116
+ }
12117
+ };
12118
+ function createFleetTools(baseTools2) {
12119
+ const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
12120
+ const checkRouterHealthTool = {
12121
+ name: "check_router_health",
12122
+ title: "Check Router Health",
12123
+ description: "Probe a router by fetching system/resource. Returns health status, ROS version, uptime, CPU load, and memory info. Unlike other tools, this never throws \u2014 unreachable routers are reported as healthy=false.",
12124
+ inputSchema: checkHealthInputSchema,
12125
+ annotations: {
12126
+ readOnlyHint: true,
12127
+ destructiveHint: false,
12128
+ idempotentHint: true,
12129
+ openWorldHint: false
12130
+ },
12131
+ async handler(params, context) {
12132
+ checkHealthInputSchema.parse(params);
12133
+ log48.info({ routerId: context.routerId }, "Checking router health");
12134
+ const startMs = Date.now();
12135
+ try {
12136
+ const resources = await context.routerClient.get("system/resource");
12137
+ const resource = resources[0];
12138
+ const latencyMs = Date.now() - startMs;
12139
+ const result = {
12140
+ routerId: context.routerId,
12141
+ healthy: true,
12142
+ rosVersion: resource?.["version"],
12143
+ uptime: resource?.["uptime"],
12144
+ cpuLoad: resource?.["cpu-load"],
12145
+ freeMemory: resource?.["free-memory"],
12146
+ totalMemory: resource?.["total-memory"],
12147
+ latencyMs
12148
+ };
12149
+ return {
12150
+ content: `Router ${context.routerId} is healthy (${latencyMs}ms)`,
12151
+ structuredContent: result
12152
+ };
12153
+ } catch (err) {
12154
+ const latencyMs = Date.now() - startMs;
12155
+ const message = err instanceof Error ? err.message : String(err);
12156
+ return {
12157
+ content: `Router ${context.routerId} is unhealthy: ${message}`,
12158
+ structuredContent: {
12159
+ routerId: context.routerId,
12160
+ healthy: false,
12161
+ latencyMs,
12162
+ error: message
12163
+ }
12164
+ };
12165
+ }
12166
+ }
12167
+ };
12168
+ const bulkExecuteTool = {
12169
+ name: "bulk_execute",
12170
+ title: "Bulk Execute",
12171
+ description: "Fan out a single-router tool to many routers in parallel (up to `concurrency`), targeted by routerIds or tag. Destructive tools need two-step confirmation: call without `confirmationToken` to get a fleet token (needs MIKROMCP_CONFIRMATION_SECRET), then re-call with it. Writes snapshot+journal each router for rollback. Returns per-router results with succeeded/failed counts.",
12172
+ inputSchema: bulkExecuteInputSchema,
12173
+ annotations: {
12174
+ readOnlyHint: false,
12175
+ destructiveHint: false,
12176
+ idempotentHint: false,
12177
+ openWorldHint: false
12178
+ },
12179
+ skipRouterContext: true,
12180
+ async handler(params, context) {
12181
+ const parsed = bulkExecuteInputSchema.parse(params);
12182
+ const hasRouterIds = Array.isArray(parsed.routerIds) && parsed.routerIds.length > 0;
12183
+ const hasTags = Array.isArray(parsed.tags) && parsed.tags.length > 0;
12184
+ if (hasRouterIds === hasTags) {
12185
+ throw new MikroMCPError({
12186
+ category: "VALIDATION" /* VALIDATION */,
12187
+ code: "BULK_TARGET_REQUIRED",
12188
+ message: "Provide exactly one of routerIds or tags, not both and not neither.",
12189
+ recoverability: {
12190
+ retryable: false,
12191
+ suggestedAction: "Supply either routerIds (array of IDs) or tags (array of tag strings)."
12192
+ }
12193
+ });
12194
+ }
12195
+ log48.info(
12196
+ { toolName: parsed.toolName, concurrency: parsed.concurrency },
12197
+ "bulk_execute invoked"
12198
+ );
12199
+ auditLog({
12200
+ type: "audit",
12201
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
12202
+ correlationId: context.correlationId,
12203
+ identityId: context.identity.id,
12204
+ role: context.identity.role,
12205
+ tool: "bulk_execute",
12206
+ routerId: "(fleet)",
12207
+ phase: "attempt",
12208
+ params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
12209
+ }, context.appConfig.auditLogPath);
12210
+ if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
11919
12211
  throw new MikroMCPError({
11920
- category: "ROUTER_BUSY" /* ROUTER_BUSY */,
11921
- code: "CIRCUIT_PROBE_IN_FLIGHT",
11922
- message: `Circuit breaker for router "${this.routerId}" is half-open and a recovery probe is already in flight.`,
11923
- details: { routerId: this.routerId },
12212
+ category: "VALIDATION" /* VALIDATION */,
12213
+ code: "BULK_SELF_REFERENCE",
12214
+ message: `Cannot use bulk_execute to fan out fleet tools ("${parsed.toolName}").`,
11924
12215
  recoverability: {
11925
- retryable: true,
11926
- retryAfterMs: 1e3,
11927
- suggestedAction: "Wait for the in-flight probe to complete, then retry."
12216
+ retryable: false,
12217
+ suggestedAction: "Choose a single-router tool as the toolName."
11928
12218
  }
11929
12219
  });
11930
12220
  }
11931
- this.probeInFlight = true;
11932
- }
11933
- try {
11934
- const result = await fn();
11935
- this.onSuccess();
11936
- return result;
11937
- } catch (error) {
11938
- if (isTransientFailure(error)) {
11939
- this.onFailure();
12221
+ const foundTool = toolMap.get(parsed.toolName);
12222
+ if (!foundTool) {
12223
+ throw new MikroMCPError({
12224
+ category: "NOT_FOUND" /* NOT_FOUND */,
12225
+ code: "TOOL_NOT_FOUND",
12226
+ message: `Tool "${parsed.toolName}" not found. Available tools: ${[...toolMap.keys()].join(", ")}`,
12227
+ recoverability: {
12228
+ retryable: false,
12229
+ suggestedAction: "Check the tool name and try again."
12230
+ }
12231
+ });
11940
12232
  }
11941
- throw error;
11942
- } finally {
11943
- this.probeInFlight = false;
11944
- }
11945
- }
11946
- /** Force the circuit back to the closed state. */
11947
- reset() {
11948
- this.currentState = "closed";
11949
- this.consecutiveFailures = 0;
11950
- this.lastFailureTime = 0;
11951
- this.probeInFlight = false;
11952
- }
11953
- // ---------- internal helpers ----------
11954
- onSuccess() {
11955
- this.consecutiveFailures = 0;
11956
- this.currentState = "closed";
11957
- }
11958
- onFailure() {
11959
- this.consecutiveFailures++;
11960
- this.lastFailureTime = Date.now();
11961
- if (this.currentState === "half-open") {
11962
- this.currentState = "open";
11963
- } else if (this.consecutiveFailures >= this.options.failureThreshold) {
11964
- this.currentState = "open";
11965
- }
11966
- }
11967
- };
11968
-
11969
- // src/mcp/tool-executor.ts
11970
- var log48 = createLogger("tool-executor");
11971
- function resolveRouterId(explicit, registry, config) {
11972
- if (explicit) {
11973
- return explicit;
11974
- }
11975
- const fallback = config.defaultRouter ?? registry.soleRouterId();
11976
- if (fallback) {
11977
- return fallback;
11978
- }
11979
- const available = registry.routerIds();
11980
- throw new MikroMCPError({
11981
- category: "VALIDATION" /* VALIDATION */,
11982
- code: "MISSING_ROUTER_ID",
11983
- message: available.length > 0 ? `routerId is required: more than one router is configured. Available: ${available.join(", ")}.` : "routerId is required, but no routers are configured.",
11984
- details: { availableRouters: available },
11985
- recoverability: {
11986
- retryable: false,
11987
- suggestedAction: available.length > 0 ? "Provide a routerId, or set MIKROMCP_DEFAULT_ROUTER to choose a default." : "Configure at least one router in routers.yaml."
11988
- }
11989
- });
11990
- }
11991
- async function executeToolCall(tool, args, deps) {
11992
- const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
11993
- const correlationId = nanoid4();
11994
- return withContext({ correlationId, tool: tool.name }, async () => {
11995
- const startMs = Date.now();
11996
- let journalId;
11997
- try {
11998
- const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
11999
- if (tool.skipRouterContext) {
12000
- const fleetContext = {
12001
- routerClient: null,
12002
- routerId: "",
12003
- correlationId,
12004
- routerConfig: null,
12005
- sshClient: null,
12006
- ftpClient: null,
12007
- identity,
12008
- routerRegistry: registry,
12009
- connectionPool: pool,
12010
- appConfig: config
12011
- };
12012
- const fleetResult = await tool.handler(args, fleetContext);
12013
- recordToolCall(tool.name, "success");
12014
- return formatToolResult(fleetResult);
12233
+ const targetTool = foundTool;
12234
+ const preErrors = [];
12235
+ let routers;
12236
+ if (hasRouterIds) {
12237
+ const resolved = [];
12238
+ for (const id of parsed.routerIds) {
12239
+ try {
12240
+ resolved.push(context.routerRegistry.getRouter(id));
12241
+ } catch {
12242
+ log48.warn({ routerId: id }, "bulk_execute: router not found");
12243
+ preErrors.push({
12244
+ routerId: id,
12245
+ status: "error",
12246
+ error: `Router "${id}" not found in registry`,
12247
+ durationMs: 0
12248
+ });
12249
+ }
12250
+ }
12251
+ routers = resolved;
12252
+ } else {
12253
+ const tags = parsed.tags;
12254
+ routers = context.routerRegistry.listRouters().filter((r) => tags.every((t) => r.tags.includes(t)));
12015
12255
  }
12016
- const routerId2 = resolveRouterId(args.routerId, registry, config);
12017
- args.routerId = routerId2;
12018
- checkAuthz(identity, tool.name, routerId2);
12019
- const routerConfig = registry.getRouter(routerId2);
12020
- if (tool.annotations.destructiveHint) {
12021
- const windows = routerConfig.maintenanceWindows;
12022
- if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
12256
+ if (targetTool.annotations.destructiveHint) {
12257
+ const secret = context.appConfig.confirmationSecret;
12258
+ if (!secret) {
12023
12259
  throw new MikroMCPError({
12024
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
12025
- code: "OUTSIDE_MAINTENANCE_WINDOW",
12026
- message: `Router "${routerId2}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
12027
- details: { maintenanceWindows: windows },
12260
+ category: "CONFIGURATION" /* CONFIGURATION */,
12261
+ code: "FLEET_CONFIRMATION_UNAVAILABLE",
12262
+ message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
12028
12263
  recoverability: {
12029
- retryable: true,
12030
- suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
12264
+ retryable: false,
12265
+ suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
12031
12266
  }
12032
12267
  });
12033
12268
  }
12269
+ const routerIdList = routers.map((r) => r.id);
12270
+ checkFleetConfirmation(
12271
+ {
12272
+ toolName: parsed.toolName,
12273
+ routerIds: routerIdList,
12274
+ params: parsed.params,
12275
+ identityId: context.identity.id,
12276
+ submittedToken: parsed.confirmationToken
12277
+ },
12278
+ secret
12279
+ );
12034
12280
  }
12035
- if (tool.annotations.destructiveHint && config.confirmationSecret) {
12036
- await checkConfirmation(tool.name, routerId2, args, identity, config.confirmationSecret);
12037
- }
12038
- const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
12039
- if (shouldAudit) {
12281
+ if (routers.length === 0 && preErrors.length === 0) {
12040
12282
  auditLog({
12041
12283
  type: "audit",
12042
12284
  ts: (/* @__PURE__ */ new Date()).toISOString(),
12043
- correlationId,
12044
- identityId: identity.id,
12045
- role: identity.role,
12046
- tool: tool.name,
12047
- routerId: routerId2,
12048
- phase: "attempt",
12049
- params: args
12050
- }, config.auditLogPath);
12051
- }
12052
- const toolContext = buildRouterToolContext({
12053
- routerConfig,
12054
- correlationId,
12055
- identity,
12056
- pool,
12057
- config,
12058
- registry
12059
- });
12060
- let cb = circuitBreakers.get(routerId2);
12061
- if (!cb) {
12062
- cb = new CircuitBreaker(routerId2, {
12063
- failureThreshold: config.circuitBreaker.failureThreshold,
12064
- cooldownMs: config.circuitBreaker.cooldownMs
12065
- });
12066
- circuitBreakers.set(routerId2, cb);
12285
+ correlationId: context.correlationId,
12286
+ identityId: context.identity.id,
12287
+ role: context.identity.role,
12288
+ tool: "bulk_execute",
12289
+ routerId: "(fleet)",
12290
+ phase: "success",
12291
+ params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
12292
+ }, context.appConfig.auditLogPath);
12293
+ return {
12294
+ content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
12295
+ structuredContent: {
12296
+ toolName: parsed.toolName,
12297
+ totalRouters: 0,
12298
+ succeeded: 0,
12299
+ failed: 0,
12300
+ results: []
12301
+ }
12302
+ };
12067
12303
  }
12068
- toolContext.circuitBreaker = cb;
12069
- const { confirmationToken: _ct, ...handlerArgs } = args;
12070
- const snapshotIds = [];
12071
- if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
12072
- for (const path of tool.snapshotPaths) {
12073
- try {
12074
- const meta = await takeSnapshot(toolContext.routerClient, routerId2, path, config.snapshotDir);
12075
- snapshotIds.push(meta.id);
12076
- log48.debug({ snapshotId: meta.id, path }, "Snapshot taken");
12077
- } catch (err) {
12078
- log48.warn({ err, path, routerId: routerId2 }, "Snapshot failed \u2014 proceeding without snapshot");
12304
+ const isWrite = !targetTool.annotations.readOnlyHint;
12305
+ const snapshotDir = context.appConfig.snapshotDir;
12306
+ const journalPath = context.appConfig.journalPath;
12307
+ async function runForRouter(router) {
12308
+ const start = Date.now();
12309
+ let journalId;
12310
+ try {
12311
+ checkAuthz(context.identity, parsed.toolName, router.id);
12312
+ assertMaintenanceWindow(targetTool.annotations.destructiveHint, router, router.id);
12313
+ const routerContext = buildRouterToolContext({
12314
+ routerConfig: router,
12315
+ correlationId: context.correlationId,
12316
+ identity: context.identity,
12317
+ pool: context.connectionPool,
12318
+ config: context.appConfig,
12319
+ registry: context.routerRegistry
12320
+ });
12321
+ const toolParams = { ...parsed.params, routerId: router.id };
12322
+ const snapshotIds = [];
12323
+ if (isWrite && targetTool.snapshotPaths && targetTool.snapshotPaths.length > 0) {
12324
+ for (const path of targetTool.snapshotPaths) {
12325
+ try {
12326
+ const meta = await takeSnapshot(
12327
+ routerContext.routerClient,
12328
+ router.id,
12329
+ path,
12330
+ snapshotDir
12331
+ );
12332
+ snapshotIds.push(meta.id);
12333
+ } catch (err) {
12334
+ log48.warn(
12335
+ { err, path, routerId: router.id },
12336
+ "bulk_execute snapshot failed \u2014 proceeding without snapshot"
12337
+ );
12338
+ }
12339
+ }
12340
+ }
12341
+ if (isWrite && journalPath) {
12342
+ journalId = recordAttempt({
12343
+ journalPath,
12344
+ identityId: context.identity.id,
12345
+ role: context.identity.role,
12346
+ tool: parsed.toolName,
12347
+ routerId: router.id,
12348
+ params: toolParams,
12349
+ snapshotIds
12350
+ });
12351
+ }
12352
+ const cb = getOrCreateBreaker(context.circuitBreakers, router.id, context.appConfig);
12353
+ routerContext.circuitBreaker = cb;
12354
+ const runOnce = () => targetTool.handler(toolParams, routerContext);
12355
+ const shouldRetry = targetTool.annotations.readOnlyHint && targetTool.retryable !== false;
12356
+ const result = await cb.execute(
12357
+ shouldRetry ? () => withRetry(runOnce, context.appConfig.retry) : runOnce
12358
+ );
12359
+ const elapsed = Date.now() - start;
12360
+ if (journalId) {
12361
+ recordOutcome({ journalPath, journalId, phase: "success", durationMs: elapsed });
12362
+ }
12363
+ auditLog({
12364
+ type: "audit",
12365
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
12366
+ correlationId: context.correlationId,
12367
+ identityId: context.identity.id,
12368
+ role: context.identity.role,
12369
+ tool: parsed.toolName,
12370
+ routerId: router.id,
12371
+ phase: "success",
12372
+ params: parsed.params,
12373
+ durationMs: elapsed
12374
+ }, context.appConfig.auditLogPath);
12375
+ return { routerId: router.id, status: "ok", result, durationMs: elapsed };
12376
+ } catch (err) {
12377
+ const elapsed = Date.now() - start;
12378
+ const message = err instanceof Error ? err.message : String(err);
12379
+ if (journalId) {
12380
+ recordOutcome({ journalPath, journalId, phase: "failure", outcome: message, durationMs: elapsed });
12079
12381
  }
12382
+ auditLog({
12383
+ type: "audit",
12384
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
12385
+ correlationId: context.correlationId,
12386
+ identityId: context.identity.id,
12387
+ role: context.identity.role,
12388
+ tool: parsed.toolName,
12389
+ routerId: router.id,
12390
+ phase: "failure",
12391
+ params: parsed.params,
12392
+ outcome: message,
12393
+ durationMs: elapsed
12394
+ }, context.appConfig.auditLogPath);
12395
+ return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
12080
12396
  }
12081
12397
  }
12082
- if (!tool.annotations.readOnlyHint && config.journalPath) {
12083
- journalId = recordAttempt({
12084
- journalPath: config.journalPath,
12085
- identityId: identity.id,
12086
- role: identity.role,
12087
- tool: tool.name,
12088
- routerId: routerId2,
12089
- params: handlerArgs,
12090
- snapshotIds
12091
- });
12092
- }
12093
- const runHandler = () => tool.handler(handlerArgs, toolContext);
12094
- const executeHandler = () => cb.execute(
12095
- tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
12096
- );
12097
- const result = await executeHandler();
12098
- if (journalId) {
12099
- recordOutcome({
12100
- journalPath: config.journalPath,
12101
- journalId,
12102
- phase: "success",
12103
- durationMs: Date.now() - startMs
12104
- });
12105
- }
12106
- if (shouldAudit) {
12107
- auditLog({
12108
- type: "audit",
12109
- ts: (/* @__PURE__ */ new Date()).toISOString(),
12110
- correlationId,
12111
- identityId: identity.id,
12112
- role: identity.role,
12113
- tool: tool.name,
12114
- routerId: routerId2,
12115
- phase: "success",
12116
- params: args,
12117
- durationMs: Date.now() - startMs
12118
- }, config.auditLogPath);
12398
+ const results2 = [...preErrors];
12399
+ for (let i = 0; i < routers.length; i += parsed.concurrency) {
12400
+ const batch = routers.slice(i, i + parsed.concurrency);
12401
+ const batchResults = await Promise.all(batch.map(runForRouter));
12402
+ results2.push(...batchResults);
12119
12403
  }
12120
- log48.info({ tool: tool.name, routerId: routerId2, correlationId }, "Tool executed successfully");
12121
- recordToolCall(tool.name, "success");
12122
- return formatToolResult(result);
12123
- } catch (err) {
12124
- const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
12125
- if (error.category === "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */) {
12126
- const failedRouterId = args.routerId;
12127
- if (failedRouterId) {
12128
- pool.removeClient(failedRouterId);
12129
- log48.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
12404
+ const succeeded = results2.filter((r) => r.status === "ok").length;
12405
+ const failed = results2.filter((r) => r.status === "error").length;
12406
+ auditLog({
12407
+ type: "audit",
12408
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
12409
+ correlationId: context.correlationId,
12410
+ identityId: context.identity.id,
12411
+ role: context.identity.role,
12412
+ tool: "bulk_execute",
12413
+ routerId: "(fleet)",
12414
+ phase: "success",
12415
+ params: { toolName: parsed.toolName, succeeded, failed }
12416
+ }, context.appConfig.auditLogPath);
12417
+ return {
12418
+ content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
12419
+ structuredContent: {
12420
+ toolName: parsed.toolName,
12421
+ totalRouters: results2.length,
12422
+ succeeded,
12423
+ failed,
12424
+ results: results2
12130
12425
  }
12131
- }
12132
- if (journalId) {
12133
- recordOutcome({
12134
- journalPath: config.journalPath,
12135
- journalId,
12136
- phase: "failure",
12137
- outcome: error.code,
12138
- durationMs: Date.now() - startMs
12139
- });
12140
- }
12141
- const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
12142
- if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
12143
- const routerId2 = args.routerId ?? "unknown";
12144
- const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
12145
- auditLog({
12146
- type: "audit",
12147
- ts: (/* @__PURE__ */ new Date()).toISOString(),
12148
- correlationId,
12149
- identityId: identity.id,
12150
- role: identity.role,
12151
- tool: tool.name,
12152
- routerId: routerId2,
12153
- phase: "failure",
12154
- params: args,
12155
- outcome: error.code,
12156
- durationMs: Date.now() - startMs
12157
- }, config.auditLogPath);
12158
- }
12159
- log48.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
12160
- recordToolCall(tool.name, "error");
12161
- return formatError(error);
12426
+ };
12162
12427
  }
12163
- });
12428
+ };
12429
+ return [checkRouterHealthTool, bulkExecuteTool, listRoutersTool];
12164
12430
  }
12165
12431
 
12432
+ // src/domain/tools/index.ts
12433
+ var baseTools = [
12434
+ ...systemTools,
12435
+ ...interfaceTools,
12436
+ ...ipTools,
12437
+ ...dhcpTools,
12438
+ ...routeTools,
12439
+ ...firewallTools,
12440
+ ...diagnosticTools,
12441
+ ...systemOpsTools,
12442
+ ...bridgeTools,
12443
+ ...wifiTools,
12444
+ ...wireguardTools,
12445
+ ...dnsTools,
12446
+ ...mangleTools,
12447
+ ...addressListTools,
12448
+ ...policyRoutingTools,
12449
+ ...routingProtocolTools,
12450
+ ...scriptsTools,
12451
+ ...schedulerTools,
12452
+ ...packagesTools,
12453
+ ...filesTools,
12454
+ ...containerTools,
12455
+ ...ipsecTools,
12456
+ ...certificateTools,
12457
+ ...userTools,
12458
+ ...userGroupTools,
12459
+ ...dhcpServerTools,
12460
+ ...ipPoolTools,
12461
+ ...queueTools,
12462
+ ...vrrpTools,
12463
+ ...networkServicesTools,
12464
+ ...vlanTools,
12465
+ ...dhcpClientTools,
12466
+ ...ipServiceTools,
12467
+ ...pppoeTools,
12468
+ ...ovpnTools,
12469
+ ...pppTools,
12470
+ ...upgradeTools,
12471
+ ...backupTools,
12472
+ ...logTools,
12473
+ ...networkTestTools,
12474
+ ...containerConfigTools,
12475
+ ...interfaceListTools
12476
+ ];
12477
+ var allTools = [
12478
+ ...baseTools,
12479
+ ...createChangeManagementTools(baseTools),
12480
+ ...createFleetTools(baseTools)
12481
+ ];
12482
+
12166
12483
  // src/mcp/tool-registry.ts
12167
12484
  var log49 = createLogger("tool-registry");
12168
12485
  function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
@@ -12280,11 +12597,40 @@ ${issues}`);
12280
12597
  }
12281
12598
  };
12282
12599
 
12600
+ // src/adapter/connection-pool.ts
12601
+ import { createHash as createHash5 } from "crypto";
12602
+
12283
12603
  // src/adapter/rest-client.ts
12284
- import { request, Agent } from "undici";
12604
+ import { request, Agent as Agent2 } from "undici";
12285
12605
 
12286
12606
  // src/adapter/tls-manager.ts
12287
12607
  import { readFileSync as readFileSync3 } from "fs";
12608
+ import { buildConnector } from "undici";
12609
+ function makePinnedConnector(inner, expectedFingerprint) {
12610
+ const expected = expectedFingerprint.replace(/:/g, "").toLowerCase();
12611
+ return (opts, cb) => {
12612
+ inner(opts, (err, socket) => {
12613
+ if (err || !socket) {
12614
+ cb(err ?? new Error("TLS connection failed"), null);
12615
+ return;
12616
+ }
12617
+ const peer = socket;
12618
+ const cert = typeof peer.getPeerCertificate === "function" ? peer.getPeerCertificate() : void 0;
12619
+ const actual = (cert?.fingerprint256 ?? "").replace(/:/g, "").toLowerCase();
12620
+ if (actual !== expected) {
12621
+ socket.destroy();
12622
+ cb(
12623
+ new Error(
12624
+ `TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual || "none"}`
12625
+ ),
12626
+ null
12627
+ );
12628
+ return;
12629
+ }
12630
+ cb(null, socket);
12631
+ });
12632
+ };
12633
+ }
12288
12634
  function buildAgentOptions(tls) {
12289
12635
  if (!tls.enabled) {
12290
12636
  return {};
@@ -12296,53 +12642,14 @@ function buildAgentOptions(tls) {
12296
12642
  connectOptions.ca = readFileSync3(tls.ca, "utf-8");
12297
12643
  }
12298
12644
  if (tls.fingerprint) {
12299
- const expected = tls.fingerprint.replace(/:/g, "").toLowerCase();
12300
- connectOptions.checkServerIdentity = (_host, cert) => {
12301
- const actual = cert.fingerprint256.replace(/:/g, "").toLowerCase();
12302
- if (actual !== expected) {
12303
- return new Error(
12304
- `TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual}`
12305
- );
12306
- }
12307
- return void 0;
12308
- };
12645
+ const inner = buildConnector(connectOptions);
12646
+ return { connect: makePinnedConnector(inner, tls.fingerprint) };
12309
12647
  }
12310
12648
  return {
12311
12649
  connect: connectOptions
12312
12650
  };
12313
12651
  }
12314
12652
 
12315
- // src/adapter/response-parser.ts
12316
- var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
12317
- var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
12318
- function parseRouterOSValue(key2, value) {
12319
- if (key2 === ".id") {
12320
- return value;
12321
- }
12322
- if (value === "true") return true;
12323
- if (value === "false") return false;
12324
- if (DURATION_RE.test(value)) {
12325
- return value;
12326
- }
12327
- if (NUMERIC_RE.test(value)) {
12328
- const num = Number(value);
12329
- if (Number.isFinite(num)) {
12330
- return num;
12331
- }
12332
- }
12333
- return value;
12334
- }
12335
- function parseRecord(raw) {
12336
- const parsed = {};
12337
- for (const [key2, value] of Object.entries(raw)) {
12338
- parsed[key2] = parseRouterOSValue(key2, value);
12339
- }
12340
- return parsed;
12341
- }
12342
- function parseRecords(raw) {
12343
- return raw.map((r) => parseRecord(r));
12344
- }
12345
-
12346
12653
  // src/adapter/query-builder.ts
12347
12654
  function buildListQuery(options) {
12348
12655
  if (!options) {
@@ -12404,7 +12711,7 @@ var RouterOSRestClient = class {
12404
12711
  const scheme = config.tls.enabled ? "https" : "http";
12405
12712
  this.baseUrl = `${scheme}://${config.host}:${config.port}/rest`;
12406
12713
  this.authHeader = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64");
12407
- this.agent = new Agent(buildAgentOptions(config.tls));
12714
+ this.agent = new Agent2(buildAgentOptions(config.tls));
12408
12715
  }
12409
12716
  // ---------- public CRUD methods ----------
12410
12717
  /**
@@ -12497,19 +12804,27 @@ var RouterOSRestClient = class {
12497
12804
  };
12498
12805
 
12499
12806
  // src/adapter/connection-pool.ts
12807
+ function credentialHash(credentials) {
12808
+ return createHash5("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
12809
+ }
12500
12810
  var ConnectionPool = class {
12501
12811
  clients = /* @__PURE__ */ new Map();
12502
12812
  /**
12503
- * Get an existing client for the given router or create a new one.
12504
- * Clients are keyed by `config.id`.
12813
+ * Get an existing client for the given router or create a new one. Clients are
12814
+ * keyed by `config.id`, but a client whose credentials no longer match the
12815
+ * request is closed and rebuilt so rotated credentials take effect.
12505
12816
  */
12506
12817
  getClient(config, credentials) {
12818
+ const credHash = credentialHash(credentials);
12507
12819
  const existing = this.clients.get(config.id);
12508
12820
  if (existing) {
12509
- return existing;
12821
+ if (existing.credHash === credHash) {
12822
+ return existing.client;
12823
+ }
12824
+ existing.client.close();
12510
12825
  }
12511
12826
  const client = new RouterOSRestClient(config, credentials);
12512
- this.clients.set(config.id, client);
12827
+ this.clients.set(config.id, { client, credHash });
12513
12828
  return client;
12514
12829
  }
12515
12830
  /**
@@ -12517,12 +12832,12 @@ var ConnectionPool = class {
12517
12832
  * `/rest/system/resource`. Returns `true` if the request succeeds.
12518
12833
  */
12519
12834
  async healthCheck(routerId2) {
12520
- const client = this.clients.get(routerId2);
12521
- if (!client) {
12835
+ const entry = this.clients.get(routerId2);
12836
+ if (!entry) {
12522
12837
  return false;
12523
12838
  }
12524
12839
  try {
12525
- await client.get("system/resource");
12840
+ await entry.client.get("system/resource");
12526
12841
  return true;
12527
12842
  } catch {
12528
12843
  return false;
@@ -12530,23 +12845,23 @@ var ConnectionPool = class {
12530
12845
  }
12531
12846
  /** Remove and close a single client by router ID. */
12532
12847
  removeClient(routerId2) {
12533
- const client = this.clients.get(routerId2);
12534
- if (client) {
12535
- client.close();
12848
+ const entry = this.clients.get(routerId2);
12849
+ if (entry) {
12850
+ entry.client.close();
12536
12851
  this.clients.delete(routerId2);
12537
12852
  }
12538
12853
  }
12539
12854
  /** Close and remove all clients. */
12540
12855
  closeAll() {
12541
- for (const [id, client] of this.clients) {
12542
- client.close();
12856
+ for (const [id, entry] of this.clients) {
12857
+ entry.client.close();
12543
12858
  this.clients.delete(id);
12544
12859
  }
12545
12860
  }
12546
12861
  };
12547
12862
 
12548
12863
  // src/version.ts
12549
- var VERSION = "1.7.0";
12864
+ var VERSION = "1.8.0";
12550
12865
 
12551
12866
  // src/mcp/server.ts
12552
12867
  var SERVER_INSTRUCTIONS = [
@@ -12589,6 +12904,7 @@ import { createServer } from "http";
12589
12904
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12590
12905
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
12591
12906
  var log51 = createLogger("transport-http");
12907
+ var SESSION_TTL_MS = 30 * 6e4;
12592
12908
  var BodyTooLargeError = class extends Error {
12593
12909
  constructor() {
12594
12910
  super("Request body exceeds maximum allowed size");
@@ -12625,7 +12941,7 @@ function createRateLimiter(rpm) {
12625
12941
  }
12626
12942
  async function readBody(req, maxBytes) {
12627
12943
  return new Promise((resolve, reject) => {
12628
- let data = "";
12944
+ const chunks = [];
12629
12945
  let size = 0;
12630
12946
  let settled = false;
12631
12947
  const fail2 = (err) => {
@@ -12640,11 +12956,12 @@ async function readBody(req, maxBytes) {
12640
12956
  fail2(new BodyTooLargeError());
12641
12957
  return;
12642
12958
  }
12643
- data += chunk.toString();
12959
+ chunks.push(chunk);
12644
12960
  });
12645
12961
  req.on("end", () => {
12646
12962
  if (settled) return;
12647
12963
  settled = true;
12964
+ const data = Buffer.concat(chunks).toString("utf-8");
12648
12965
  if (!data) {
12649
12966
  resolve(void 0);
12650
12967
  return;
@@ -12665,6 +12982,17 @@ async function connectHttp(makeServer, config, identityRegistry) {
12665
12982
  sweepTimer.unref();
12666
12983
  const streamableSessions = /* @__PURE__ */ new Map();
12667
12984
  const sseTransports = /* @__PURE__ */ new Map();
12985
+ const sessionSweep = setInterval(() => {
12986
+ const now = Date.now();
12987
+ for (const [id, session] of streamableSessions) {
12988
+ if (now - session.lastSeenMs > SESSION_TTL_MS) {
12989
+ streamableSessions.delete(id);
12990
+ session.transport.close();
12991
+ log51.info({ sessionId: id }, "Streamable HTTP session expired (idle TTL)");
12992
+ }
12993
+ }
12994
+ }, 6e4);
12995
+ sessionSweep.unref();
12668
12996
  const httpServer = createServer(async (req, res) => {
12669
12997
  const ip = req.socket.remoteAddress ?? "unknown";
12670
12998
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
@@ -12675,6 +13003,21 @@ async function connectHttp(makeServer, config, identityRegistry) {
12675
13003
  return;
12676
13004
  }
12677
13005
  if (pathname === "/metrics") {
13006
+ if (identityRegistry.getIdentities().length > 0) {
13007
+ try {
13008
+ await authenticateHttp(req, identityRegistry);
13009
+ } catch (err) {
13010
+ if (err instanceof MikroMCPError) {
13011
+ res.writeHead(401, {
13012
+ "Content-Type": "application/json",
13013
+ "WWW-Authenticate": "Bearer"
13014
+ });
13015
+ res.end(JSON.stringify({ error: err.message, code: err.code }));
13016
+ return;
13017
+ }
13018
+ throw err;
13019
+ }
13020
+ }
12678
13021
  res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
12679
13022
  res.end(renderPrometheus());
12680
13023
  return;
@@ -12692,14 +13035,18 @@ async function connectHttp(makeServer, config, identityRegistry) {
12692
13035
  identity = await authenticateHttp(req, identityRegistry);
12693
13036
  } catch (err) {
12694
13037
  if (err instanceof MikroMCPError) {
12695
- res.writeHead(401, { "Content-Type": "application/json" });
13038
+ res.writeHead(401, {
13039
+ "Content-Type": "application/json",
13040
+ "WWW-Authenticate": "Bearer"
13041
+ });
12696
13042
  res.end(JSON.stringify({ error: err.message, code: err.code }));
12697
13043
  return;
12698
13044
  }
12699
13045
  throw err;
12700
13046
  }
12701
- await withIdentity(identity, async () => {
12702
- await handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports);
13047
+ const boundIdentity = identity;
13048
+ await withIdentity(boundIdentity, async () => {
13049
+ await handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports, boundIdentity.id);
12703
13050
  });
12704
13051
  return;
12705
13052
  }
@@ -12728,7 +13075,10 @@ async function connectHttp(makeServer, config, identityRegistry) {
12728
13075
  }
12729
13076
  }
12730
13077
  });
12731
- httpServer.on("close", () => clearInterval(sweepTimer));
13078
+ httpServer.on("close", () => {
13079
+ clearInterval(sweepTimer);
13080
+ clearInterval(sessionSweep);
13081
+ });
12732
13082
  await new Promise((resolve, reject) => {
12733
13083
  httpServer.listen(port, bindHost, () => {
12734
13084
  log51.info(
@@ -12741,10 +13091,19 @@ async function connectHttp(makeServer, config, identityRegistry) {
12741
13091
  });
12742
13092
  return httpServer;
12743
13093
  }
12744
- async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports) {
13094
+ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports, identityId) {
12745
13095
  if (pathname === "/mcp") {
12746
13096
  const sessionId = req.headers["mcp-session-id"];
12747
- let transport2 = sessionId ? streamableSessions.get(sessionId) : void 0;
13097
+ let session = sessionId ? streamableSessions.get(sessionId) : void 0;
13098
+ if (session) {
13099
+ if (session.identityId !== identityId) {
13100
+ res.writeHead(403, { "Content-Type": "application/json" });
13101
+ res.end(JSON.stringify({ error: "Session belongs to a different identity." }));
13102
+ return;
13103
+ }
13104
+ session.lastSeenMs = Date.now();
13105
+ }
13106
+ let transport2 = session?.transport;
12748
13107
  if (!transport2) {
12749
13108
  if (sessionId) {
12750
13109
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -12754,8 +13113,8 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
12754
13113
  transport2 = new StreamableHTTPServerTransport({
12755
13114
  sessionIdGenerator: () => randomUUID(),
12756
13115
  onsessioninitialized: (id) => {
12757
- streamableSessions.set(id, transport2);
12758
- log51.info({ sessionId: id }, "Streamable HTTP session created");
13116
+ streamableSessions.set(id, { transport: transport2, identityId, lastSeenMs: Date.now() });
13117
+ log51.info({ sessionId: id, identityId }, "Streamable HTTP session created");
12759
13118
  }
12760
13119
  });
12761
13120
  transport2.onclose = () => {
@@ -12798,6 +13157,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
12798
13157
 
12799
13158
  // src/config/identity-registry.ts
12800
13159
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
13160
+ import { createHash as createHash6 } from "crypto";
12801
13161
  import { parse as parse2 } from "yaml";
12802
13162
  import { z as z49 } from "zod";
12803
13163
  import bcrypt from "bcryptjs";
@@ -12813,6 +13173,9 @@ var ConfigFileSchema2 = z49.object({
12813
13173
  }).strict();
12814
13174
  var IdentityRegistry = class {
12815
13175
  identities = [];
13176
+ // Maps sha256(token) → resolved identity id. Avoids a bcrypt compare against
13177
+ // every identity on each authenticated request after the first match.
13178
+ tokenCache = /* @__PURE__ */ new Map();
12816
13179
  constructor(configPath) {
12817
13180
  if (!existsSync2(configPath)) {
12818
13181
  log52.warn(
@@ -12849,15 +13212,24 @@ ${issues}`);
12849
13212
  }));
12850
13213
  }
12851
13214
  async findIdentityByToken(token) {
13215
+ const cacheKey = createHash6("sha256").update(token).digest("hex");
13216
+ const toIdentity = (stored) => ({
13217
+ id: stored.id,
13218
+ role: stored.role,
13219
+ allowedRouters: stored.allowedRouters,
13220
+ allowedToolPatterns: stored.allowedToolPatterns
13221
+ });
13222
+ const cachedId = this.tokenCache.get(cacheKey);
13223
+ if (cachedId !== void 0) {
13224
+ const stored = this.identities.find((i) => i.id === cachedId);
13225
+ if (stored) return toIdentity(stored);
13226
+ this.tokenCache.delete(cacheKey);
13227
+ }
12852
13228
  for (const stored of this.identities) {
12853
13229
  const match = await bcrypt.compare(token, stored.tokenHash);
12854
13230
  if (match) {
12855
- return {
12856
- id: stored.id,
12857
- role: stored.role,
12858
- allowedRouters: stored.allowedRouters,
12859
- allowedToolPatterns: stored.allowedToolPatterns
12860
- };
13231
+ this.tokenCache.set(cacheKey, stored.id);
13232
+ return toIdentity(stored);
12861
13233
  }
12862
13234
  }
12863
13235
  return null;
@@ -12957,7 +13329,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
12957
13329
  import { homedir as homedir2 } from "os";
12958
13330
  import * as net from "net";
12959
13331
  import chalk from "chalk";
12960
- var localVersion = "1.7.0";
13332
+ var localVersion = "1.8.0";
12961
13333
  var results = [];
12962
13334
  function ok(msg) {
12963
13335
  console.log(chalk.green("\u2705 ") + msg);
@@ -13669,7 +14041,7 @@ async function runInit() {
13669
14041
  }
13670
14042
 
13671
14043
  // src/cli/index.ts
13672
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.7.0");
14044
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.8.0");
13673
14045
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
13674
14046
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
13675
14047
  program.command("init").description("Interactive setup wizard").action(runInit);