mikromcp 1.0.10 → 1.2.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 +15 -8
  2. package/dist/main.js +1759 -946
  3. package/package.json +2 -1
package/dist/main.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/main.ts
4
4
  import { config as loadDotenv } from "dotenv";
5
5
  import { join as join5 } from "path";
6
- import { homedir as homedir5 } from "os";
6
+ import { homedir as homedir4 } from "os";
7
7
 
8
8
  // src/cli/index.ts
9
9
  import { Command } from "commander";
@@ -50,9 +50,8 @@ function loadAppConfig() {
50
50
  failureThreshold: 5,
51
51
  cooldownMs: 3e4
52
52
  },
53
- pagination: {
54
- defaultLimit: 100,
55
- maxLimit: 500
53
+ retention: {
54
+ snapshotMaxAgeDays: parseInt(env.MIKROMCP_SNAPSHOT_RETENTION_DAYS ?? "30", 10)
56
55
  }
57
56
  };
58
57
  }
@@ -61,7 +60,7 @@ function loadAppConfig() {
61
60
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
62
61
 
63
62
  // src/mcp/tool-registry.ts
64
- import { z as z32 } from "zod";
63
+ import { z as z36 } from "zod";
65
64
 
66
65
  // src/domain/tools/system-tools.ts
67
66
  import { z } from "zod";
@@ -371,8 +370,8 @@ function buildTextSummary(sections) {
371
370
  if (sections.health) {
372
371
  const h = sections.health;
373
372
  lines.push(`Health:`);
374
- for (const [key, value] of Object.entries(h)) {
375
- if (key !== ".id") lines.push(` ${key}: ${value}`);
373
+ for (const [key2, value] of Object.entries(h)) {
374
+ if (key2 !== ".id") lines.push(` ${key2}: ${value}`);
376
375
  }
377
376
  }
378
377
  if (sections.clock) {
@@ -447,7 +446,12 @@ var COUNTER_PROPS = [
447
446
  "rx-drop",
448
447
  "tx-error",
449
448
  "rx-error",
450
- "tx-queue-drop"
449
+ "tx-queue-drop",
450
+ "fp-tx-byte",
451
+ "fp-rx-byte",
452
+ "fp-tx-packet",
453
+ "fp-rx-packet",
454
+ "fp-rps-drop"
451
455
  ];
452
456
  var listInterfacesTool = {
453
457
  name: "list_interfaces",
@@ -491,14 +495,17 @@ var listInterfacesTool = {
491
495
  }
492
496
  const total = interfaces.length;
493
497
  const paginated = interfaces.slice(parsed.offset, parsed.offset + parsed.limit);
494
- const results2 = parsed.includeCounters ? paginated : paginated.map((iface) => {
495
- const cleaned = {};
496
- for (const [key, value] of Object.entries(iface)) {
497
- if (!COUNTER_PROPS.includes(key)) {
498
- cleaned[key] = value;
498
+ const results2 = paginated.map((iface) => {
499
+ const rec = iface;
500
+ const isRunning = rec.running === true || rec.running === "true";
501
+ const isDisabled = rec.disabled === true || rec.disabled === "true";
502
+ const enriched = { status: isDisabled ? "disabled" : isRunning ? "up" : "down" };
503
+ for (const [key2, value] of Object.entries(rec)) {
504
+ if (parsed.includeCounters || !COUNTER_PROPS.includes(key2)) {
505
+ enriched[key2] = value;
499
506
  }
500
507
  }
501
- return cleaned;
508
+ return enriched;
502
509
  });
503
510
  const hasMore = parsed.offset + parsed.limit < total;
504
511
  const lines = [
@@ -508,8 +515,8 @@ var listInterfacesTool = {
508
515
  const rec = iface;
509
516
  const name = rec.name ?? rec[".id"] ?? "unknown";
510
517
  const type = rec.type ?? parsed.type;
511
- const running = rec.running === "true" ? "UP" : "DOWN";
512
- lines.push(` ${name} [${type}] ${running}`);
518
+ const status = String(rec.status).toUpperCase();
519
+ lines.push(` ${name} [${type}] ${status}`);
513
520
  }
514
521
  return {
515
522
  content: lines.join("\n"),
@@ -527,115 +534,7 @@ var listInterfacesTool = {
527
534
  }
528
535
  }
529
536
  };
530
- var createVlanInputSchema = z2.object({
531
- routerId: z2.string().describe("Target router identifier from the router registry"),
532
- name: z2.string().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe("VLAN interface name (alphanumeric, hyphens, underscores, max 15 chars)"),
533
- vlanId: z2.number().int().min(1).max(4094).describe("VLAN ID (1-4094)"),
534
- parentInterface: z2.string().describe("Parent interface name (e.g., ether1, bridge1)"),
535
- mtu: z2.number().int().min(68).max(9e3).default(1500).describe("MTU size"),
536
- disabled: z2.boolean().default(false).describe("Whether the VLAN interface should be disabled"),
537
- comment: z2.string().max(255).optional().describe("Optional comment for the VLAN interface"),
538
- dryRun: z2.boolean().default(false).describe("If true, validate and return planned changes without applying")
539
- }).strict();
540
- function sanitizeComment(comment) {
541
- if (comment === void 0) return void 0;
542
- return comment.replace(/[\x00-\x1f\x7f]/g, "");
543
- }
544
- var createVlanTool = {
545
- name: "create_vlan",
546
- title: "Create VLAN Interface",
547
- description: "Create a new VLAN interface on a MikroTik router. Performs idempotency checks: if a VLAN with the same name already exists with matching configuration, returns success without changes. Supports dry-run mode.",
548
- inputSchema: createVlanInputSchema,
549
- annotations: {
550
- readOnlyHint: false,
551
- destructiveHint: false,
552
- idempotentHint: true,
553
- openWorldHint: false
554
- },
555
- async handler(params, context) {
556
- const parsed = createVlanInputSchema.parse(params);
557
- const comment = sanitizeComment(parsed.comment);
558
- log2.info(
559
- { routerId: context.routerId, name: parsed.name, vlanId: parsed.vlanId },
560
- "Creating VLAN interface"
561
- );
562
- try {
563
- const existing = await context.routerClient.get("interface/vlan", {
564
- filter: { name: parsed.name }
565
- });
566
- if (existing.length > 0) {
567
- const found = existing[0];
568
- const existingVlanId = found["vlan-id"];
569
- const existingInterface = found.interface;
570
- if (existingVlanId === String(parsed.vlanId) && existingInterface === parsed.parentInterface) {
571
- log2.info({ name: parsed.name }, "VLAN already exists with matching config");
572
- return {
573
- content: `VLAN "${parsed.name}" already exists with VLAN ID ${parsed.vlanId} on ${parsed.parentInterface}. No changes made.`,
574
- structuredContent: {
575
- action: "already_exists",
576
- vlan: found
577
- }
578
- };
579
- }
580
- throw new MikroMCPError({
581
- category: "CONFLICT" /* CONFLICT */,
582
- code: "VLAN_NAME_CONFLICT",
583
- message: `VLAN "${parsed.name}" already exists but with different configuration (vlan-id=${existingVlanId}, interface=${existingInterface}). Expected vlan-id=${parsed.vlanId}, interface=${parsed.parentInterface}.`,
584
- details: {
585
- existingVlanId,
586
- existingInterface,
587
- requestedVlanId: parsed.vlanId,
588
- requestedInterface: parsed.parentInterface
589
- },
590
- recoverability: {
591
- retryable: false,
592
- suggestedAction: "Use a different name or update the existing VLAN."
593
- }
594
- });
595
- }
596
- if (parsed.dryRun) {
597
- const diff = [
598
- { property: "name", before: null, after: parsed.name },
599
- { property: "vlan-id", before: null, after: String(parsed.vlanId) },
600
- { property: "interface", before: null, after: parsed.parentInterface },
601
- { property: "mtu", before: null, after: String(parsed.mtu) },
602
- { property: "disabled", before: null, after: parsed.disabled ? "true" : "false" },
603
- ...comment ? [{ property: "comment", before: null, after: comment }] : []
604
- ];
605
- return {
606
- content: `Dry run: Would create VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
607
- structuredContent: {
608
- action: "dry_run",
609
- diff
610
- }
611
- };
612
- }
613
- const body = {
614
- name: parsed.name,
615
- "vlan-id": String(parsed.vlanId),
616
- interface: parsed.parentInterface,
617
- mtu: String(parsed.mtu),
618
- disabled: parsed.disabled ? "true" : "false"
619
- };
620
- if (comment) {
621
- body.comment = comment;
622
- }
623
- const created = await context.routerClient.create("interface/vlan", body);
624
- log2.info({ name: parsed.name, id: created[".id"] }, "VLAN created successfully");
625
- return {
626
- content: `Created VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
627
- structuredContent: {
628
- action: "created",
629
- vlan: created
630
- }
631
- };
632
- } catch (err) {
633
- if (err instanceof MikroMCPError) throw err;
634
- throw enrichError(err, { routerId: context.routerId, tool: "create_vlan" });
635
- }
636
- }
637
- };
638
- var interfaceTools = [listInterfacesTool, createVlanTool];
537
+ var interfaceTools = [listInterfacesTool];
639
538
 
640
539
  // src/domain/tools/ip-tools.ts
641
540
  import { z as z4 } from "zod";
@@ -686,7 +585,7 @@ var inputSchema2 = z4.object({
686
585
  disabled: z4.boolean().default(false).describe("Whether the IP address should be disabled"),
687
586
  dryRun: z4.boolean().default(false).describe("If true, validate and return planned changes without applying")
688
587
  }).strict();
689
- function sanitizeComment2(comment) {
588
+ function sanitizeComment(comment) {
690
589
  if (comment === void 0) return void 0;
691
590
  return comment.replace(/[\x00-\x1f\x7f]/g, "");
692
591
  }
@@ -710,7 +609,7 @@ var manageIpAddressTool = {
710
609
  snapshotPaths: ["ip/address"],
711
610
  async handler(params, context) {
712
611
  const parsed = inputSchema2.parse(params);
713
- const comment = sanitizeComment2(parsed.comment);
612
+ const comment = sanitizeComment(parsed.comment);
714
613
  log3.info(
715
614
  {
716
615
  routerId: context.routerId,
@@ -899,13 +798,14 @@ var listDhcpLeasesInputSchema = z5.object({
899
798
  server: z5.string().optional().describe("Filter by DHCP server name"),
900
799
  status: z5.enum(["bound", "waiting", "offered", "blocked", "all"]).default("all").describe("Filter by lease status"),
901
800
  macAddress: z5.string().optional().describe("Filter by MAC address (exact match, case-insensitive)"),
801
+ leaseType: z5.enum(["dynamic", "static", "all"]).default("all").describe("Filter by lease type (dynamic or static)"),
902
802
  limit: z5.number().int().min(1).max(500).default(100).describe("Maximum number of leases to return"),
903
803
  offset: z5.number().int().min(0).default(0).describe("Offset for pagination")
904
804
  }).strict();
905
805
  var listDhcpLeasesTool = {
906
806
  name: "list_dhcp_leases",
907
807
  title: "List DHCP Leases",
908
- description: "List DHCP leases on a MikroTik router with optional filtering by server, status, and MAC address. Supports pagination.",
808
+ description: "List DHCP leases on a MikroTik router with optional filtering by server, status, lease type (dynamic/static), and MAC address. Supports pagination.",
909
809
  inputSchema: listDhcpLeasesInputSchema,
910
810
  annotations: {
911
811
  readOnlyHint: true,
@@ -949,6 +849,14 @@ var listDhcpLeasesTool = {
949
849
  return leaseMAC === upperMac;
950
850
  });
951
851
  }
852
+ if (parsed.leaseType !== "all") {
853
+ leases = leases.filter((lease) => {
854
+ const rec = lease;
855
+ const isDynamic = rec.dynamic === true || rec.dynamic === "true";
856
+ if (parsed.leaseType === "dynamic") return isDynamic;
857
+ return !isDynamic;
858
+ });
859
+ }
952
860
  const total = leases.length;
953
861
  const paginated = leases.slice(parsed.offset, parsed.offset + parsed.limit);
954
862
  const hasMore = parsed.offset + parsed.limit < total;
@@ -980,7 +888,110 @@ var listDhcpLeasesTool = {
980
888
  }
981
889
  }
982
890
  };
983
- var dhcpTools = [listDhcpLeasesTool];
891
+ var manageDhcpLeaseInputSchema = z5.object({
892
+ routerId: z5.string().describe("Target router identifier from the router registry"),
893
+ action: z5.enum(["make-static", "remove"]).describe(
894
+ "Action to perform: make-static converts a dynamic lease to static; remove deletes the lease"
895
+ ),
896
+ macAddress: z5.string().describe("MAC address of the lease \u2014 idempotency key (case-insensitive)"),
897
+ dryRun: z5.boolean().default(false).describe("Preview changes without applying")
898
+ }).strict();
899
+ var manageDhcpLeaseTool = {
900
+ name: "manage_dhcp_lease",
901
+ title: "Manage DHCP Lease",
902
+ description: "Convert a dynamic DHCP lease to static (make-static) or remove a lease. Idempotent by MAC address.",
903
+ inputSchema: manageDhcpLeaseInputSchema,
904
+ annotations: {
905
+ readOnlyHint: false,
906
+ destructiveHint: false,
907
+ idempotentHint: true,
908
+ openWorldHint: false
909
+ },
910
+ snapshotPaths: ["ip/dhcp-server/lease"],
911
+ async handler(params, context) {
912
+ const parsed = manageDhcpLeaseInputSchema.parse(params);
913
+ const upperMac = parsed.macAddress.toUpperCase();
914
+ log4.info(
915
+ { routerId: context.routerId, action: parsed.action, macAddress: upperMac },
916
+ "Managing DHCP lease"
917
+ );
918
+ try {
919
+ const allLeases = await context.routerClient.get("ip/dhcp-server/lease", {
920
+ limit: void 0,
921
+ offset: void 0
922
+ });
923
+ const existing = allLeases.find(
924
+ (l) => (l["mac-address"] ?? "").toUpperCase() === upperMac
925
+ );
926
+ if (parsed.action === "make-static") {
927
+ if (!existing) {
928
+ throw new MikroMCPError({
929
+ category: "NOT_FOUND" /* NOT_FOUND */,
930
+ code: "DHCP_LEASE_NOT_FOUND",
931
+ message: `No DHCP lease found for MAC address ${upperMac}.`,
932
+ details: { macAddress: upperMac },
933
+ recoverability: {
934
+ retryable: false,
935
+ suggestedAction: "Verify the MAC address with list_dhcp_leases.",
936
+ alternativeTools: ["list_dhcp_leases"]
937
+ }
938
+ });
939
+ }
940
+ const existingDynamic = existing.dynamic;
941
+ const isAlreadyStatic = existingDynamic === "false" || existingDynamic === false;
942
+ if (isAlreadyStatic) {
943
+ return {
944
+ content: `Lease for ${upperMac} (${existing.address ?? "unknown"}) is already static. No changes made.`,
945
+ structuredContent: { action: "already_static", macAddress: upperMac, id: existing[".id"] }
946
+ };
947
+ }
948
+ if (parsed.dryRun) {
949
+ return {
950
+ content: `Dry run: Would convert lease for ${upperMac} (${existing.address ?? "unknown"}) to static.`,
951
+ structuredContent: {
952
+ action: "dry_run",
953
+ diff: [{ property: "dynamic", before: "true", after: "false" }],
954
+ lease: existing
955
+ }
956
+ };
957
+ }
958
+ await context.routerClient.update("ip/dhcp-server/lease", existing[".id"], {
959
+ dynamic: "false"
960
+ });
961
+ log4.info({ macAddress: upperMac, id: existing[".id"] }, "DHCP lease converted to static");
962
+ return {
963
+ content: `Lease for ${upperMac} (${existing.address ?? "unknown"}) converted to static.`,
964
+ structuredContent: { action: "made-static", macAddress: upperMac, id: existing[".id"] }
965
+ };
966
+ }
967
+ if (!existing) {
968
+ return {
969
+ content: `No DHCP lease found for MAC address ${upperMac}. Nothing to remove.`,
970
+ structuredContent: { action: "not_found", macAddress: upperMac }
971
+ };
972
+ }
973
+ if (parsed.dryRun) {
974
+ return {
975
+ content: `Dry run: Would remove lease for ${upperMac}.`,
976
+ structuredContent: {
977
+ action: "dry_run",
978
+ diff: [{ property: "mac-address", before: upperMac, after: null }]
979
+ }
980
+ };
981
+ }
982
+ await context.routerClient.remove("ip/dhcp-server/lease", existing[".id"]);
983
+ log4.info({ macAddress: upperMac }, "DHCP lease removed");
984
+ return {
985
+ content: `Removed DHCP lease for ${upperMac}.`,
986
+ structuredContent: { action: "removed", macAddress: upperMac, id: existing[".id"] }
987
+ };
988
+ } catch (err) {
989
+ if (err instanceof MikroMCPError) throw err;
990
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_dhcp_lease" });
991
+ }
992
+ }
993
+ };
994
+ var dhcpTools = [listDhcpLeasesTool, manageDhcpLeaseTool];
984
995
 
985
996
  // src/domain/tools/route-tools.ts
986
997
  import { z as z6 } from "zod";
@@ -1084,7 +1095,7 @@ var manageRouteInputSchema = z6.object({
1084
1095
  disabled: z6.boolean().default(false).describe("Whether the route should be disabled"),
1085
1096
  dryRun: z6.boolean().default(false).describe("If true, validate and return planned changes without applying")
1086
1097
  }).strict();
1087
- function sanitizeComment3(comment) {
1098
+ function sanitizeComment2(comment) {
1088
1099
  if (comment === void 0) return void 0;
1089
1100
  return comment.replace(/[\x00-\x1f\x7f]/g, "");
1090
1101
  }
@@ -1112,7 +1123,7 @@ var manageRouteTool = {
1112
1123
  snapshotPaths: ["ip/route"],
1113
1124
  async handler(params, context) {
1114
1125
  const parsed = manageRouteInputSchema.parse(params);
1115
- const comment = sanitizeComment3(parsed.comment);
1126
+ const comment = sanitizeComment2(parsed.comment);
1116
1127
  log5.info(
1117
1128
  {
1118
1129
  routerId: context.routerId,
@@ -1253,7 +1264,7 @@ var log6 = createLogger("firewall-tools");
1253
1264
  function tableToPath(table) {
1254
1265
  return table === "filter" ? "ip/firewall/filter" : "ip/firewall/nat";
1255
1266
  }
1256
- function sanitizeComment4(comment) {
1267
+ function sanitizeComment3(comment) {
1257
1268
  if (comment === void 0) return void 0;
1258
1269
  return comment.replace(/[\x00-\x1f\x7f]/g, "");
1259
1270
  }
@@ -1392,7 +1403,7 @@ var manageFirewallRuleTool = {
1392
1403
  snapshotPaths: ["ip/firewall/filter", "ip/firewall/nat"],
1393
1404
  async handler(params, context) {
1394
1405
  const parsed = manageFirewallRuleInputSchema.parse(params);
1395
- const comment = sanitizeComment4(parsed.comment);
1406
+ const comment = sanitizeComment3(parsed.comment);
1396
1407
  log6.info(
1397
1408
  {
1398
1409
  routerId: context.routerId,
@@ -2112,8 +2123,8 @@ var runCommandTool = {
2112
2123
  async handler(params, context) {
2113
2124
  const parsed = runCommandInputSchema.parse(params);
2114
2125
  log8.info({ routerId: context.routerId, command: parsed.command }, "Running command");
2115
- const globalAllow = (process.env.MIKROMCP_CMD_ALLOW ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2116
- const globalDeny = (process.env.MIKROMCP_CMD_DENY ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2126
+ const globalAllow = context.appConfig.cmdAllow;
2127
+ const globalDeny = context.appConfig.cmdDeny;
2117
2128
  const policy = resolveCommandPolicy(context.routerConfig, globalAllow, globalDeny);
2118
2129
  checkCommand(parsed.command, policy);
2119
2130
  if (parsed.dryRun) {
@@ -2711,9 +2722,9 @@ var listPeersTool = {
2711
2722
  const lines = [`WireGuard peers on ${context.routerId}: ${total} total`];
2712
2723
  for (const peer of peers) {
2713
2724
  const rec = peer;
2714
- const key = rec["public-key"] ? rec["public-key"].slice(0, 8) + "\u2026" : "unknown";
2725
+ const key2 = rec["public-key"] ? rec["public-key"].slice(0, 8) + "\u2026" : "unknown";
2715
2726
  lines.push(
2716
- ` [${rec.interface ?? "?"}] ${key} last-handshake=${rec["last-handshake"] ?? "never"}`
2727
+ ` [${rec.interface ?? "?"}] ${key2} last-handshake=${rec["last-handshake"] ?? "never"}`
2717
2728
  );
2718
2729
  }
2719
2730
  return {
@@ -4905,12 +4916,10 @@ var containerTools = [listContainersTool, manageContainerTool];
4905
4916
 
4906
4917
  // src/domain/tools/change-management-tools.ts
4907
4918
  import { z as z23 } from "zod";
4908
- import { readFileSync as readFileSync2 } from "fs";
4909
- import { join as join3 } from "path";
4910
- import { homedir as homedir2 } from "os";
4919
+ import { readFileSync } from "fs";
4911
4920
 
4912
4921
  // src/domain/snapshot/snapshot-engine.ts
4913
- import { mkdirSync, writeFileSync, readFileSync } from "fs";
4922
+ import { mkdir, writeFile, readFile } from "fs/promises";
4914
4923
  import { join as join2 } from "path";
4915
4924
  import { nanoid } from "nanoid";
4916
4925
  function pathToSlug(rosPath) {
@@ -4925,13 +4934,13 @@ async function takeSnapshot(client, routerId, path, snapshotDir) {
4925
4934
  const dir = join2(snapshotDir, routerId);
4926
4935
  const filePath = join2(dir, `${id}.json`);
4927
4936
  const ts = (/* @__PURE__ */ new Date()).toISOString();
4928
- mkdirSync(dir, { recursive: true });
4937
+ await mkdir(dir, { recursive: true });
4929
4938
  const stored = { id, routerId, path, ts, records };
4930
- writeFileSync(filePath, JSON.stringify(stored, null, 2));
4939
+ await writeFile(filePath, JSON.stringify(stored, null, 2));
4931
4940
  return { id, routerId, path, ts, filePath, recordCount: records.length };
4932
4941
  }
4933
- function loadSnapshot(filePath) {
4934
- return JSON.parse(readFileSync(filePath, "utf-8"));
4942
+ async function loadSnapshot(filePath) {
4943
+ return JSON.parse(await readFile(filePath, "utf-8"));
4935
4944
  }
4936
4945
 
4937
4946
  // src/domain/snapshot/diff-engine.ts
@@ -4951,7 +4960,16 @@ var SEMANTIC_KEYS = {
4951
4960
  "system/scheduler": ["name"],
4952
4961
  "system/script": ["name"],
4953
4962
  "system/package": ["name"],
4954
- "container": ["name"]
4963
+ "container": ["name"],
4964
+ "certificate": ["name"],
4965
+ "file": ["name"],
4966
+ "interface/vrrp": ["name"],
4967
+ "ip/dhcp-server": ["name"],
4968
+ "ip/ipsec/peer": ["name"],
4969
+ "ip/pool": ["name"],
4970
+ "queue/simple": ["name"],
4971
+ "tool/netwatch": ["host"],
4972
+ "user": ["name"]
4955
4973
  };
4956
4974
  function normalizeValue(v) {
4957
4975
  if (v === "true" || v === "yes") return "true";
@@ -4986,8 +5004,8 @@ function computeRestorePlan(path, before, current) {
4986
5004
  const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
4987
5005
  const toCreate = [];
4988
5006
  const toUpdate = [];
4989
- for (const [key, beforeRecord] of beforeByKey) {
4990
- const currentRecord = currentByKey.get(key);
5007
+ for (const [key2, beforeRecord] of beforeByKey) {
5008
+ const currentRecord = currentByKey.get(key2);
4991
5009
  if (!currentRecord) {
4992
5010
  toCreate.push(beforeRecord);
4993
5011
  } else if (!recordsAreEqual(beforeRecord, currentRecord)) {
@@ -5012,52 +5030,63 @@ async function applyRestorePlan(plan, client) {
5012
5030
  }
5013
5031
 
5014
5032
  // src/domain/snapshot/write-journal.ts
5015
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync2 } from "fs";
5033
+ import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
5016
5034
  import { dirname } from "path";
5017
5035
  import { nanoid as nanoid2 } from "nanoid";
5018
5036
 
5019
5037
  // src/observability/audit-log.ts
5020
- import { appendFileSync } from "fs";
5038
+ import { appendFile } from "fs/promises";
5021
5039
  var log22 = createLogger("audit");
5022
- var SENSITIVE_KEYS = /* @__PURE__ */ new Set(["password", "token", "secret", "authorization", "credentials"]);
5040
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
5041
+ "password",
5042
+ "pass",
5043
+ "token",
5044
+ "secret",
5045
+ "authorization",
5046
+ "credentials",
5047
+ "privatekey",
5048
+ "presharedkey",
5049
+ "psk",
5050
+ "passphrase",
5051
+ "community",
5052
+ "apikey",
5053
+ "confirmationsecret"
5054
+ ]);
5023
5055
  function redactParams(params) {
5024
5056
  const out = {};
5025
- for (const [key, value] of Object.entries(params)) {
5026
- if (SENSITIVE_KEYS.has(key)) continue;
5057
+ for (const [key2, value] of Object.entries(params)) {
5058
+ if (SENSITIVE_KEYS.has(key2.toLowerCase())) continue;
5027
5059
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
5028
- out[key] = redactParams(value);
5060
+ out[key2] = redactParams(value);
5029
5061
  } else {
5030
- out[key] = value;
5062
+ out[key2] = value;
5031
5063
  }
5032
5064
  }
5033
5065
  return out;
5034
5066
  }
5035
- var auditFilePath = process.env.MIKROMCP_AUDIT_LOG_PATH || void 0;
5036
- function auditLog(event) {
5067
+ function auditLog(event, auditLogPath) {
5037
5068
  const safeEvent = { ...event, params: redactParams(event.params) };
5038
5069
  log22.info(safeEvent, "audit");
5039
- if (auditFilePath) {
5040
- try {
5041
- appendFileSync(auditFilePath, JSON.stringify(safeEvent) + "\n");
5042
- } catch (err) {
5043
- log22.error({ err, auditFilePath }, "Failed to write audit event to file");
5044
- }
5070
+ if (auditLogPath) {
5071
+ appendFile(auditLogPath, JSON.stringify(safeEvent) + "\n").catch((err) => {
5072
+ log22.error({ err, auditLogPath }, "Failed to write audit event to file");
5073
+ });
5045
5074
  }
5046
5075
  }
5047
5076
 
5048
5077
  // src/domain/snapshot/write-journal.ts
5049
5078
  var log23 = createLogger("write-journal");
5050
- function appendLine(filePath, obj) {
5079
+ async function appendLine(filePath, obj) {
5051
5080
  try {
5052
- mkdirSync2(dirname(filePath), { recursive: true });
5053
- appendFileSync2(filePath, JSON.stringify(obj) + "\n");
5081
+ await mkdir2(dirname(filePath), { recursive: true });
5082
+ await appendFile2(filePath, JSON.stringify(obj) + "\n");
5054
5083
  } catch (err) {
5055
5084
  log23.error({ err, filePath }, "Failed to write to write journal");
5056
5085
  }
5057
5086
  }
5058
5087
  function recordAttempt(args) {
5059
5088
  const id = nanoid2();
5060
- appendLine(args.journalPath, {
5089
+ void appendLine(args.journalPath, {
5061
5090
  id,
5062
5091
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5063
5092
  identityId: args.identityId,
@@ -5071,7 +5100,7 @@ function recordAttempt(args) {
5071
5100
  return id;
5072
5101
  }
5073
5102
  function recordOutcome(args) {
5074
- appendLine(args.journalPath, {
5103
+ void appendLine(args.journalPath, {
5075
5104
  id: args.journalId,
5076
5105
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5077
5106
  phase: args.phase,
@@ -5185,9 +5214,8 @@ function createChangeManagementTools(baseTools2) {
5185
5214
  async handler(params, context) {
5186
5215
  const parsed = applyPlanInputSchema.parse(params);
5187
5216
  log24.info({ routerId: context.routerId, stepCount: parsed.steps.length }, "Applying plan");
5188
- const dataDir = process.env.MIKROMCP_DATA_DIR ?? join3(homedir2(), ".mikromcp", "data");
5189
- const journalPath = `${dataDir}/write-journal.ndjson`;
5190
- const snapshotDir = `${dataDir}/snapshots`;
5217
+ const journalPath = context.appConfig.journalPath;
5218
+ const snapshotDir = context.appConfig.snapshotDir;
5191
5219
  const results2 = [];
5192
5220
  for (const [i, step] of parsed.steps.entries()) {
5193
5221
  const tool = requireTool(step.tool);
@@ -5212,9 +5240,11 @@ function createChangeManagementTools(baseTools2) {
5212
5240
  params: stepParams,
5213
5241
  snapshotIds
5214
5242
  });
5243
+ const stepStartMs = Date.now();
5215
5244
  try {
5216
- const result = await tool.handler(stepParams, context);
5217
- recordOutcome({ journalPath, journalId: stepJournalId, phase: "success", durationMs: 0 });
5245
+ const runStep = () => tool.handler(stepParams, context);
5246
+ const result = context.circuitBreaker ? await context.circuitBreaker.execute(runStep) : await runStep();
5247
+ recordOutcome({ journalPath, journalId: stepJournalId, phase: "success", durationMs: Date.now() - stepStartMs });
5218
5248
  results2.push({
5219
5249
  stepIndex: i,
5220
5250
  tool: step.tool,
@@ -5224,7 +5254,7 @@ function createChangeManagementTools(baseTools2) {
5224
5254
  });
5225
5255
  } catch (err) {
5226
5256
  const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: step.tool });
5227
- recordOutcome({ journalPath, journalId: stepJournalId, phase: "failure", outcome: error.code, durationMs: 0 });
5257
+ recordOutcome({ journalPath, journalId: stepJournalId, phase: "failure", outcome: error.code, durationMs: Date.now() - stepStartMs });
5228
5258
  log24.error({ err: error, tool: step.tool, step: i }, "apply_plan step failed");
5229
5259
  return {
5230
5260
  content: `Apply failed at step ${i + 1}/${parsed.steps.length} (${step.tool}): ${error.message}. ${i} step(s) completed before failure.`,
@@ -5250,7 +5280,7 @@ function createChangeManagementTools(baseTools2) {
5250
5280
  }
5251
5281
  };
5252
5282
  function findJournalEntry(journalPath, journalId) {
5253
- const raw = readFileSync2(journalPath, "utf-8");
5283
+ const raw = readFileSync(journalPath, "utf-8");
5254
5284
  const lines = raw.split("\n").filter(Boolean);
5255
5285
  const entry = lines.map((l) => JSON.parse(l)).find((e) => e.id === journalId && e.phase === "attempt");
5256
5286
  if (!entry) {
@@ -5279,9 +5309,8 @@ function createChangeManagementTools(baseTools2) {
5279
5309
  },
5280
5310
  async handler(params, context) {
5281
5311
  const parsed = rollbackChangeInputSchema.parse(params);
5282
- const dataDir = process.env.MIKROMCP_DATA_DIR ?? join3(homedir2(), ".mikromcp", "data");
5283
- const journalPath = `${dataDir}/write-journal.ndjson`;
5284
- const snapshotDir = `${dataDir}/snapshots`;
5312
+ const journalPath = context.appConfig.journalPath;
5313
+ const snapshotDir = context.appConfig.snapshotDir;
5285
5314
  log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
5286
5315
  const { snapshotIds } = findJournalEntry(journalPath, parsed.journalId);
5287
5316
  if (snapshotIds.length === 0) {
@@ -5293,7 +5322,7 @@ function createChangeManagementTools(baseTools2) {
5293
5322
  const restorePlans = [];
5294
5323
  for (const snapshotId of snapshotIds) {
5295
5324
  const filePath = `${snapshotDir}/${context.routerId}/${snapshotId}.json`;
5296
- const stored = loadSnapshot(filePath);
5325
+ const stored = await loadSnapshot(filePath);
5297
5326
  const current = await context.routerClient.get(stored.path, {});
5298
5327
  const plan = computeRestorePlan(stored.path, stored.records, current);
5299
5328
  restorePlans.push(plan);
@@ -6034,7 +6063,8 @@ var log28 = createLogger("dhcp-server-tools");
6034
6063
  var listDhcpServersInputSchema = z27.object({
6035
6064
  routerId: z27.string().describe("Target router identifier from the router registry"),
6036
6065
  interface: z27.string().optional().describe("Filter by interface name (exact match)"),
6037
- limit: z27.number().int().min(1).max(500).default(100).describe("Maximum number of DHCP servers to return")
6066
+ limit: z27.number().int().min(1).max(500).default(100).describe("Maximum number of DHCP servers to return"),
6067
+ offset: z27.number().int().min(0).default(0).describe("Offset for pagination")
6038
6068
  }).strict();
6039
6069
  var listDhcpServersTool = {
6040
6070
  name: "list_dhcp_servers",
@@ -6055,17 +6085,19 @@ var listDhcpServersTool = {
6055
6085
  limit: void 0,
6056
6086
  offset: void 0
6057
6087
  });
6058
- const filtered = parsed.interface ? allServers.filter(
6059
- (s) => s.interface === parsed.interface
6060
- ) : allServers;
6061
- const servers = filtered.slice(0, parsed.limit);
6088
+ let filtered = parsed.interface ? allServers.filter((s) => s.interface === parsed.interface) : allServers;
6089
+ const total = filtered.length;
6090
+ const servers = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
6091
+ const hasMore = parsed.offset + parsed.limit < total;
6062
6092
  return {
6063
- content: `DHCP servers on ${context.routerId}: ${servers.length} returned (${allServers.length} total)`,
6093
+ content: `DHCP servers on ${context.routerId}: ${total} total, showing ${servers.length} (offset ${parsed.offset})`,
6064
6094
  structuredContent: {
6065
6095
  routerId: context.routerId,
6066
6096
  servers,
6067
- total: allServers.length,
6068
- returned: servers.length
6097
+ total,
6098
+ hasMore,
6099
+ offset: parsed.offset,
6100
+ limit: parsed.limit
6069
6101
  }
6070
6102
  };
6071
6103
  } catch (err) {
@@ -6256,16 +6288,22 @@ var manageDhcpServerTool = {
6256
6288
  }
6257
6289
  }
6258
6290
  };
6259
- var listDhcpPoolsInputSchema = z27.object({
6260
- routerId: z27.string().describe("Target router identifier from the router registry"),
6261
- name: z27.string().optional().describe("Filter by pool name (substring match)"),
6262
- limit: z27.number().int().min(1).max(500).default(100).describe("Maximum number of pools to return")
6291
+ var dhcpServerTools = [listDhcpServersTool, manageDhcpServerTool];
6292
+
6293
+ // src/domain/tools/ip-pool-tools.ts
6294
+ import { z as z28 } from "zod";
6295
+ var log29 = createLogger("ip-pool-tools");
6296
+ var listIpPoolsInputSchema = z28.object({
6297
+ routerId: z28.string().describe("Target router identifier from the router registry"),
6298
+ name: z28.string().optional().describe("Filter by pool name (substring match)"),
6299
+ limit: z28.number().int().min(1).max(500).default(100).describe("Maximum number of pools to return"),
6300
+ offset: z28.number().int().min(0).default(0).describe("Offset for pagination")
6263
6301
  }).strict();
6264
- var listDhcpPoolsTool = {
6265
- name: "list_dhcp_pools",
6266
- title: "List DHCP Pools",
6267
- description: "List IP pools on a MikroTik router.",
6268
- inputSchema: listDhcpPoolsInputSchema,
6302
+ var listIpPoolsTool = {
6303
+ name: "list_ip_pools",
6304
+ title: "List IP Pools",
6305
+ description: "List IP address pools on a MikroTik router. Supports filtering by name and pagination.",
6306
+ inputSchema: listIpPoolsInputSchema,
6269
6307
  annotations: {
6270
6308
  readOnlyHint: true,
6271
6309
  destructiveHint: false,
@@ -6273,44 +6311,49 @@ var listDhcpPoolsTool = {
6273
6311
  openWorldHint: false
6274
6312
  },
6275
6313
  async handler(params, context) {
6276
- const parsed = listDhcpPoolsInputSchema.parse(params);
6277
- log28.info({ routerId: context.routerId }, "Listing DHCP pools");
6314
+ const parsed = listIpPoolsInputSchema.parse(params);
6315
+ log29.info({ routerId: context.routerId }, "Listing IP pools");
6278
6316
  try {
6279
6317
  const allPools = await context.routerClient.get("ip/pool", {
6280
6318
  limit: void 0,
6281
6319
  offset: void 0
6282
6320
  });
6283
- const filtered = parsed.name ? allPools.filter(
6284
- (p) => (p.name ?? "").includes(parsed.name)
6285
- ) : allPools;
6286
- const pools = filtered.slice(0, parsed.limit);
6321
+ let filtered = allPools;
6322
+ if (parsed.name) {
6323
+ filtered = filtered.filter((p) => (p.name ?? "").includes(parsed.name));
6324
+ }
6325
+ const total = filtered.length;
6326
+ const pools = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
6327
+ const hasMore = parsed.offset + parsed.limit < total;
6287
6328
  return {
6288
- content: `DHCP pools on ${context.routerId}: ${pools.length} returned (${allPools.length} total)`,
6329
+ content: `IP pools on ${context.routerId}: ${total} total, showing ${pools.length} (offset ${parsed.offset})`,
6289
6330
  structuredContent: {
6290
6331
  routerId: context.routerId,
6291
6332
  pools,
6292
- total: allPools.length,
6293
- returned: pools.length
6333
+ total,
6334
+ hasMore,
6335
+ offset: parsed.offset,
6336
+ limit: parsed.limit
6294
6337
  }
6295
6338
  };
6296
6339
  } catch (err) {
6297
- throw enrichError(err, { routerId: context.routerId, tool: "list_dhcp_pools" });
6340
+ throw enrichError(err, { routerId: context.routerId, tool: "list_ip_pools" });
6298
6341
  }
6299
6342
  }
6300
6343
  };
6301
- var manageDhcpPoolInputSchema = z27.object({
6302
- routerId: z27.string().describe("Target router identifier from the router registry"),
6303
- action: z27.enum(["add", "remove"]).describe("Action to perform"),
6304
- name: z27.string().describe("Pool name \u2014 idempotency key"),
6305
- ranges: z27.string().optional().describe("IP range (e.g. '192.168.1.100-192.168.1.200'; required for add)"),
6306
- nextPool: z27.string().optional().describe("Next pool name for overflow"),
6307
- dryRun: z27.boolean().default(false).describe("Preview changes without applying")
6344
+ var manageIpPoolInputSchema = z28.object({
6345
+ routerId: z28.string().describe("Target router identifier from the router registry"),
6346
+ action: z28.enum(["add", "remove"]).describe("Action to perform"),
6347
+ name: z28.string().describe("Pool name \u2014 idempotency key"),
6348
+ ranges: z28.string().optional().describe("IP range (e.g. '192.168.1.100-192.168.1.200'; required for add)"),
6349
+ nextPool: z28.string().optional().describe("Next pool name for overflow"),
6350
+ dryRun: z28.boolean().default(false).describe("Preview changes without applying")
6308
6351
  }).strict();
6309
- var manageDhcpPoolTool = {
6310
- name: "manage_dhcp_pool",
6311
- title: "Manage DHCP Pool",
6312
- description: "Add or remove an IP pool. Idempotent by name: add returns already_exists if a pool with the same name and ranges already exists.",
6313
- inputSchema: manageDhcpPoolInputSchema,
6352
+ var manageIpPoolTool = {
6353
+ name: "manage_ip_pool",
6354
+ title: "Manage IP Pool",
6355
+ description: "Add or remove an IP address pool. Idempotent by name: add returns already_exists if a pool with the same name and ranges already exists.",
6356
+ inputSchema: manageIpPoolInputSchema,
6314
6357
  annotations: {
6315
6358
  readOnlyHint: false,
6316
6359
  destructiveHint: false,
@@ -6319,25 +6362,20 @@ var manageDhcpPoolTool = {
6319
6362
  },
6320
6363
  snapshotPaths: ["ip/pool"],
6321
6364
  async handler(params, context) {
6322
- const parsed = manageDhcpPoolInputSchema.parse(params);
6323
- log28.info(
6324
- { routerId: context.routerId, action: parsed.action, name: parsed.name },
6325
- "Managing DHCP pool"
6326
- );
6365
+ const parsed = manageIpPoolInputSchema.parse(params);
6366
+ log29.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing IP pool");
6327
6367
  try {
6328
6368
  const allPools = await context.routerClient.get("ip/pool", {
6329
6369
  limit: void 0,
6330
6370
  offset: void 0
6331
6371
  });
6332
- const existing = allPools.find(
6333
- (p) => p.name === parsed.name
6334
- );
6372
+ const existing = allPools.find((p) => p.name === parsed.name);
6335
6373
  if (parsed.action === "add") {
6336
6374
  if (!parsed.ranges) {
6337
6375
  throw new MikroMCPError({
6338
6376
  category: "VALIDATION" /* VALIDATION */,
6339
- code: "DHCP_POOL_RANGES_REQUIRED",
6340
- message: "ranges is required when adding a DHCP pool.",
6377
+ code: "IP_POOL_RANGES_REQUIRED",
6378
+ message: "ranges is required when adding an IP pool.",
6341
6379
  details: { name: parsed.name },
6342
6380
  recoverability: {
6343
6381
  retryable: false,
@@ -6348,22 +6386,19 @@ var manageDhcpPoolTool = {
6348
6386
  if (existing) {
6349
6387
  if (existing.ranges === parsed.ranges) {
6350
6388
  return {
6351
- content: `DHCP pool "${parsed.name}" already exists with the same ranges. No changes made.`,
6389
+ content: `IP pool "${parsed.name}" already exists with the same ranges. No changes made.`,
6352
6390
  structuredContent: { action: "already_exists", pool: existing }
6353
6391
  };
6354
6392
  }
6355
6393
  throw new MikroMCPError({
6356
6394
  category: "CONFLICT" /* CONFLICT */,
6357
- code: "DHCP_POOL_CONFLICT",
6358
- message: `DHCP pool "${parsed.name}" exists with different ranges.`,
6359
- details: {
6360
- existing: existing.ranges,
6361
- requested: parsed.ranges
6362
- },
6395
+ code: "IP_POOL_CONFLICT",
6396
+ message: `IP pool "${parsed.name}" exists with different ranges.`,
6397
+ details: { existing: existing.ranges, requested: parsed.ranges },
6363
6398
  recoverability: {
6364
6399
  retryable: false,
6365
6400
  suggestedAction: "Remove the existing pool first or use a different name.",
6366
- alternativeTools: ["manage_dhcp_pool"]
6401
+ alternativeTools: ["manage_ip_pool"]
6367
6402
  }
6368
6403
  });
6369
6404
  }
@@ -6374,63 +6409,52 @@ var manageDhcpPoolTool = {
6374
6409
  ...parsed.nextPool ? [{ property: "next-pool", before: null, after: parsed.nextPool }] : []
6375
6410
  ];
6376
6411
  return {
6377
- content: `Dry run: Would add DHCP pool "${parsed.name}".`,
6412
+ content: `Dry run: Would add IP pool "${parsed.name}" with ranges ${parsed.ranges}.`,
6378
6413
  structuredContent: { action: "dry_run", diff }
6379
6414
  };
6380
6415
  }
6381
- const body = {
6382
- name: parsed.name,
6383
- ranges: parsed.ranges
6384
- };
6416
+ const body = { name: parsed.name, ranges: parsed.ranges };
6385
6417
  if (parsed.nextPool) body["next-pool"] = parsed.nextPool;
6386
6418
  const created = await context.routerClient.create("ip/pool", body);
6387
- log28.info({ name: parsed.name, id: created[".id"] }, "DHCP pool added");
6419
+ log29.info({ name: parsed.name, id: created[".id"] }, "IP pool added");
6388
6420
  return {
6389
- content: `Added DHCP pool "${parsed.name}".`,
6421
+ content: `Added IP pool "${parsed.name}" with ranges ${parsed.ranges}.`,
6390
6422
  structuredContent: { action: "created", pool: created }
6391
6423
  };
6392
6424
  }
6393
6425
  if (!existing) {
6394
6426
  return {
6395
- content: `DHCP pool "${parsed.name}" not found. Nothing to remove.`,
6427
+ content: `IP pool "${parsed.name}" not found. Nothing to remove.`,
6396
6428
  structuredContent: { action: "not_found", name: parsed.name }
6397
6429
  };
6398
6430
  }
6399
6431
  if (parsed.dryRun) {
6400
6432
  return {
6401
- content: `Dry run: Would remove DHCP pool "${parsed.name}".`,
6402
- structuredContent: {
6403
- action: "dry_run",
6404
- diff: [{ property: "name", before: parsed.name, after: null }]
6405
- }
6433
+ content: `Dry run: Would remove IP pool "${parsed.name}".`,
6434
+ structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] }
6406
6435
  };
6407
6436
  }
6408
6437
  await context.routerClient.remove("ip/pool", existing[".id"]);
6409
- log28.info({ name: parsed.name }, "DHCP pool removed");
6438
+ log29.info({ name: parsed.name }, "IP pool removed");
6410
6439
  return {
6411
- content: `Removed DHCP pool "${parsed.name}".`,
6440
+ content: `Removed IP pool "${parsed.name}".`,
6412
6441
  structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
6413
6442
  };
6414
6443
  } catch (err) {
6415
6444
  if (err instanceof MikroMCPError) throw err;
6416
- throw enrichError(err, { routerId: context.routerId, tool: "manage_dhcp_pool" });
6445
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_ip_pool" });
6417
6446
  }
6418
6447
  }
6419
6448
  };
6420
- var dhcpServerTools = [
6421
- listDhcpServersTool,
6422
- manageDhcpServerTool,
6423
- listDhcpPoolsTool,
6424
- manageDhcpPoolTool
6425
- ];
6449
+ var ipPoolTools = [listIpPoolsTool, manageIpPoolTool];
6426
6450
 
6427
6451
  // src/domain/tools/queue-tools.ts
6428
- import { z as z28 } from "zod";
6429
- var log29 = createLogger("queue-tools");
6430
- var listQueuesInputSchema = z28.object({
6431
- routerId: z28.string().describe("Target router identifier from the router registry"),
6432
- target: z28.string().optional().describe("Filter by target address (substring match)"),
6433
- limit: z28.number().int().min(1).max(500).default(100).describe("Maximum number of queues to return")
6452
+ import { z as z29 } from "zod";
6453
+ var log30 = createLogger("queue-tools");
6454
+ var listQueuesInputSchema = z29.object({
6455
+ routerId: z29.string().describe("Target router identifier from the router registry"),
6456
+ target: z29.string().optional().describe("Filter by target address (substring match)"),
6457
+ limit: z29.number().int().min(1).max(500).default(100).describe("Maximum number of queues to return")
6434
6458
  }).strict();
6435
6459
  var listQueuesTool = {
6436
6460
  name: "list_queues",
@@ -6445,7 +6469,7 @@ var listQueuesTool = {
6445
6469
  },
6446
6470
  async handler(params, context) {
6447
6471
  const parsed = listQueuesInputSchema.parse(params);
6448
- log29.info({ routerId: context.routerId }, "Listing queues");
6472
+ log30.info({ routerId: context.routerId }, "Listing queues");
6449
6473
  try {
6450
6474
  const allQueues = await context.routerClient.get("queue/simple", {
6451
6475
  limit: void 0,
@@ -6469,15 +6493,15 @@ var listQueuesTool = {
6469
6493
  }
6470
6494
  }
6471
6495
  };
6472
- var manageQueueInputSchema = z28.object({
6473
- routerId: z28.string().describe("Target router identifier from the router registry"),
6474
- action: z28.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
6475
- name: z28.string().describe("Queue name \u2014 idempotency key"),
6476
- target: z28.string().optional().describe("Target address (required for add; e.g. '192.168.1.0/24')"),
6477
- maxLimit: z28.string().optional().describe("Max upload/download limit (e.g. '10M/10M')"),
6478
- limitAt: z28.string().optional().describe("Guaranteed rate (e.g. '1M/1M')"),
6479
- comment: z28.string().optional().describe("Optional comment"),
6480
- dryRun: z28.boolean().default(false).describe("Preview changes without applying")
6496
+ var manageQueueInputSchema = z29.object({
6497
+ routerId: z29.string().describe("Target router identifier from the router registry"),
6498
+ action: z29.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
6499
+ name: z29.string().describe("Queue name \u2014 idempotency key"),
6500
+ target: z29.string().optional().describe("Target address (required for add; e.g. '192.168.1.0/24')"),
6501
+ maxLimit: z29.string().optional().describe("Max upload/download limit (e.g. '10M/10M')"),
6502
+ limitAt: z29.string().optional().describe("Guaranteed rate (e.g. '1M/1M')"),
6503
+ comment: z29.string().optional().describe("Optional comment"),
6504
+ dryRun: z29.boolean().default(false).describe("Preview changes without applying")
6481
6505
  }).strict();
6482
6506
  var manageQueueTool = {
6483
6507
  name: "manage_queue",
@@ -6493,7 +6517,7 @@ var manageQueueTool = {
6493
6517
  snapshotPaths: ["queue/simple"],
6494
6518
  async handler(params, context) {
6495
6519
  const parsed = manageQueueInputSchema.parse(params);
6496
- log29.info(
6520
+ log30.info(
6497
6521
  { routerId: context.routerId, action: parsed.action, name: parsed.name },
6498
6522
  "Managing queue"
6499
6523
  );
@@ -6555,7 +6579,7 @@ var manageQueueTool = {
6555
6579
  if (parsed.limitAt) body["limit-at"] = parsed.limitAt;
6556
6580
  if (parsed.comment) body.comment = parsed.comment;
6557
6581
  const created = await context.routerClient.create("queue/simple", body);
6558
- log29.info({ name: parsed.name, id: created[".id"] }, "Queue added");
6582
+ log30.info({ name: parsed.name, id: created[".id"] }, "Queue added");
6559
6583
  return {
6560
6584
  content: `Added queue "${parsed.name}".`,
6561
6585
  structuredContent: { action: "created", queue: created }
@@ -6578,7 +6602,7 @@ var manageQueueTool = {
6578
6602
  };
6579
6603
  }
6580
6604
  await context.routerClient.remove("queue/simple", existing[".id"]);
6581
- log29.info({ name: parsed.name }, "Queue removed");
6605
+ log30.info({ name: parsed.name }, "Queue removed");
6582
6606
  return {
6583
6607
  content: `Removed queue "${parsed.name}".`,
6584
6608
  structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
@@ -6617,7 +6641,7 @@ var manageQueueTool = {
6617
6641
  disabled: disabledValue
6618
6642
  });
6619
6643
  const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
6620
- log29.info({ name: parsed.name, action: resultAction }, "Queue updated");
6644
+ log30.info({ name: parsed.name, action: resultAction }, "Queue updated");
6621
6645
  return {
6622
6646
  content: `Queue "${parsed.name}" ${resultAction}.`,
6623
6647
  structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
@@ -6631,12 +6655,12 @@ var manageQueueTool = {
6631
6655
  var queueTools = [listQueuesTool, manageQueueTool];
6632
6656
 
6633
6657
  // src/domain/tools/vrrp-tools.ts
6634
- import { z as z29 } from "zod";
6635
- var log30 = createLogger("vrrp-tools");
6636
- var listVrrpInstancesInputSchema = z29.object({
6637
- routerId: z29.string().describe("Target router identifier from the router registry"),
6638
- interface: z29.string().optional().describe("Filter by master interface name (exact match)"),
6639
- limit: z29.number().int().min(1).max(500).default(100).describe("Maximum number of instances to return")
6658
+ import { z as z30 } from "zod";
6659
+ var log31 = createLogger("vrrp-tools");
6660
+ var listVrrpInstancesInputSchema = z30.object({
6661
+ routerId: z30.string().describe("Target router identifier from the router registry"),
6662
+ interface: z30.string().optional().describe("Filter by master interface name (exact match)"),
6663
+ limit: z30.number().int().min(1).max(500).default(100).describe("Maximum number of instances to return")
6640
6664
  }).strict();
6641
6665
  var listVrrpInstancesTool = {
6642
6666
  name: "list_vrrp_instances",
@@ -6651,7 +6675,7 @@ var listVrrpInstancesTool = {
6651
6675
  },
6652
6676
  async handler(params, context) {
6653
6677
  const parsed = listVrrpInstancesInputSchema.parse(params);
6654
- log30.info({ routerId: context.routerId }, "Listing VRRP instances");
6678
+ log31.info({ routerId: context.routerId }, "Listing VRRP instances");
6655
6679
  try {
6656
6680
  const allInstances = await context.routerClient.get("interface/vrrp", {
6657
6681
  limit: void 0,
@@ -6675,17 +6699,17 @@ var listVrrpInstancesTool = {
6675
6699
  }
6676
6700
  }
6677
6701
  };
6678
- var manageVrrpInstanceInputSchema = z29.object({
6679
- routerId: z29.string().describe("Target router identifier from the router registry"),
6680
- action: z29.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
6681
- name: z29.string().describe("VRRP interface name \u2014 idempotency key"),
6682
- interface: z29.string().optional().describe("Master interface (required for add)"),
6683
- vrid: z29.number().int().min(1).max(255).optional().describe("Virtual router ID (required for add)"),
6684
- priority: z29.number().int().min(1).max(254).default(100).describe("Router priority (1\u2013254)"),
6685
- interval: z29.number().int().min(1).optional().describe("Advertisement interval in seconds"),
6686
- version: z29.enum(["2", "3"]).default("3").describe("VRRP protocol version"),
6687
- comment: z29.string().optional().describe("Optional comment"),
6688
- dryRun: z29.boolean().default(false).describe("Preview changes without applying")
6702
+ var manageVrrpInstanceInputSchema = z30.object({
6703
+ routerId: z30.string().describe("Target router identifier from the router registry"),
6704
+ action: z30.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
6705
+ name: z30.string().describe("VRRP interface name \u2014 idempotency key"),
6706
+ interface: z30.string().optional().describe("Master interface (required for add)"),
6707
+ vrid: z30.number().int().min(1).max(255).optional().describe("Virtual router ID (required for add)"),
6708
+ priority: z30.number().int().min(1).max(254).default(100).describe("Router priority (1\u2013254)"),
6709
+ interval: z30.number().int().min(1).optional().describe("Advertisement interval in seconds"),
6710
+ version: z30.enum(["2", "3"]).default("3").describe("VRRP protocol version"),
6711
+ comment: z30.string().optional().describe("Optional comment"),
6712
+ dryRun: z30.boolean().default(false).describe("Preview changes without applying")
6689
6713
  }).strict();
6690
6714
  var manageVrrpInstanceTool = {
6691
6715
  name: "manage_vrrp_instance",
@@ -6701,7 +6725,7 @@ var manageVrrpInstanceTool = {
6701
6725
  snapshotPaths: ["interface/vrrp"],
6702
6726
  async handler(params, context) {
6703
6727
  const parsed = manageVrrpInstanceInputSchema.parse(params);
6704
- log30.info(
6728
+ log31.info(
6705
6729
  { routerId: context.routerId, action: parsed.action, name: parsed.name },
6706
6730
  "Managing VRRP instance"
6707
6731
  );
@@ -6785,7 +6809,7 @@ var manageVrrpInstanceTool = {
6785
6809
  if (parsed.interval) body.interval = String(parsed.interval);
6786
6810
  if (parsed.comment) body.comment = parsed.comment;
6787
6811
  const created = await context.routerClient.create("interface/vrrp", body);
6788
- log30.info({ name: parsed.name, id: created[".id"] }, "VRRP instance added");
6812
+ log31.info({ name: parsed.name, id: created[".id"] }, "VRRP instance added");
6789
6813
  return {
6790
6814
  content: `Added VRRP instance "${parsed.name}".`,
6791
6815
  structuredContent: { action: "created", instance: created }
@@ -6808,7 +6832,7 @@ var manageVrrpInstanceTool = {
6808
6832
  };
6809
6833
  }
6810
6834
  await context.routerClient.remove("interface/vrrp", existing[".id"]);
6811
- log30.info({ name: parsed.name }, "VRRP instance removed");
6835
+ log31.info({ name: parsed.name }, "VRRP instance removed");
6812
6836
  return {
6813
6837
  content: `Removed VRRP instance "${parsed.name}".`,
6814
6838
  structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
@@ -6847,7 +6871,7 @@ var manageVrrpInstanceTool = {
6847
6871
  disabled: disabledValue
6848
6872
  });
6849
6873
  const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
6850
- log30.info({ name: parsed.name, action: resultAction }, "VRRP instance updated");
6874
+ log31.info({ name: parsed.name, action: resultAction }, "VRRP instance updated");
6851
6875
  return {
6852
6876
  content: `VRRP instance "${parsed.name}" ${resultAction}.`,
6853
6877
  structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
@@ -6861,10 +6885,10 @@ var manageVrrpInstanceTool = {
6861
6885
  var vrrpTools = [listVrrpInstancesTool, manageVrrpInstanceTool];
6862
6886
 
6863
6887
  // src/domain/tools/network-services-tools.ts
6864
- import { z as z30 } from "zod";
6865
- var log31 = createLogger("network-services-tools");
6866
- var getSnmpSettingsInputSchema = z30.object({
6867
- routerId: z30.string().describe("Target router identifier from the router registry")
6888
+ import { z as z31 } from "zod";
6889
+ var log32 = createLogger("network-services-tools");
6890
+ var getSnmpSettingsInputSchema = z31.object({
6891
+ routerId: z31.string().describe("Target router identifier from the router registry")
6868
6892
  }).strict();
6869
6893
  var getSnmpSettingsTool = {
6870
6894
  name: "get_snmp_settings",
@@ -6879,7 +6903,7 @@ var getSnmpSettingsTool = {
6879
6903
  },
6880
6904
  async handler(params, context) {
6881
6905
  const parsed = getSnmpSettingsInputSchema.parse(params);
6882
- log31.info({ routerId: context.routerId }, "Fetching SNMP settings");
6906
+ log32.info({ routerId: context.routerId }, "Fetching SNMP settings");
6883
6907
  try {
6884
6908
  const results2 = await context.routerClient.get("snmp");
6885
6909
  if (!results2 || results2.length === 0) {
@@ -6908,8 +6932,8 @@ var getSnmpSettingsTool = {
6908
6932
  }
6909
6933
  }
6910
6934
  };
6911
- var getNtpSettingsInputSchema = z30.object({
6912
- routerId: z30.string().describe("Target router identifier from the router registry")
6935
+ var getNtpSettingsInputSchema = z31.object({
6936
+ routerId: z31.string().describe("Target router identifier from the router registry")
6913
6937
  }).strict();
6914
6938
  var getNtpSettingsTool = {
6915
6939
  name: "get_ntp_settings",
@@ -6924,7 +6948,7 @@ var getNtpSettingsTool = {
6924
6948
  },
6925
6949
  async handler(params, context) {
6926
6950
  const parsed = getNtpSettingsInputSchema.parse(params);
6927
- log31.info({ routerId: context.routerId }, "Fetching NTP settings");
6951
+ log32.info({ routerId: context.routerId }, "Fetching NTP settings");
6928
6952
  try {
6929
6953
  const results2 = await context.routerClient.get("system/ntp/client");
6930
6954
  if (!results2 || results2.length === 0) {
@@ -6953,11 +6977,11 @@ var getNtpSettingsTool = {
6953
6977
  }
6954
6978
  }
6955
6979
  };
6956
- var listNetwatchEntriesInputSchema = z30.object({
6957
- routerId: z30.string().describe("Target router identifier from the router registry"),
6958
- host: z30.string().optional().describe("Filter by host (substring match)"),
6959
- status: z30.enum(["up", "down", "unknown"]).optional().describe("Filter by current status"),
6960
- limit: z30.number().int().min(1).max(500).default(100).describe("Maximum number of entries to return")
6980
+ var listNetwatchEntriesInputSchema = z31.object({
6981
+ routerId: z31.string().describe("Target router identifier from the router registry"),
6982
+ host: z31.string().optional().describe("Filter by host (substring match)"),
6983
+ status: z31.enum(["up", "down", "unknown"]).optional().describe("Filter by current status"),
6984
+ limit: z31.number().int().min(1).max(500).default(100).describe("Maximum number of entries to return")
6961
6985
  }).strict();
6962
6986
  var listNetwatchEntriesTool = {
6963
6987
  name: "list_netwatch_entries",
@@ -6972,7 +6996,7 @@ var listNetwatchEntriesTool = {
6972
6996
  },
6973
6997
  async handler(params, context) {
6974
6998
  const parsed = listNetwatchEntriesInputSchema.parse(params);
6975
- log31.info({ routerId: context.routerId }, "Listing Netwatch entries");
6999
+ log32.info({ routerId: context.routerId }, "Listing Netwatch entries");
6976
7000
  try {
6977
7001
  const allEntries = await context.routerClient.get("tool/netwatch", {
6978
7002
  limit: void 0,
@@ -6994,15 +7018,15 @@ var listNetwatchEntriesTool = {
6994
7018
  }
6995
7019
  }
6996
7020
  };
6997
- var manageNetwatchEntryInputSchema = z30.object({
6998
- routerId: z30.string().describe("Target router identifier from the router registry"),
6999
- action: z30.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
7000
- host: z30.string().describe("Host to monitor \u2014 idempotency key"),
7001
- port: z30.number().int().min(1).max(65535).optional().describe("TCP port (optional; ICMP if omitted)"),
7002
- interval: z30.string().optional().describe("Check interval (e.g. '1m'; default '1m')"),
7003
- timeout: z30.string().optional().describe("Probe timeout (e.g. '500ms')"),
7004
- comment: z30.string().optional().describe("Optional comment"),
7005
- dryRun: z30.boolean().default(false).describe("Preview changes without applying")
7021
+ var manageNetwatchEntryInputSchema = z31.object({
7022
+ routerId: z31.string().describe("Target router identifier from the router registry"),
7023
+ action: z31.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
7024
+ host: z31.string().describe("Host to monitor \u2014 idempotency key"),
7025
+ port: z31.number().int().min(1).max(65535).optional().describe("TCP port (optional; ICMP if omitted)"),
7026
+ interval: z31.string().optional().describe("Check interval (e.g. '1m'; default '1m')"),
7027
+ timeout: z31.string().optional().describe("Probe timeout (e.g. '500ms')"),
7028
+ comment: z31.string().optional().describe("Optional comment"),
7029
+ dryRun: z31.boolean().default(false).describe("Preview changes without applying")
7006
7030
  }).strict();
7007
7031
  var manageNetwatchEntryTool = {
7008
7032
  name: "manage_netwatch_entry",
@@ -7018,7 +7042,7 @@ var manageNetwatchEntryTool = {
7018
7042
  snapshotPaths: ["tool/netwatch"],
7019
7043
  async handler(params, context) {
7020
7044
  const parsed = manageNetwatchEntryInputSchema.parse(params);
7021
- log31.info(
7045
+ log32.info(
7022
7046
  { routerId: context.routerId, action: parsed.action, host: parsed.host },
7023
7047
  "Managing Netwatch entry"
7024
7048
  );
@@ -7075,7 +7099,7 @@ var manageNetwatchEntryTool = {
7075
7099
  if (parsed.timeout) body.timeout = parsed.timeout;
7076
7100
  if (parsed.comment) body.comment = parsed.comment;
7077
7101
  const created = await context.routerClient.create("tool/netwatch", body);
7078
- log31.info({ host: parsed.host, id: created[".id"] }, "Netwatch entry added");
7102
+ log32.info({ host: parsed.host, id: created[".id"] }, "Netwatch entry added");
7079
7103
  return {
7080
7104
  content: `Added Netwatch entry for "${parsed.host}".`,
7081
7105
  structuredContent: { action: "created", entry: created }
@@ -7099,7 +7123,7 @@ var manageNetwatchEntryTool = {
7099
7123
  };
7100
7124
  }
7101
7125
  await context.routerClient.remove("tool/netwatch", existing2[".id"]);
7102
- log31.info({ host: parsed.host }, "Netwatch entry removed");
7126
+ log32.info({ host: parsed.host }, "Netwatch entry removed");
7103
7127
  return {
7104
7128
  content: `Removed Netwatch entry for "${parsed.host}".`,
7105
7129
  structuredContent: { action: "removed", host: parsed.host, id: existing2[".id"] }
@@ -7139,7 +7163,7 @@ var manageNetwatchEntryTool = {
7139
7163
  disabled: disabledValue
7140
7164
  });
7141
7165
  const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
7142
- log31.info({ host: parsed.host, action: resultAction }, "Netwatch entry updated");
7166
+ log32.info({ host: parsed.host, action: resultAction }, "Netwatch entry updated");
7143
7167
  return {
7144
7168
  content: `Netwatch entry for "${parsed.host}" ${resultAction}.`,
7145
7169
  structuredContent: { action: resultAction, host: parsed.host, id: existing[".id"] }
@@ -7150,10 +7174,10 @@ var manageNetwatchEntryTool = {
7150
7174
  }
7151
7175
  }
7152
7176
  };
7153
- var listNeighborsInputSchema = z30.object({
7154
- routerId: z30.string().describe("Target router identifier from the router registry"),
7155
- interface: z30.string().optional().describe("Filter by interface name (substring match)"),
7156
- limit: z30.number().int().min(1).max(500).default(100).describe("Maximum number of neighbors to return")
7177
+ var listNeighborsInputSchema = z31.object({
7178
+ routerId: z31.string().describe("Target router identifier from the router registry"),
7179
+ interface: z31.string().optional().describe("Filter by interface name (substring match)"),
7180
+ limit: z31.number().int().min(1).max(500).default(100).describe("Maximum number of neighbors to return")
7157
7181
  }).strict();
7158
7182
  var listNeighborsTool = {
7159
7183
  name: "list_neighbors",
@@ -7168,7 +7192,7 @@ var listNeighborsTool = {
7168
7192
  },
7169
7193
  async handler(params, context) {
7170
7194
  const parsed = listNeighborsInputSchema.parse(params);
7171
- log31.info({ routerId: context.routerId }, "Listing neighbors");
7195
+ log32.info({ routerId: context.routerId }, "Listing neighbors");
7172
7196
  try {
7173
7197
  const allNeighbors = await context.routerClient.get("ip/neighbor", {
7174
7198
  limit: void 0,
@@ -7192,12 +7216,12 @@ var listNeighborsTool = {
7192
7216
  }
7193
7217
  }
7194
7218
  };
7195
- var listArpEntriesInputSchema = z30.object({
7196
- routerId: z30.string().describe("Target router identifier from the router registry"),
7197
- interface: z30.string().optional().describe("Filter by interface (substring match)"),
7198
- address: z30.string().optional().describe("Filter by IP address (substring match)"),
7199
- macAddress: z30.string().optional().describe("Filter by MAC address (substring match)"),
7200
- limit: z30.number().int().min(1).max(500).default(100).describe("Maximum number of ARP entries to return")
7219
+ var listArpEntriesInputSchema = z31.object({
7220
+ routerId: z31.string().describe("Target router identifier from the router registry"),
7221
+ interface: z31.string().optional().describe("Filter by interface (substring match)"),
7222
+ address: z31.string().optional().describe("Filter by IP address (substring match)"),
7223
+ macAddress: z31.string().optional().describe("Filter by MAC address (substring match)"),
7224
+ limit: z31.number().int().min(1).max(500).default(100).describe("Maximum number of ARP entries to return")
7201
7225
  }).strict();
7202
7226
  var listArpEntriesTool = {
7203
7227
  name: "list_arp_entries",
@@ -7212,7 +7236,7 @@ var listArpEntriesTool = {
7212
7236
  },
7213
7237
  async handler(params, context) {
7214
7238
  const parsed = listArpEntriesInputSchema.parse(params);
7215
- log31.info({ routerId: context.routerId }, "Listing ARP entries");
7239
+ log32.info({ routerId: context.routerId }, "Listing ARP entries");
7216
7240
  try {
7217
7241
  const allEntries = await context.routerClient.get("ip/arp", {
7218
7242
  limit: void 0,
@@ -7245,51 +7269,602 @@ var networkServicesTools = [
7245
7269
  listArpEntriesTool
7246
7270
  ];
7247
7271
 
7248
- // src/domain/tools/fleet-tools.ts
7249
- import { z as z31 } from "zod";
7250
-
7251
- // src/middleware/authz.ts
7252
- function matchesPattern(name, pattern) {
7253
- if (!pattern.includes("*")) return name === pattern;
7254
- const prefix = pattern.slice(0, pattern.indexOf("*"));
7255
- return name.startsWith(prefix);
7256
- }
7257
- function checkAuthz(identity, toolName, routerId) {
7258
- if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId)) {
7259
- throw new MikroMCPError({
7260
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
7261
- code: "ROUTER_NOT_ALLOWED",
7262
- message: `Identity "${identity.id}" is not allowed to access router "${routerId}".`,
7263
- details: { identityId: identity.id, routerId, allowedRouters: identity.allowedRouters },
7264
- recoverability: {
7265
- retryable: false,
7266
- suggestedAction: `Add "${routerId}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
7267
- }
7268
- });
7269
- }
7270
- if (identity.allowedToolPatterns.length > 0) {
7271
- const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
7272
- if (!allowed) {
7273
- throw new MikroMCPError({
7274
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
7275
- code: "TOOL_NOT_ALLOWED",
7276
- message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
7277
- details: { identityId: identity.id, toolName, allowedToolPatterns: identity.allowedToolPatterns },
7278
- recoverability: {
7279
- retryable: false,
7280
- suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
7281
- }
7282
- });
7283
- }
7284
- }
7272
+ // src/domain/tools/vlan-tools.ts
7273
+ import { z as z32 } from "zod";
7274
+ var log33 = createLogger("vlan-tools");
7275
+ function sanitizeComment4(comment) {
7276
+ if (comment === void 0) return void 0;
7277
+ return comment.replace(/[\x00-\x1f\x7f]/g, "");
7285
7278
  }
7286
-
7287
- // src/adapter/ssh-client.ts
7288
- import { createHash } from "crypto";
7289
- import { Client } from "ssh2";
7290
- var SshClient = class {
7291
- constructor(config, credentials, options) {
7292
- this.config = config;
7279
+ var manageVlanInputSchema = z32.object({
7280
+ routerId: z32.string().describe("Target router identifier from the router registry"),
7281
+ action: z32.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
7282
+ name: z32.string().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe("VLAN interface name \u2014 idempotency key (alphanumeric, hyphens, underscores, max 15 chars)"),
7283
+ vlanId: z32.number().int().min(1).max(4094).optional().describe("VLAN ID (1-4094; required for add)"),
7284
+ parentInterface: z32.string().optional().describe("Parent interface name (e.g., ether1, bridge1; required for add)"),
7285
+ mtu: z32.number().int().min(68).max(9e3).default(1500).describe("MTU size (add only)"),
7286
+ disabled: z32.boolean().default(false).describe("Whether to create the VLAN disabled (add only)"),
7287
+ comment: z32.string().max(255).optional().describe("Optional comment (add only)"),
7288
+ dryRun: z32.boolean().default(false).describe("Preview changes without applying")
7289
+ }).strict();
7290
+ var manageVlanTool = {
7291
+ name: "manage_vlan",
7292
+ title: "Manage VLAN Interface",
7293
+ description: "Add, remove, enable, or disable a VLAN interface. Idempotent by name: add returns already_exists when a VLAN with matching name, vlan-id, and parent interface exists. Supports dry-run mode.",
7294
+ inputSchema: manageVlanInputSchema,
7295
+ annotations: {
7296
+ readOnlyHint: false,
7297
+ destructiveHint: true,
7298
+ idempotentHint: true,
7299
+ openWorldHint: false
7300
+ },
7301
+ snapshotPaths: ["interface/vlan"],
7302
+ async handler(params, context) {
7303
+ const parsed = manageVlanInputSchema.parse(params);
7304
+ const comment = sanitizeComment4(parsed.comment);
7305
+ log33.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing VLAN");
7306
+ try {
7307
+ const allVlans = await context.routerClient.get("interface/vlan", {
7308
+ limit: void 0,
7309
+ offset: void 0
7310
+ });
7311
+ const existing = allVlans.find((v) => v.name === parsed.name);
7312
+ if (parsed.action === "add") {
7313
+ if (parsed.vlanId === void 0) {
7314
+ throw new MikroMCPError({
7315
+ category: "VALIDATION" /* VALIDATION */,
7316
+ code: "VLAN_ID_REQUIRED",
7317
+ message: "vlanId is required when adding a VLAN.",
7318
+ details: { name: parsed.name },
7319
+ recoverability: { retryable: false, suggestedAction: "Provide vlanId (1-4094)." }
7320
+ });
7321
+ }
7322
+ if (!parsed.parentInterface) {
7323
+ throw new MikroMCPError({
7324
+ category: "VALIDATION" /* VALIDATION */,
7325
+ code: "VLAN_PARENT_REQUIRED",
7326
+ message: "parentInterface is required when adding a VLAN.",
7327
+ details: { name: parsed.name },
7328
+ recoverability: { retryable: false, suggestedAction: "Provide the parent interface name." }
7329
+ });
7330
+ }
7331
+ if (existing) {
7332
+ if (existing["vlan-id"] === String(parsed.vlanId) && existing.interface === parsed.parentInterface) {
7333
+ log33.info({ name: parsed.name }, "VLAN already exists with matching config");
7334
+ return {
7335
+ content: `VLAN "${parsed.name}" already exists with VLAN ID ${parsed.vlanId} on ${parsed.parentInterface}. No changes made.`,
7336
+ structuredContent: { action: "already_exists", vlan: existing }
7337
+ };
7338
+ }
7339
+ throw new MikroMCPError({
7340
+ category: "CONFLICT" /* CONFLICT */,
7341
+ code: "VLAN_NAME_CONFLICT",
7342
+ message: `VLAN "${parsed.name}" already exists but with different configuration (vlan-id=${existing["vlan-id"]}, interface=${existing.interface}).`,
7343
+ details: {
7344
+ existing: { vlanId: existing["vlan-id"], interface: existing.interface },
7345
+ requested: { vlanId: parsed.vlanId, interface: parsed.parentInterface }
7346
+ },
7347
+ recoverability: {
7348
+ retryable: false,
7349
+ suggestedAction: "Use a different name or remove the existing VLAN first.",
7350
+ alternativeTools: ["manage_vlan"]
7351
+ }
7352
+ });
7353
+ }
7354
+ if (parsed.dryRun) {
7355
+ const diff = [
7356
+ { property: "name", before: null, after: parsed.name },
7357
+ { property: "vlan-id", before: null, after: String(parsed.vlanId) },
7358
+ { property: "interface", before: null, after: parsed.parentInterface },
7359
+ { property: "mtu", before: null, after: String(parsed.mtu) },
7360
+ { property: "disabled", before: null, after: parsed.disabled ? "true" : "false" },
7361
+ ...comment ? [{ property: "comment", before: null, after: comment }] : []
7362
+ ];
7363
+ return {
7364
+ content: `Dry run: Would create VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
7365
+ structuredContent: { action: "dry_run", diff }
7366
+ };
7367
+ }
7368
+ const body = {
7369
+ name: parsed.name,
7370
+ "vlan-id": String(parsed.vlanId),
7371
+ interface: parsed.parentInterface,
7372
+ mtu: String(parsed.mtu),
7373
+ disabled: parsed.disabled ? "true" : "false"
7374
+ };
7375
+ if (comment) body.comment = comment;
7376
+ const created = await context.routerClient.create("interface/vlan", body);
7377
+ log33.info({ name: parsed.name, id: created[".id"] }, "VLAN created");
7378
+ return {
7379
+ content: `Created VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
7380
+ structuredContent: { action: "created", vlan: created }
7381
+ };
7382
+ }
7383
+ if (parsed.action === "remove") {
7384
+ if (!existing) {
7385
+ return {
7386
+ content: `VLAN "${parsed.name}" not found. Nothing to remove.`,
7387
+ structuredContent: { action: "not_found", name: parsed.name }
7388
+ };
7389
+ }
7390
+ if (parsed.dryRun) {
7391
+ return {
7392
+ content: `Dry run: Would remove VLAN "${parsed.name}".`,
7393
+ structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] }
7394
+ };
7395
+ }
7396
+ await context.routerClient.remove("interface/vlan", existing[".id"]);
7397
+ log33.info({ name: parsed.name }, "VLAN removed");
7398
+ return {
7399
+ content: `Removed VLAN "${parsed.name}".`,
7400
+ structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
7401
+ };
7402
+ }
7403
+ if (!existing) {
7404
+ throw new MikroMCPError({
7405
+ category: "NOT_FOUND" /* NOT_FOUND */,
7406
+ code: "VLAN_NOT_FOUND",
7407
+ message: `VLAN "${parsed.name}" not found.`,
7408
+ details: { name: parsed.name },
7409
+ recoverability: {
7410
+ retryable: false,
7411
+ suggestedAction: "Verify the VLAN name with list_interfaces.",
7412
+ alternativeTools: ["list_interfaces"]
7413
+ }
7414
+ });
7415
+ }
7416
+ if (parsed.dryRun) {
7417
+ return {
7418
+ content: `Dry run: Would ${parsed.action} VLAN "${parsed.name}".`,
7419
+ structuredContent: {
7420
+ action: "dry_run",
7421
+ diff: [{ property: "disabled", before: existing.disabled, after: parsed.action === "disable" ? "true" : "false" }]
7422
+ }
7423
+ };
7424
+ }
7425
+ const disabledValue = parsed.action === "disable" ? "true" : "false";
7426
+ await context.routerClient.update("interface/vlan", existing[".id"], { disabled: disabledValue });
7427
+ const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
7428
+ log33.info({ name: parsed.name, action: resultAction }, "VLAN updated");
7429
+ return {
7430
+ content: `VLAN "${parsed.name}" ${resultAction}.`,
7431
+ structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
7432
+ };
7433
+ } catch (err) {
7434
+ if (err instanceof MikroMCPError) throw err;
7435
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_vlan" });
7436
+ }
7437
+ }
7438
+ };
7439
+ var vlanTools = [manageVlanTool];
7440
+
7441
+ // src/domain/tools/dhcp-client-tools.ts
7442
+ import { z as z33 } from "zod";
7443
+ var log34 = createLogger("dhcp-client-tools");
7444
+ var listDhcpClientsInputSchema = z33.object({
7445
+ routerId: z33.string().describe("Target router identifier from the router registry"),
7446
+ interface: z33.string().optional().describe("Filter by interface name (exact match)"),
7447
+ status: z33.enum(["bound", "searching", "requesting", "init", "all"]).default("all").describe("Filter by DHCP client status"),
7448
+ limit: z33.number().int().min(1).max(500).default(100).describe("Maximum number of clients to return"),
7449
+ offset: z33.number().int().min(0).default(0).describe("Offset for pagination")
7450
+ }).strict();
7451
+ var listDhcpClientsTool = {
7452
+ name: "list_dhcp_clients",
7453
+ title: "List DHCP Clients",
7454
+ description: "List DHCP client configurations on a MikroTik router. Shows which interfaces obtain their IP via DHCP, current status, and assigned address.",
7455
+ inputSchema: listDhcpClientsInputSchema,
7456
+ annotations: {
7457
+ readOnlyHint: true,
7458
+ destructiveHint: false,
7459
+ idempotentHint: true,
7460
+ openWorldHint: false
7461
+ },
7462
+ async handler(params, context) {
7463
+ const parsed = listDhcpClientsInputSchema.parse(params);
7464
+ log34.info({ routerId: context.routerId }, "Listing DHCP clients");
7465
+ try {
7466
+ const allClients = await context.routerClient.get("ip/dhcp-client", {
7467
+ limit: void 0,
7468
+ offset: void 0
7469
+ });
7470
+ let filtered = allClients;
7471
+ if (parsed.interface) {
7472
+ filtered = filtered.filter((c) => c.interface === parsed.interface);
7473
+ }
7474
+ if (parsed.status !== "all") {
7475
+ filtered = filtered.filter((c) => c.status === parsed.status);
7476
+ }
7477
+ const total = filtered.length;
7478
+ const clients = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
7479
+ const hasMore = parsed.offset + parsed.limit < total;
7480
+ const lines = [
7481
+ `DHCP clients on ${context.routerId}: ${total} total, showing ${clients.length} (offset ${parsed.offset})`
7482
+ ];
7483
+ for (const c of clients) {
7484
+ const rec = c;
7485
+ const iface = rec.interface ?? "unknown";
7486
+ const status = rec.status ?? "unknown";
7487
+ const address = rec.address ?? rec["active-address"] ?? "-";
7488
+ const isDisabled = rec.disabled === true || rec.disabled === "true";
7489
+ lines.push(` ${iface} [${status}] ${address}${isDisabled ? " DISABLED" : ""}`);
7490
+ }
7491
+ return {
7492
+ content: lines.join("\n"),
7493
+ structuredContent: {
7494
+ routerId: context.routerId,
7495
+ clients,
7496
+ total,
7497
+ hasMore,
7498
+ offset: parsed.offset,
7499
+ limit: parsed.limit
7500
+ }
7501
+ };
7502
+ } catch (err) {
7503
+ throw enrichError(err, { routerId: context.routerId, tool: "list_dhcp_clients" });
7504
+ }
7505
+ }
7506
+ };
7507
+ var manageDhcpClientInputSchema = z33.object({
7508
+ routerId: z33.string().describe("Target router identifier from the router registry"),
7509
+ action: z33.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
7510
+ interface: z33.string().describe("Interface name \u2014 idempotency key (e.g. ether1, ether2)"),
7511
+ usePeerDns: z33.boolean().default(true).describe("Use DNS servers provided by DHCP server (add only)"),
7512
+ usePeerNtp: z33.boolean().default(false).describe("Use NTP servers provided by DHCP server (add only)"),
7513
+ addDefaultRoute: z33.boolean().default(true).describe("Add default route from DHCP (add only)"),
7514
+ comment: z33.string().max(255).optional().describe("Optional comment (add only)"),
7515
+ dryRun: z33.boolean().default(false).describe("Preview changes without applying")
7516
+ }).strict();
7517
+ var manageDhcpClientTool = {
7518
+ name: "manage_dhcp_client",
7519
+ title: "Manage DHCP Client",
7520
+ description: "Add, remove, enable, or disable a DHCP client on an interface. Idempotent by interface name: add returns already_exists if a DHCP client is already configured on the same interface.",
7521
+ inputSchema: manageDhcpClientInputSchema,
7522
+ annotations: {
7523
+ readOnlyHint: false,
7524
+ destructiveHint: true,
7525
+ idempotentHint: true,
7526
+ openWorldHint: false
7527
+ },
7528
+ snapshotPaths: ["ip/dhcp-client"],
7529
+ async handler(params, context) {
7530
+ const parsed = manageDhcpClientInputSchema.parse(params);
7531
+ log34.info({ routerId: context.routerId, action: parsed.action, interface: parsed.interface }, "Managing DHCP client");
7532
+ try {
7533
+ const allClients = await context.routerClient.get("ip/dhcp-client", {
7534
+ limit: void 0,
7535
+ offset: void 0
7536
+ });
7537
+ const existing = allClients.find(
7538
+ (c) => c.interface === parsed.interface
7539
+ );
7540
+ if (parsed.action === "add") {
7541
+ if (existing) {
7542
+ return {
7543
+ content: `DHCP client already configured on "${parsed.interface}". No changes made.`,
7544
+ structuredContent: { action: "already_exists", client: existing }
7545
+ };
7546
+ }
7547
+ if (parsed.dryRun) {
7548
+ const diff = [
7549
+ { property: "interface", before: null, after: parsed.interface },
7550
+ { property: "use-peer-dns", before: null, after: parsed.usePeerDns ? "yes" : "no" },
7551
+ { property: "use-peer-ntp", before: null, after: parsed.usePeerNtp ? "yes" : "no" },
7552
+ { property: "add-default-route", before: null, after: parsed.addDefaultRoute ? "yes" : "no" }
7553
+ ];
7554
+ return {
7555
+ content: `Dry run: Would add DHCP client on "${parsed.interface}".`,
7556
+ structuredContent: { action: "dry_run", diff }
7557
+ };
7558
+ }
7559
+ const body = {
7560
+ interface: parsed.interface,
7561
+ "use-peer-dns": parsed.usePeerDns ? "yes" : "no",
7562
+ "use-peer-ntp": parsed.usePeerNtp ? "yes" : "no",
7563
+ "add-default-route": parsed.addDefaultRoute ? "yes" : "no"
7564
+ };
7565
+ if (parsed.comment) body.comment = parsed.comment;
7566
+ const created = await context.routerClient.create("ip/dhcp-client", body);
7567
+ log34.info({ interface: parsed.interface, id: created[".id"] }, "DHCP client added");
7568
+ return {
7569
+ content: `Added DHCP client on "${parsed.interface}".`,
7570
+ structuredContent: { action: "created", client: created }
7571
+ };
7572
+ }
7573
+ if (parsed.action === "remove") {
7574
+ if (!existing) {
7575
+ return {
7576
+ content: `No DHCP client found on "${parsed.interface}". Nothing to remove.`,
7577
+ structuredContent: { action: "not_found", interface: parsed.interface }
7578
+ };
7579
+ }
7580
+ if (parsed.dryRun) {
7581
+ return {
7582
+ content: `Dry run: Would remove DHCP client on "${parsed.interface}".`,
7583
+ structuredContent: {
7584
+ action: "dry_run",
7585
+ diff: [{ property: "interface", before: parsed.interface, after: null }]
7586
+ }
7587
+ };
7588
+ }
7589
+ await context.routerClient.remove("ip/dhcp-client", existing[".id"]);
7590
+ log34.info({ interface: parsed.interface }, "DHCP client removed");
7591
+ return {
7592
+ content: `Removed DHCP client on "${parsed.interface}".`,
7593
+ structuredContent: { action: "removed", interface: parsed.interface, id: existing[".id"] }
7594
+ };
7595
+ }
7596
+ if (!existing) {
7597
+ throw new MikroMCPError({
7598
+ category: "NOT_FOUND" /* NOT_FOUND */,
7599
+ code: "DHCP_CLIENT_NOT_FOUND",
7600
+ message: `No DHCP client found on interface "${parsed.interface}".`,
7601
+ details: { interface: parsed.interface },
7602
+ recoverability: {
7603
+ retryable: false,
7604
+ suggestedAction: "Verify with list_dhcp_clients.",
7605
+ alternativeTools: ["list_dhcp_clients"]
7606
+ }
7607
+ });
7608
+ }
7609
+ if (parsed.dryRun) {
7610
+ return {
7611
+ content: `Dry run: Would ${parsed.action} DHCP client on "${parsed.interface}".`,
7612
+ structuredContent: {
7613
+ action: "dry_run",
7614
+ diff: [
7615
+ {
7616
+ property: "disabled",
7617
+ before: existing.disabled,
7618
+ after: parsed.action === "disable" ? "true" : "false"
7619
+ }
7620
+ ]
7621
+ }
7622
+ };
7623
+ }
7624
+ const disabledValue = parsed.action === "disable" ? "true" : "false";
7625
+ await context.routerClient.update("ip/dhcp-client", existing[".id"], { disabled: disabledValue });
7626
+ const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
7627
+ log34.info({ interface: parsed.interface, action: resultAction }, "DHCP client updated");
7628
+ return {
7629
+ content: `DHCP client on "${parsed.interface}" ${resultAction}.`,
7630
+ structuredContent: { action: resultAction, interface: parsed.interface, id: existing[".id"] }
7631
+ };
7632
+ } catch (err) {
7633
+ if (err instanceof MikroMCPError) throw err;
7634
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_dhcp_client" });
7635
+ }
7636
+ }
7637
+ };
7638
+ var dhcpClientTools = [listDhcpClientsTool, manageDhcpClientTool];
7639
+
7640
+ // src/domain/tools/ip-service-tools.ts
7641
+ import { z as z34 } from "zod";
7642
+ var log35 = createLogger("ip-service-tools");
7643
+ var SERVICE_NAMES = ["api", "api-ssl", "ssh", "telnet", "www", "www-ssl", "winbox", "ftp"];
7644
+ var listIpServicesInputSchema = z34.object({
7645
+ routerId: z34.string().describe("Target router identifier from the router registry"),
7646
+ name: z34.enum(SERVICE_NAMES).optional().describe("Filter by service name (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp)"),
7647
+ enabled: z34.boolean().optional().describe("When true, return only enabled services; when false, only disabled services")
7648
+ }).strict();
7649
+ var listIpServicesTool = {
7650
+ name: "list_ip_services",
7651
+ title: "List IP Services",
7652
+ description: "List IP services on a MikroTik router (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp) with their port numbers and enabled/disabled status.",
7653
+ inputSchema: listIpServicesInputSchema,
7654
+ annotations: {
7655
+ readOnlyHint: true,
7656
+ destructiveHint: false,
7657
+ idempotentHint: true,
7658
+ openWorldHint: false
7659
+ },
7660
+ async handler(params, context) {
7661
+ const parsed = listIpServicesInputSchema.parse(params);
7662
+ log35.info({ routerId: context.routerId }, "Listing IP services");
7663
+ try {
7664
+ const allServices = await context.routerClient.get("ip/service", {
7665
+ limit: void 0,
7666
+ offset: void 0
7667
+ });
7668
+ let filtered = allServices;
7669
+ if (parsed.name) {
7670
+ filtered = filtered.filter((s) => s.name === parsed.name);
7671
+ }
7672
+ if (parsed.enabled !== void 0) {
7673
+ filtered = filtered.filter((s) => {
7674
+ const isDisabled = s.disabled === true || s.disabled === "true";
7675
+ return parsed.enabled ? !isDisabled : isDisabled;
7676
+ });
7677
+ }
7678
+ const lines = [`IP services on ${context.routerId}: ${filtered.length} returned`];
7679
+ for (const s of filtered) {
7680
+ const rec = s;
7681
+ const isDisabled = rec.disabled === true || rec.disabled === "true";
7682
+ lines.push(` ${rec.name} port:${rec.port ?? "-"} [${isDisabled ? "DISABLED" : "enabled"}]`);
7683
+ }
7684
+ return {
7685
+ content: lines.join("\n"),
7686
+ structuredContent: {
7687
+ routerId: context.routerId,
7688
+ services: filtered,
7689
+ total: filtered.length
7690
+ }
7691
+ };
7692
+ } catch (err) {
7693
+ throw enrichError(err, { routerId: context.routerId, tool: "list_ip_services" });
7694
+ }
7695
+ }
7696
+ };
7697
+ var manageIpServiceInputSchema = z34.object({
7698
+ routerId: z34.string().describe("Target router identifier from the router registry"),
7699
+ action: z34.enum(["enable", "disable"]).describe(
7700
+ "Action to perform \u2014 only enable/disable to prevent accidental lockout from changing ports"
7701
+ ),
7702
+ name: z34.enum(SERVICE_NAMES).describe("Service name to manage (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp)"),
7703
+ dryRun: z34.boolean().default(false).describe("Preview changes without applying")
7704
+ }).strict();
7705
+ var manageIpServiceTool = {
7706
+ name: "manage_ip_service",
7707
+ title: "Manage IP Service",
7708
+ description: "Enable or disable a RouterOS IP service (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp). Port number changes are intentionally not supported to prevent accidental lockout.",
7709
+ inputSchema: manageIpServiceInputSchema,
7710
+ annotations: {
7711
+ readOnlyHint: false,
7712
+ destructiveHint: true,
7713
+ idempotentHint: true,
7714
+ openWorldHint: false
7715
+ },
7716
+ async handler(params, context) {
7717
+ const parsed = manageIpServiceInputSchema.parse(params);
7718
+ log35.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing IP service");
7719
+ try {
7720
+ const allServices = await context.routerClient.get("ip/service", {
7721
+ limit: void 0,
7722
+ offset: void 0
7723
+ });
7724
+ const existing = allServices.find((s) => s.name === parsed.name);
7725
+ if (!existing) {
7726
+ throw new MikroMCPError({
7727
+ category: "NOT_FOUND" /* NOT_FOUND */,
7728
+ code: "IP_SERVICE_NOT_FOUND",
7729
+ message: `IP service "${parsed.name}" not found on router.`,
7730
+ details: { name: parsed.name },
7731
+ recoverability: {
7732
+ retryable: false,
7733
+ suggestedAction: "Verify service name with list_ip_services.",
7734
+ alternativeTools: ["list_ip_services"]
7735
+ }
7736
+ });
7737
+ }
7738
+ const isCurrentlyDisabled = existing.disabled === "true" || existing.disabled === true;
7739
+ const wouldBeDisabled = parsed.action === "disable";
7740
+ if (isCurrentlyDisabled === wouldBeDisabled) {
7741
+ const alreadyState = parsed.action === "disable" ? "disabled" : "enabled";
7742
+ return {
7743
+ content: `IP service "${parsed.name}" is already ${alreadyState}. No changes made.`,
7744
+ structuredContent: { action: "no_change", name: parsed.name, id: existing[".id"] }
7745
+ };
7746
+ }
7747
+ if (parsed.dryRun) {
7748
+ return {
7749
+ content: `Dry run: Would ${parsed.action} IP service "${parsed.name}".`,
7750
+ structuredContent: {
7751
+ action: "dry_run",
7752
+ diff: [
7753
+ {
7754
+ property: "disabled",
7755
+ before: existing.disabled,
7756
+ after: parsed.action === "disable" ? "true" : "false"
7757
+ }
7758
+ ]
7759
+ }
7760
+ };
7761
+ }
7762
+ const disabledValue = parsed.action === "disable" ? "true" : "false";
7763
+ await context.routerClient.update("ip/service", existing[".id"], { disabled: disabledValue });
7764
+ const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
7765
+ log35.info({ name: parsed.name, action: resultAction }, "IP service updated");
7766
+ return {
7767
+ content: `IP service "${parsed.name}" ${resultAction}.`,
7768
+ structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
7769
+ };
7770
+ } catch (err) {
7771
+ if (err instanceof MikroMCPError) throw err;
7772
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_ip_service" });
7773
+ }
7774
+ }
7775
+ };
7776
+ var ipServiceTools = [listIpServicesTool, manageIpServiceTool];
7777
+
7778
+ // src/domain/tools/fleet-tools.ts
7779
+ import { z as z35 } from "zod";
7780
+
7781
+ // src/middleware/authz.ts
7782
+ function matchesPattern(name, pattern) {
7783
+ if (!pattern.includes("*")) return name === pattern;
7784
+ const prefix = pattern.slice(0, pattern.indexOf("*"));
7785
+ return name.startsWith(prefix);
7786
+ }
7787
+ function checkAuthz(identity, toolName, routerId) {
7788
+ if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId)) {
7789
+ throw new MikroMCPError({
7790
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
7791
+ code: "ROUTER_NOT_ALLOWED",
7792
+ message: `Identity "${identity.id}" is not allowed to access router "${routerId}".`,
7793
+ details: { identityId: identity.id, routerId, allowedRouters: identity.allowedRouters },
7794
+ recoverability: {
7795
+ retryable: false,
7796
+ suggestedAction: `Add "${routerId}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
7797
+ }
7798
+ });
7799
+ }
7800
+ if (identity.allowedToolPatterns.length > 0) {
7801
+ const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
7802
+ if (!allowed) {
7803
+ throw new MikroMCPError({
7804
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
7805
+ code: "TOOL_NOT_ALLOWED",
7806
+ message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
7807
+ details: { identityId: identity.id, toolName, allowedToolPatterns: identity.allowedToolPatterns },
7808
+ recoverability: {
7809
+ retryable: false,
7810
+ suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
7811
+ }
7812
+ });
7813
+ }
7814
+ }
7815
+ }
7816
+
7817
+ // src/config/secrets.ts
7818
+ function getCredentials(router) {
7819
+ const { source, envPrefix, vaultPath } = router.credentials;
7820
+ if (source === "vault") {
7821
+ throw new MikroMCPError({
7822
+ category: "CONFIGURATION" /* CONFIGURATION */,
7823
+ code: "VAULT_NOT_SUPPORTED",
7824
+ message: `Vault credential source is not supported in v0.1 (vaultPath=${vaultPath ?? "n/a"})`,
7825
+ recoverability: {
7826
+ retryable: false,
7827
+ suggestedAction: 'Switch to source="env" and provide credentials via environment variables.'
7828
+ }
7829
+ });
7830
+ }
7831
+ if (!envPrefix) {
7832
+ throw new MikroMCPError({
7833
+ category: "CONFIGURATION" /* CONFIGURATION */,
7834
+ code: "MISSING_ENV_PREFIX",
7835
+ message: `Router "${router.id}" uses source="env" but no envPrefix is configured`,
7836
+ recoverability: {
7837
+ retryable: false,
7838
+ suggestedAction: "Set envPrefix in the router configuration."
7839
+ }
7840
+ });
7841
+ }
7842
+ const userKey = `${envPrefix}_USER`;
7843
+ const passKey = `${envPrefix}_PASS`;
7844
+ const username = process.env[userKey];
7845
+ const password = process.env[passKey];
7846
+ if (!username || !password) {
7847
+ const missing = [!username ? userKey : null, !password ? passKey : null].filter(Boolean);
7848
+ throw new MikroMCPError({
7849
+ category: "CONFIGURATION" /* CONFIGURATION */,
7850
+ code: "MISSING_CREDENTIALS",
7851
+ message: `Missing environment variable(s) for router "${router.id}": ${missing.join(", ")}`,
7852
+ details: { missing },
7853
+ recoverability: {
7854
+ retryable: false,
7855
+ suggestedAction: `Set the ${missing.join(" and ")} environment variable(s).`
7856
+ }
7857
+ });
7858
+ }
7859
+ return { username, password };
7860
+ }
7861
+
7862
+ // src/adapter/ssh-client.ts
7863
+ import { createHash } from "crypto";
7864
+ import { Client } from "ssh2";
7865
+ var SshClient = class {
7866
+ constructor(config, credentials, options) {
7867
+ this.config = config;
7293
7868
  this.credentials = credentials;
7294
7869
  this.commandTimeoutMs = options?.commandTimeoutMs ?? 3e4;
7295
7870
  this.maxOutputBytes = options?.maxOutputBytes ?? 512 * 1024;
@@ -7356,8 +7931,8 @@ var SshClient = class {
7356
7931
  };
7357
7932
  if (this.config.sshFingerprint) {
7358
7933
  const expected = this.config.sshFingerprint.toLowerCase();
7359
- connectOptions.hostVerifier = (key) => {
7360
- const actual = createHash("sha256").update(key).digest("hex");
7934
+ connectOptions.hostVerifier = (key2) => {
7935
+ const actual = createHash("sha256").update(key2).digest("hex");
7361
7936
  return actual === expected;
7362
7937
  };
7363
7938
  }
@@ -7369,7 +7944,7 @@ var SshClient = class {
7369
7944
  // src/adapter/ftp-client.ts
7370
7945
  import { Client as Client2 } from "basic-ftp";
7371
7946
  import { Readable } from "stream";
7372
- var log32 = createLogger("ftp-client");
7947
+ var log36 = createLogger("ftp-client");
7373
7948
  var FtpClient = class {
7374
7949
  #host;
7375
7950
  #port;
@@ -7382,7 +7957,7 @@ var FtpClient = class {
7382
7957
  this.#password = password;
7383
7958
  }
7384
7959
  async upload(remoteName, content) {
7385
- log32.info({ host: this.#host, remoteName }, "Uploading file via FTP");
7960
+ log36.info({ host: this.#host, remoteName }, "Uploading file via FTP");
7386
7961
  const client = new Client2();
7387
7962
  try {
7388
7963
  await client.access({
@@ -7392,7 +7967,7 @@ var FtpClient = class {
7392
7967
  password: this.#password
7393
7968
  });
7394
7969
  await client.uploadFrom(Readable.from(Buffer.from(content, "utf-8")), remoteName);
7395
- log32.info({ host: this.#host, remoteName }, "FTP upload complete");
7970
+ log36.info({ host: this.#host, remoteName }, "FTP upload complete");
7396
7971
  } finally {
7397
7972
  client.close();
7398
7973
  }
@@ -7413,51 +7988,6 @@ var FtpClient = class {
7413
7988
  }
7414
7989
  };
7415
7990
 
7416
- // src/config/secrets.ts
7417
- function getCredentials(router) {
7418
- const { source, envPrefix, vaultPath } = router.credentials;
7419
- if (source === "vault") {
7420
- throw new MikroMCPError({
7421
- category: "CONFIGURATION" /* CONFIGURATION */,
7422
- code: "VAULT_NOT_SUPPORTED",
7423
- message: `Vault credential source is not supported in v0.1 (vaultPath=${vaultPath ?? "n/a"})`,
7424
- recoverability: {
7425
- retryable: false,
7426
- suggestedAction: 'Switch to source="env" and provide credentials via environment variables.'
7427
- }
7428
- });
7429
- }
7430
- if (!envPrefix) {
7431
- throw new MikroMCPError({
7432
- category: "CONFIGURATION" /* CONFIGURATION */,
7433
- code: "MISSING_ENV_PREFIX",
7434
- message: `Router "${router.id}" uses source="env" but no envPrefix is configured`,
7435
- recoverability: {
7436
- retryable: false,
7437
- suggestedAction: "Set envPrefix in the router configuration."
7438
- }
7439
- });
7440
- }
7441
- const userKey = `${envPrefix}_USER`;
7442
- const passKey = `${envPrefix}_PASS`;
7443
- const username = process.env[userKey];
7444
- const password = process.env[passKey];
7445
- if (!username || !password) {
7446
- const missing = [!username ? userKey : null, !password ? passKey : null].filter(Boolean);
7447
- throw new MikroMCPError({
7448
- category: "CONFIGURATION" /* CONFIGURATION */,
7449
- code: "MISSING_CREDENTIALS",
7450
- message: `Missing environment variable(s) for router "${router.id}": ${missing.join(", ")}`,
7451
- details: { missing },
7452
- recoverability: {
7453
- retryable: false,
7454
- suggestedAction: `Set the ${missing.join(" and ")} environment variable(s).`
7455
- }
7456
- });
7457
- }
7458
- return { username, password };
7459
- }
7460
-
7461
7991
  // src/adapter/adapter-factory.ts
7462
7992
  function createSshClient(routerConfig, options) {
7463
7993
  const creds = getCredentials(routerConfig);
@@ -7468,17 +7998,96 @@ function createFtpClient(routerConfig) {
7468
7998
  return new FtpClient(routerConfig.host, 21, creds.username, creds.password);
7469
7999
  }
7470
8000
 
8001
+ // src/mcp/tool-context.ts
8002
+ function buildRouterToolContext(args) {
8003
+ const { routerConfig, correlationId, identity, pool, config, registry } = args;
8004
+ const credentials = getCredentials(routerConfig);
8005
+ return {
8006
+ routerClient: pool.getClient(routerConfig, credentials),
8007
+ routerId: routerConfig.id,
8008
+ correlationId,
8009
+ routerConfig,
8010
+ sshClient: createSshClient(routerConfig, config.ssh),
8011
+ ftpClient: createFtpClient(routerConfig),
8012
+ identity,
8013
+ routerRegistry: registry,
8014
+ connectionPool: pool,
8015
+ appConfig: config
8016
+ };
8017
+ }
8018
+
8019
+ // src/middleware/fleet-confirmation.ts
8020
+ import { createHmac, createHash as createHash2 } from "crypto";
8021
+ var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
8022
+ var pending = /* @__PURE__ */ new Map();
8023
+ function fingerprint(args) {
8024
+ const sortedRouters = [...args.routerIds].sort();
8025
+ const payload = JSON.stringify({
8026
+ toolName: args.toolName,
8027
+ routerIds: sortedRouters,
8028
+ params: args.params,
8029
+ identityId: args.identityId
8030
+ });
8031
+ return createHash2("sha256").update(payload).digest("hex");
8032
+ }
8033
+ function evictExpired() {
8034
+ const now = /* @__PURE__ */ new Date();
8035
+ for (const [token, entry] of pending) {
8036
+ if (entry.expiresAt <= now) pending.delete(token);
8037
+ }
8038
+ }
8039
+ function checkFleetConfirmation(args, secret) {
8040
+ evictExpired();
8041
+ const fp = fingerprint(args);
8042
+ if (args.submittedToken) {
8043
+ const entry = pending.get(args.submittedToken);
8044
+ if (!entry || entry.expiresAt <= /* @__PURE__ */ new Date() || entry.fingerprint !== fp) {
8045
+ throw new MikroMCPError({
8046
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
8047
+ code: "FLEET_CONFIRMATION_MISMATCH",
8048
+ message: "Fleet confirmation token is expired, already used, or does not match the current toolName/routerIds/params.",
8049
+ recoverability: {
8050
+ retryable: true,
8051
+ suggestedAction: "Re-submit bulk_execute without confirmationToken to get a fresh token."
8052
+ }
8053
+ });
8054
+ }
8055
+ pending.delete(args.submittedToken);
8056
+ return;
8057
+ }
8058
+ const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS);
8059
+ const token = createHmac("sha256", secret).update(`${fp}:${expiresAt.toISOString()}`).digest("hex");
8060
+ pending.set(token, { expiresAt, fingerprint: fp });
8061
+ throw new MikroMCPError({
8062
+ category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
8063
+ code: "FLEET_CONFIRMATION_REQUIRED",
8064
+ message: `This will run destructive tool "${args.toolName}" across ${args.routerIds.length} router(s). Re-submit with confirmationToken to proceed.`,
8065
+ details: {
8066
+ confirmationToken: token,
8067
+ expiresAt: expiresAt.toISOString(),
8068
+ routerCount: args.routerIds.length
8069
+ },
8070
+ recoverability: {
8071
+ retryable: true,
8072
+ suggestedAction: "Re-submit the identical bulk_execute call with details.confirmationToken."
8073
+ }
8074
+ });
8075
+ }
8076
+
7471
8077
  // src/domain/tools/fleet-tools.ts
7472
- var log33 = createLogger("fleet-tools");
7473
- var checkHealthInputSchema = z31.object({
7474
- routerId: z31.string().describe("Target router to health-check")
8078
+ var log37 = createLogger("fleet-tools");
8079
+ var checkHealthInputSchema = z35.object({
8080
+ routerId: z35.string().describe("Target router to health-check")
7475
8081
  }).strict();
7476
- var bulkExecuteInputSchema = z31.object({
7477
- toolName: z31.string().describe("Name of the tool to fan out (must be a single-router tool)"),
7478
- routerIds: z31.array(z31.string()).optional().describe("Explicit list of router IDs to target"),
7479
- tags: z31.array(z31.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
7480
- params: z31.record(z31.string(), z31.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
7481
- concurrency: z31.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls")
8082
+ var bulkExecuteInputSchema = z35.object({
8083
+ toolName: z35.string().describe("Name of the tool to fan out (must be a single-router tool)"),
8084
+ routerIds: z35.array(z35.string()).optional().describe("Explicit list of router IDs to target"),
8085
+ tags: z35.array(z35.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
8086
+ params: z35.record(z35.string(), z35.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
8087
+ concurrency: z35.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
8088
+ confirmationToken: z35.string().optional().describe(
8089
+ "Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
8090
+ )
7482
8091
  }).strict();
7483
8092
  function createFleetTools(baseTools2) {
7484
8093
  const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
@@ -7495,7 +8104,7 @@ function createFleetTools(baseTools2) {
7495
8104
  },
7496
8105
  async handler(params, context) {
7497
8106
  checkHealthInputSchema.parse(params);
7498
- log33.info({ routerId: context.routerId }, "Checking router health");
8107
+ log37.info({ routerId: context.routerId }, "Checking router health");
7499
8108
  const startMs = Date.now();
7500
8109
  try {
7501
8110
  const resources = await context.routerClient.get("system/resource");
@@ -7533,7 +8142,7 @@ function createFleetTools(baseTools2) {
7533
8142
  const bulkExecuteTool = {
7534
8143
  name: "bulk_execute",
7535
8144
  title: "Bulk Execute",
7536
- description: "Fan out a single-router tool to multiple routers in parallel (up to `concurrency` at a time). Target routers via explicit routerIds or by tag. Destructive tools are not allowed. Returns per-router results with succeeded/failed counts.",
8145
+ description: "Fan out a single-router tool to multiple routers in parallel (up to `concurrency` at a time). Target routers via explicit routerIds or by tag. Non-destructive tools fan out immediately. Destructive tools require a two-step confirmation: call without `confirmationToken` to receive a fleet token (requires `MIKROMCP_CONFIRMATION_SECRET`), then re-call with that token to proceed. Returns per-router results with succeeded/failed counts.",
7537
8146
  inputSchema: bulkExecuteInputSchema,
7538
8147
  annotations: {
7539
8148
  readOnlyHint: false,
@@ -7557,10 +8166,21 @@ function createFleetTools(baseTools2) {
7557
8166
  }
7558
8167
  });
7559
8168
  }
7560
- log33.info(
8169
+ log37.info(
7561
8170
  { toolName: parsed.toolName, concurrency: parsed.concurrency },
7562
8171
  "bulk_execute invoked"
7563
8172
  );
8173
+ auditLog({
8174
+ type: "audit",
8175
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8176
+ correlationId: context.correlationId,
8177
+ identityId: context.identity.id,
8178
+ role: context.identity.role,
8179
+ tool: "bulk_execute",
8180
+ routerId: "(fleet)",
8181
+ phase: "attempt",
8182
+ params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
8183
+ }, context.appConfig.auditLogPath);
7564
8184
  if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
7565
8185
  throw new MikroMCPError({
7566
8186
  category: "VALIDATION" /* VALIDATION */,
@@ -7585,17 +8205,6 @@ function createFleetTools(baseTools2) {
7585
8205
  });
7586
8206
  }
7587
8207
  const targetTool = foundTool;
7588
- if (targetTool.annotations.destructiveHint) {
7589
- throw new MikroMCPError({
7590
- category: "VALIDATION" /* VALIDATION */,
7591
- code: "BULK_DESTRUCTIVE_NOT_ALLOWED",
7592
- message: `Tool "${parsed.toolName}" is destructive. Destructive tools require per-router confirmation and cannot be fanned out via bulk_execute.`,
7593
- recoverability: {
7594
- retryable: false,
7595
- suggestedAction: "Call the tool directly on each router to provide the required confirmation token."
7596
- }
7597
- });
7598
- }
7599
8208
  const preErrors = [];
7600
8209
  let routers;
7601
8210
  if (hasRouterIds) {
@@ -7604,15 +8213,56 @@ function createFleetTools(baseTools2) {
7604
8213
  try {
7605
8214
  resolved.push(context.routerRegistry.getRouter(id));
7606
8215
  } catch {
7607
- log33.warn({ routerId: id }, "bulk_execute: router not found");
7608
- preErrors.push({ routerId: id, status: "error", error: `Router "${id}" not found in registry`, durationMs: 0 });
8216
+ log37.warn({ routerId: id }, "bulk_execute: router not found");
8217
+ preErrors.push({
8218
+ routerId: id,
8219
+ status: "error",
8220
+ error: `Router "${id}" not found in registry`,
8221
+ durationMs: 0
8222
+ });
7609
8223
  }
7610
8224
  }
7611
8225
  routers = resolved;
7612
8226
  } else {
7613
8227
  routers = context.routerRegistry.listRouters(parsed.tags);
7614
8228
  }
8229
+ if (targetTool.annotations.destructiveHint) {
8230
+ const secret = context.appConfig.confirmationSecret;
8231
+ if (!secret) {
8232
+ throw new MikroMCPError({
8233
+ category: "CONFIGURATION" /* CONFIGURATION */,
8234
+ code: "FLEET_CONFIRMATION_UNAVAILABLE",
8235
+ message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
8236
+ recoverability: {
8237
+ retryable: false,
8238
+ suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
8239
+ }
8240
+ });
8241
+ }
8242
+ const routerIdList = routers.map((r) => r.id);
8243
+ checkFleetConfirmation(
8244
+ {
8245
+ toolName: parsed.toolName,
8246
+ routerIds: routerIdList,
8247
+ params: parsed.params,
8248
+ identityId: context.identity.id,
8249
+ submittedToken: parsed.confirmationToken
8250
+ },
8251
+ secret
8252
+ );
8253
+ }
7615
8254
  if (routers.length === 0 && preErrors.length === 0) {
8255
+ auditLog({
8256
+ type: "audit",
8257
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8258
+ correlationId: context.correlationId,
8259
+ identityId: context.identity.id,
8260
+ role: context.identity.role,
8261
+ tool: "bulk_execute",
8262
+ routerId: "(fleet)",
8263
+ phase: "success",
8264
+ params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
8265
+ }, context.appConfig.auditLogPath);
7616
8266
  return {
7617
8267
  content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
7618
8268
  structuredContent: {
@@ -7628,28 +8278,50 @@ function createFleetTools(baseTools2) {
7628
8278
  const start = Date.now();
7629
8279
  try {
7630
8280
  checkAuthz(context.identity, parsed.toolName, router.id);
7631
- const credentials = getCredentials(router);
7632
- const client = context.connectionPool.getClient(router, credentials);
7633
- const sshClient = createSshClient(router, {});
7634
- const ftpClient = createFtpClient(router);
7635
- const routerContext = {
7636
- routerClient: client,
7637
- routerId: router.id,
7638
- correlationId: context.correlationId,
8281
+ const routerContext = buildRouterToolContext({
7639
8282
  routerConfig: router,
7640
- sshClient,
7641
- ftpClient,
7642
- identity: context.identity
7643
- };
8283
+ correlationId: context.correlationId,
8284
+ identity: context.identity,
8285
+ pool: context.connectionPool,
8286
+ config: context.appConfig,
8287
+ registry: context.routerRegistry
8288
+ });
7644
8289
  const toolParams = { ...parsed.params, routerId: router.id };
7645
8290
  const result = await targetTool.handler(
7646
8291
  toolParams,
7647
8292
  routerContext
7648
8293
  );
7649
- return { routerId: router.id, status: "ok", result, durationMs: Date.now() - start };
8294
+ const elapsed = Date.now() - start;
8295
+ auditLog({
8296
+ type: "audit",
8297
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8298
+ correlationId: context.correlationId,
8299
+ identityId: context.identity.id,
8300
+ role: context.identity.role,
8301
+ tool: parsed.toolName,
8302
+ routerId: router.id,
8303
+ phase: "success",
8304
+ params: parsed.params,
8305
+ durationMs: elapsed
8306
+ }, context.appConfig.auditLogPath);
8307
+ return { routerId: router.id, status: "ok", result, durationMs: elapsed };
7650
8308
  } catch (err) {
8309
+ const elapsed = Date.now() - start;
7651
8310
  const message = err instanceof Error ? err.message : String(err);
7652
- return { routerId: router.id, status: "error", error: message, durationMs: Date.now() - start };
8311
+ auditLog({
8312
+ type: "audit",
8313
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8314
+ correlationId: context.correlationId,
8315
+ identityId: context.identity.id,
8316
+ role: context.identity.role,
8317
+ tool: parsed.toolName,
8318
+ routerId: router.id,
8319
+ phase: "failure",
8320
+ params: parsed.params,
8321
+ outcome: message,
8322
+ durationMs: elapsed
8323
+ }, context.appConfig.auditLogPath);
8324
+ return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
7653
8325
  }
7654
8326
  }
7655
8327
  const results2 = [...preErrors];
@@ -7660,6 +8332,17 @@ function createFleetTools(baseTools2) {
7660
8332
  }
7661
8333
  const succeeded = results2.filter((r) => r.status === "ok").length;
7662
8334
  const failed = results2.filter((r) => r.status === "error").length;
8335
+ auditLog({
8336
+ type: "audit",
8337
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8338
+ correlationId: context.correlationId,
8339
+ identityId: context.identity.id,
8340
+ role: context.identity.role,
8341
+ tool: "bulk_execute",
8342
+ routerId: "(fleet)",
8343
+ phase: "success",
8344
+ params: { toolName: parsed.toolName, succeeded, failed }
8345
+ }, context.appConfig.auditLogPath);
7663
8346
  return {
7664
8347
  content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
7665
8348
  structuredContent: {
@@ -7702,9 +8385,13 @@ var baseTools = [
7702
8385
  ...certificateTools,
7703
8386
  ...userTools,
7704
8387
  ...dhcpServerTools,
8388
+ ...ipPoolTools,
7705
8389
  ...queueTools,
7706
8390
  ...vrrpTools,
7707
- ...networkServicesTools
8391
+ ...networkServicesTools,
8392
+ ...vlanTools,
8393
+ ...dhcpClientTools,
8394
+ ...ipServiceTools
7708
8395
  ];
7709
8396
  var allTools = [
7710
8397
  ...baseTools,
@@ -7712,159 +8399,8 @@ var allTools = [
7712
8399
  ...createFleetTools(baseTools)
7713
8400
  ];
7714
8401
 
7715
- // src/adapter/retry-engine.ts
7716
- var DEFAULTS = {
7717
- maxRetries: 3,
7718
- baseDelayMs: 200,
7719
- maxDelayMs: 5e3
7720
- };
7721
- var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
7722
- function isRetryable(error) {
7723
- if (error instanceof Error) {
7724
- const code = error.code;
7725
- if (code && RETRYABLE_CODES.has(code)) {
7726
- return true;
7727
- }
7728
- const statusCode = error.statusCode;
7729
- if (typeof statusCode === "number") {
7730
- return statusCode >= 500;
7731
- }
7732
- }
7733
- return false;
7734
- }
7735
- async function withRetry(fn, options) {
7736
- const opts = { ...DEFAULTS, ...options };
7737
- let lastError;
7738
- for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
7739
- try {
7740
- return await fn();
7741
- } catch (error) {
7742
- lastError = error;
7743
- if (attempt >= opts.maxRetries || !isRetryable(error)) {
7744
- throw error;
7745
- }
7746
- const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
7747
- const jitter = Math.random() * opts.baseDelayMs;
7748
- const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
7749
- await new Promise((resolve) => setTimeout(resolve, delay));
7750
- }
7751
- }
7752
- throw lastError;
7753
- }
7754
-
7755
- // src/adapter/circuit-breaker.ts
7756
- var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
7757
- "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
7758
- "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
7759
- "ROUTER_ERROR" /* ROUTER_ERROR */,
7760
- "ROUTER_BUSY" /* ROUTER_BUSY */
7761
- ]);
7762
- function isTransientFailure(error) {
7763
- if (!(error instanceof MikroMCPError)) {
7764
- return false;
7765
- }
7766
- return TRANSIENT_CATEGORIES.has(error.category);
7767
- }
7768
- var DEFAULTS2 = {
7769
- failureThreshold: 5,
7770
- cooldownMs: 3e4
7771
- };
7772
- var CircuitBreaker = class {
7773
- constructor(routerId, options) {
7774
- this.routerId = routerId;
7775
- this.options = { ...DEFAULTS2, ...options };
7776
- }
7777
- routerId;
7778
- options;
7779
- currentState = "closed";
7780
- consecutiveFailures = 0;
7781
- lastFailureTime = 0;
7782
- get state() {
7783
- if (this.currentState === "open") {
7784
- const elapsed = Date.now() - this.lastFailureTime;
7785
- if (elapsed >= this.options.cooldownMs) {
7786
- this.currentState = "half-open";
7787
- }
7788
- }
7789
- return this.currentState;
7790
- }
7791
- /**
7792
- * Execute `fn` through the circuit breaker.
7793
- *
7794
- * - **closed**: execute normally; track consecutive failures.
7795
- * - **open**: reject immediately with `ROUTER_BUSY`.
7796
- * - **half-open**: execute as a probe; success closes, failure reopens.
7797
- */
7798
- async execute(fn) {
7799
- const currentState = this.state;
7800
- if (currentState === "open") {
7801
- throw new MikroMCPError({
7802
- category: "ROUTER_BUSY" /* ROUTER_BUSY */,
7803
- code: "CIRCUIT_OPEN",
7804
- message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
7805
- details: {
7806
- routerId: this.routerId,
7807
- consecutiveFailures: this.consecutiveFailures,
7808
- cooldownMs: this.options.cooldownMs
7809
- },
7810
- recoverability: {
7811
- retryable: true,
7812
- retryAfterMs: this.options.cooldownMs,
7813
- suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
7814
- }
7815
- });
7816
- }
7817
- try {
7818
- const result = await fn();
7819
- this.onSuccess();
7820
- return result;
7821
- } catch (error) {
7822
- if (isTransientFailure(error)) {
7823
- this.onFailure();
7824
- }
7825
- throw error;
7826
- }
7827
- }
7828
- /** Force the circuit back to the closed state. */
7829
- reset() {
7830
- this.currentState = "closed";
7831
- this.consecutiveFailures = 0;
7832
- this.lastFailureTime = 0;
7833
- }
7834
- // ---------- internal helpers ----------
7835
- onSuccess() {
7836
- this.consecutiveFailures = 0;
7837
- this.currentState = "closed";
7838
- }
7839
- onFailure() {
7840
- this.consecutiveFailures++;
7841
- this.lastFailureTime = Date.now();
7842
- if (this.currentState === "half-open") {
7843
- this.currentState = "open";
7844
- } else if (this.consecutiveFailures >= this.options.failureThreshold) {
7845
- this.currentState = "open";
7846
- }
7847
- }
7848
- };
7849
-
7850
- // src/mcp/response-formatter.ts
7851
- function formatToolResult(result) {
7852
- return {
7853
- content: [{ type: "text", text: result.content }],
7854
- structuredContent: result.structuredContent,
7855
- ...result.isError ? { isError: true } : {}
7856
- };
7857
- }
7858
- function formatError(error) {
7859
- const hint = error.recoverability.suggestedAction;
7860
- const text = `Error [${error.category}]: ${error.message}${hint ? `
7861
- Suggested action: ${hint}` : ""}`;
7862
- return {
7863
- content: [{ type: "text", text }],
7864
- structuredContent: error.toJSON(),
7865
- isError: true
7866
- };
7867
- }
8402
+ // src/mcp/tool-executor.ts
8403
+ import { nanoid as nanoid4 } from "nanoid";
7868
8404
 
7869
8405
  // src/observability/correlation.ts
7870
8406
  import { AsyncLocalStorage } from "async_hooks";
@@ -7880,9 +8416,6 @@ function withContext(ctx, fn) {
7880
8416
  return requestContext.run(fullContext, fn);
7881
8417
  }
7882
8418
 
7883
- // src/mcp/tool-registry.ts
7884
- import { nanoid as nanoid4 } from "nanoid";
7885
-
7886
8419
  // src/middleware/auth.ts
7887
8420
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
7888
8421
  var identityStorage = new AsyncLocalStorage2();
@@ -7951,19 +8484,19 @@ function getStdioIdentity(stdioIdentityName, registry) {
7951
8484
  }
7952
8485
 
7953
8486
  // src/middleware/confirmation.ts
7954
- import { createHmac, createHash as createHash2 } from "crypto";
7955
- var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
8487
+ import { createHmac as createHmac2, createHash as createHash3 } from "crypto";
8488
+ var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
7956
8489
  var BYPASS_ROLES = ["admin", "superadmin"];
7957
8490
  var pendingConfirmations = /* @__PURE__ */ new Map();
7958
8491
  function computeParamHash(params) {
7959
8492
  const { confirmationToken: _, ...rest } = params;
7960
- return createHash2("sha256").update(JSON.stringify(rest)).digest("hex");
8493
+ return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
7961
8494
  }
7962
8495
  function computeToken(tool, routerId, identityId, paramHash, expiresAt, secret) {
7963
8496
  const payload = JSON.stringify({ tool, routerId, paramHash, identityId, expiresAt });
7964
- return createHmac("sha256", secret).update(payload).digest("hex");
8497
+ return createHmac2("sha256", secret).update(payload).digest("hex");
7965
8498
  }
7966
- function evictExpired() {
8499
+ function evictExpired2() {
7967
8500
  const now = /* @__PURE__ */ new Date();
7968
8501
  for (const [token, entry] of pendingConfirmations) {
7969
8502
  if (entry.expiresAt <= now) pendingConfirmations.delete(token);
@@ -7971,7 +8504,7 @@ function evictExpired() {
7971
8504
  }
7972
8505
  async function checkConfirmation(toolName, routerId, params, identity, secret) {
7973
8506
  if (BYPASS_ROLES.includes(identity.role)) return;
7974
- evictExpired();
8507
+ evictExpired2();
7975
8508
  const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
7976
8509
  if (submittedToken) {
7977
8510
  const entry = pendingConfirmations.get(submittedToken);
@@ -7998,34 +8531,77 @@ async function checkConfirmation(toolName, routerId, params, identity, secret) {
7998
8531
  }
7999
8532
  });
8000
8533
  }
8001
- pendingConfirmations.delete(submittedToken);
8002
- return;
8534
+ pendingConfirmations.delete(submittedToken);
8535
+ return;
8536
+ }
8537
+ const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS2).toISOString();
8538
+ const paramHash = computeParamHash(params);
8539
+ const token = computeToken(toolName, routerId, identity.id, paramHash, expiresAt, secret);
8540
+ pendingConfirmations.set(token, {
8541
+ expiresAt: new Date(expiresAt),
8542
+ tool: toolName,
8543
+ routerId,
8544
+ identityId: identity.id,
8545
+ paramHash
8546
+ });
8547
+ throw new MikroMCPError({
8548
+ category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
8549
+ code: "CONFIRMATION_REQUIRED",
8550
+ message: "This action is destructive. Re-submit with confirmationToken to proceed.",
8551
+ details: {
8552
+ confirmationToken: token,
8553
+ expiresAt,
8554
+ tool: toolName,
8555
+ routerId
8556
+ },
8557
+ recoverability: {
8558
+ retryable: true,
8559
+ suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
8560
+ }
8561
+ });
8562
+ }
8563
+
8564
+ // src/adapter/retry-engine.ts
8565
+ var DEFAULTS = {
8566
+ maxRetries: 3,
8567
+ baseDelayMs: 200,
8568
+ maxDelayMs: 5e3
8569
+ };
8570
+ var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
8571
+ function isRetryable(error) {
8572
+ if (error instanceof MikroMCPError) {
8573
+ return error.recoverability.retryable;
8574
+ }
8575
+ if (error instanceof Error) {
8576
+ const code = error.code;
8577
+ if (code && RETRYABLE_CODES.has(code)) {
8578
+ return true;
8579
+ }
8580
+ const statusCode = error.statusCode;
8581
+ if (typeof statusCode === "number") {
8582
+ return statusCode >= 500;
8583
+ }
8584
+ }
8585
+ return false;
8586
+ }
8587
+ async function withRetry(fn, options) {
8588
+ const opts = { ...DEFAULTS, ...options };
8589
+ let lastError;
8590
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
8591
+ try {
8592
+ return await fn();
8593
+ } catch (error) {
8594
+ lastError = error;
8595
+ if (attempt >= opts.maxRetries || !isRetryable(error)) {
8596
+ throw error;
8597
+ }
8598
+ const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
8599
+ const jitter = Math.random() * opts.baseDelayMs;
8600
+ const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
8601
+ await new Promise((resolve) => setTimeout(resolve, delay));
8602
+ }
8003
8603
  }
8004
- const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS).toISOString();
8005
- const paramHash = computeParamHash(params);
8006
- const token = computeToken(toolName, routerId, identity.id, paramHash, expiresAt, secret);
8007
- pendingConfirmations.set(token, {
8008
- expiresAt: new Date(expiresAt),
8009
- tool: toolName,
8010
- routerId,
8011
- identityId: identity.id,
8012
- paramHash
8013
- });
8014
- throw new MikroMCPError({
8015
- category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
8016
- code: "CONFIRMATION_REQUIRED",
8017
- message: "This action is destructive. Re-submit with confirmationToken to proceed.",
8018
- details: {
8019
- confirmationToken: token,
8020
- expiresAt,
8021
- tool: toolName,
8022
- routerId
8023
- },
8024
- recoverability: {
8025
- retryable: true,
8026
- suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
8027
- }
8028
- });
8604
+ throw lastError;
8029
8605
  }
8030
8606
 
8031
8607
  // src/config/maintenance-window.ts
@@ -8058,12 +8634,355 @@ function isWithinMaintenanceWindow(windows, now) {
8058
8634
  });
8059
8635
  }
8060
8636
 
8637
+ // src/mcp/response-formatter.ts
8638
+ function formatToolResult(result) {
8639
+ return {
8640
+ content: [{ type: "text", text: result.content }],
8641
+ structuredContent: result.structuredContent,
8642
+ ...result.isError ? { isError: true } : {}
8643
+ };
8644
+ }
8645
+ function formatError(error) {
8646
+ const hint = error.recoverability.suggestedAction;
8647
+ const text = `Error [${error.category}]: ${error.message}${hint ? `
8648
+ Suggested action: ${hint}` : ""}`;
8649
+ return {
8650
+ content: [{ type: "text", text }],
8651
+ structuredContent: error.toJSON(),
8652
+ isError: true
8653
+ };
8654
+ }
8655
+
8656
+ // src/observability/metrics.ts
8657
+ var toolCalls = /* @__PURE__ */ new Map();
8658
+ function key(tool, status) {
8659
+ return `${tool}\0${status}`;
8660
+ }
8661
+ function recordToolCall(tool, status) {
8662
+ const k = key(tool, status);
8663
+ toolCalls.set(k, (toolCalls.get(k) ?? 0) + 1);
8664
+ }
8665
+ function renderPrometheus() {
8666
+ const lines = [
8667
+ "# HELP mikromcp_tool_calls_total Total MCP tool calls by tool and status.",
8668
+ "# TYPE mikromcp_tool_calls_total counter"
8669
+ ];
8670
+ for (const [k, count] of toolCalls) {
8671
+ const [tool, status] = k.split("\0");
8672
+ lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
8673
+ }
8674
+ return lines.join("\n") + "\n";
8675
+ }
8676
+
8677
+ // src/adapter/circuit-breaker.ts
8678
+ var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
8679
+ "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
8680
+ "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
8681
+ "ROUTER_ERROR" /* ROUTER_ERROR */,
8682
+ "ROUTER_BUSY" /* ROUTER_BUSY */
8683
+ ]);
8684
+ function isTransientFailure(error) {
8685
+ if (!(error instanceof MikroMCPError)) {
8686
+ return false;
8687
+ }
8688
+ return TRANSIENT_CATEGORIES.has(error.category);
8689
+ }
8690
+ var DEFAULTS2 = {
8691
+ failureThreshold: 5,
8692
+ cooldownMs: 3e4
8693
+ };
8694
+ var CircuitBreaker = class {
8695
+ constructor(routerId, options) {
8696
+ this.routerId = routerId;
8697
+ this.options = { ...DEFAULTS2, ...options };
8698
+ }
8699
+ routerId;
8700
+ options;
8701
+ currentState = "closed";
8702
+ consecutiveFailures = 0;
8703
+ lastFailureTime = 0;
8704
+ probeInFlight = false;
8705
+ get state() {
8706
+ if (this.currentState === "open") {
8707
+ const elapsed = Date.now() - this.lastFailureTime;
8708
+ if (elapsed >= this.options.cooldownMs) {
8709
+ this.currentState = "half-open";
8710
+ }
8711
+ }
8712
+ return this.currentState;
8713
+ }
8714
+ /**
8715
+ * Execute `fn` through the circuit breaker.
8716
+ *
8717
+ * - **closed**: execute normally; track consecutive failures.
8718
+ * - **open**: reject immediately with `ROUTER_BUSY`.
8719
+ * - **half-open**: execute as a probe; success closes, failure reopens.
8720
+ */
8721
+ async execute(fn) {
8722
+ const currentState = this.state;
8723
+ if (currentState === "open") {
8724
+ throw new MikroMCPError({
8725
+ category: "ROUTER_BUSY" /* ROUTER_BUSY */,
8726
+ code: "CIRCUIT_OPEN",
8727
+ message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
8728
+ details: {
8729
+ routerId: this.routerId,
8730
+ consecutiveFailures: this.consecutiveFailures,
8731
+ cooldownMs: this.options.cooldownMs
8732
+ },
8733
+ recoverability: {
8734
+ retryable: true,
8735
+ retryAfterMs: this.options.cooldownMs,
8736
+ suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
8737
+ }
8738
+ });
8739
+ }
8740
+ if (currentState === "half-open") {
8741
+ if (this.probeInFlight) {
8742
+ throw new MikroMCPError({
8743
+ category: "ROUTER_BUSY" /* ROUTER_BUSY */,
8744
+ code: "CIRCUIT_PROBE_IN_FLIGHT",
8745
+ message: `Circuit breaker for router "${this.routerId}" is half-open and a recovery probe is already in flight.`,
8746
+ details: { routerId: this.routerId },
8747
+ recoverability: {
8748
+ retryable: true,
8749
+ retryAfterMs: 1e3,
8750
+ suggestedAction: "Wait for the in-flight probe to complete, then retry."
8751
+ }
8752
+ });
8753
+ }
8754
+ this.probeInFlight = true;
8755
+ }
8756
+ try {
8757
+ const result = await fn();
8758
+ this.onSuccess();
8759
+ return result;
8760
+ } catch (error) {
8761
+ if (isTransientFailure(error)) {
8762
+ this.onFailure();
8763
+ }
8764
+ throw error;
8765
+ } finally {
8766
+ this.probeInFlight = false;
8767
+ }
8768
+ }
8769
+ /** Force the circuit back to the closed state. */
8770
+ reset() {
8771
+ this.currentState = "closed";
8772
+ this.consecutiveFailures = 0;
8773
+ this.lastFailureTime = 0;
8774
+ this.probeInFlight = false;
8775
+ }
8776
+ // ---------- internal helpers ----------
8777
+ onSuccess() {
8778
+ this.consecutiveFailures = 0;
8779
+ this.currentState = "closed";
8780
+ }
8781
+ onFailure() {
8782
+ this.consecutiveFailures++;
8783
+ this.lastFailureTime = Date.now();
8784
+ if (this.currentState === "half-open") {
8785
+ this.currentState = "open";
8786
+ } else if (this.consecutiveFailures >= this.options.failureThreshold) {
8787
+ this.currentState = "open";
8788
+ }
8789
+ }
8790
+ };
8791
+
8792
+ // src/mcp/tool-executor.ts
8793
+ var log38 = createLogger("tool-executor");
8794
+ async function executeToolCall(tool, args, deps) {
8795
+ const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
8796
+ const correlationId = nanoid4();
8797
+ return withContext({ correlationId, tool: tool.name }, async () => {
8798
+ const startMs = Date.now();
8799
+ let journalId;
8800
+ try {
8801
+ const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
8802
+ if (tool.skipRouterContext) {
8803
+ const fleetContext = {
8804
+ routerClient: null,
8805
+ routerId: "",
8806
+ correlationId,
8807
+ routerConfig: null,
8808
+ sshClient: null,
8809
+ ftpClient: null,
8810
+ identity,
8811
+ routerRegistry: registry,
8812
+ connectionPool: pool,
8813
+ appConfig: config
8814
+ };
8815
+ const fleetResult = await tool.handler(args, fleetContext);
8816
+ recordToolCall(tool.name, "success");
8817
+ return formatToolResult(fleetResult);
8818
+ }
8819
+ const routerId = args.routerId;
8820
+ if (!routerId) {
8821
+ throw new MikroMCPError({
8822
+ category: "VALIDATION" /* VALIDATION */,
8823
+ code: "MISSING_ROUTER_ID",
8824
+ message: "routerId is required",
8825
+ recoverability: {
8826
+ retryable: false,
8827
+ suggestedAction: "Provide a valid routerId parameter."
8828
+ }
8829
+ });
8830
+ }
8831
+ checkAuthz(identity, tool.name, routerId);
8832
+ const routerConfig = registry.getRouter(routerId);
8833
+ if (tool.annotations.destructiveHint) {
8834
+ const windows = routerConfig.maintenanceWindows;
8835
+ if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
8836
+ throw new MikroMCPError({
8837
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
8838
+ code: "OUTSIDE_MAINTENANCE_WINDOW",
8839
+ message: `Router "${routerId}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
8840
+ details: { maintenanceWindows: windows },
8841
+ recoverability: {
8842
+ retryable: true,
8843
+ suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
8844
+ }
8845
+ });
8846
+ }
8847
+ }
8848
+ if (tool.annotations.destructiveHint && config.confirmationSecret) {
8849
+ await checkConfirmation(tool.name, routerId, args, identity, config.confirmationSecret);
8850
+ }
8851
+ const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
8852
+ if (shouldAudit) {
8853
+ auditLog({
8854
+ type: "audit",
8855
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8856
+ correlationId,
8857
+ identityId: identity.id,
8858
+ role: identity.role,
8859
+ tool: tool.name,
8860
+ routerId,
8861
+ phase: "attempt",
8862
+ params: args
8863
+ }, config.auditLogPath);
8864
+ }
8865
+ const toolContext = buildRouterToolContext({
8866
+ routerConfig,
8867
+ correlationId,
8868
+ identity,
8869
+ pool,
8870
+ config,
8871
+ registry
8872
+ });
8873
+ let cb = circuitBreakers.get(routerId);
8874
+ if (!cb) {
8875
+ cb = new CircuitBreaker(routerId, {
8876
+ failureThreshold: config.circuitBreaker.failureThreshold,
8877
+ cooldownMs: config.circuitBreaker.cooldownMs
8878
+ });
8879
+ circuitBreakers.set(routerId, cb);
8880
+ }
8881
+ toolContext.circuitBreaker = cb;
8882
+ const { confirmationToken: _ct, ...handlerArgs } = args;
8883
+ const snapshotIds = [];
8884
+ if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
8885
+ for (const path of tool.snapshotPaths) {
8886
+ try {
8887
+ const meta = await takeSnapshot(toolContext.routerClient, routerId, path, config.snapshotDir);
8888
+ snapshotIds.push(meta.id);
8889
+ log38.debug({ snapshotId: meta.id, path }, "Snapshot taken");
8890
+ } catch (err) {
8891
+ log38.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
8892
+ }
8893
+ }
8894
+ }
8895
+ if (!tool.annotations.readOnlyHint && config.journalPath) {
8896
+ journalId = recordAttempt({
8897
+ journalPath: config.journalPath,
8898
+ identityId: identity.id,
8899
+ role: identity.role,
8900
+ tool: tool.name,
8901
+ routerId,
8902
+ params: handlerArgs,
8903
+ snapshotIds
8904
+ });
8905
+ }
8906
+ const runHandler = () => tool.handler(handlerArgs, toolContext);
8907
+ const executeHandler = () => cb.execute(
8908
+ tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
8909
+ );
8910
+ const result = await executeHandler();
8911
+ if (journalId) {
8912
+ recordOutcome({
8913
+ journalPath: config.journalPath,
8914
+ journalId,
8915
+ phase: "success",
8916
+ durationMs: Date.now() - startMs
8917
+ });
8918
+ }
8919
+ if (shouldAudit) {
8920
+ auditLog({
8921
+ type: "audit",
8922
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8923
+ correlationId,
8924
+ identityId: identity.id,
8925
+ role: identity.role,
8926
+ tool: tool.name,
8927
+ routerId,
8928
+ phase: "success",
8929
+ params: args,
8930
+ durationMs: Date.now() - startMs
8931
+ }, config.auditLogPath);
8932
+ }
8933
+ log38.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
8934
+ recordToolCall(tool.name, "success");
8935
+ return formatToolResult(result);
8936
+ } catch (err) {
8937
+ const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
8938
+ if (error.category === "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */) {
8939
+ const failedRouterId = args.routerId;
8940
+ if (failedRouterId) {
8941
+ pool.removeClient(failedRouterId);
8942
+ log38.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
8943
+ }
8944
+ }
8945
+ if (journalId) {
8946
+ recordOutcome({
8947
+ journalPath: config.journalPath,
8948
+ journalId,
8949
+ phase: "failure",
8950
+ outcome: error.code,
8951
+ durationMs: Date.now() - startMs
8952
+ });
8953
+ }
8954
+ const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
8955
+ if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
8956
+ const routerId = args.routerId ?? "unknown";
8957
+ const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
8958
+ auditLog({
8959
+ type: "audit",
8960
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8961
+ correlationId,
8962
+ identityId: identity.id,
8963
+ role: identity.role,
8964
+ tool: tool.name,
8965
+ routerId,
8966
+ phase: "failure",
8967
+ params: args,
8968
+ outcome: error.code,
8969
+ durationMs: Date.now() - startMs
8970
+ }, config.auditLogPath);
8971
+ }
8972
+ log38.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
8973
+ recordToolCall(tool.name, "error");
8974
+ return formatError(error);
8975
+ }
8976
+ });
8977
+ }
8978
+
8061
8979
  // src/mcp/tool-registry.ts
8062
- var log34 = createLogger("tool-registry");
8980
+ var log39 = createLogger("tool-registry");
8063
8981
  function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
8982
+ const deps = { registry, pool, circuitBreakers, config, identityRegistry };
8064
8983
  for (const tool of allTools) {
8065
8984
  const registrationSchema = tool.annotations.destructiveHint ? tool.inputSchema.extend({
8066
- confirmationToken: z32.string().optional().describe(
8985
+ confirmationToken: z36.string().optional().describe(
8067
8986
  "Token from a prior APPROVAL_REQUIRED response. Re-submit the identical call with this token to confirm the destructive action."
8068
8987
  )
8069
8988
  }) : tool.inputSchema;
@@ -8075,236 +8994,61 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
8075
8994
  inputSchema: registrationSchema,
8076
8995
  annotations: tool.annotations
8077
8996
  },
8078
- async (args) => {
8079
- const correlationId = nanoid4();
8080
- return withContext({ correlationId, tool: tool.name }, async () => {
8081
- const startMs = Date.now();
8082
- let journalId;
8083
- try {
8084
- const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
8085
- if (tool.skipRouterContext) {
8086
- const fleetContext = {
8087
- routerClient: null,
8088
- routerId: "",
8089
- correlationId,
8090
- routerConfig: null,
8091
- sshClient: null,
8092
- ftpClient: null,
8093
- identity,
8094
- routerRegistry: registry,
8095
- connectionPool: pool
8096
- };
8097
- return formatToolResult(await tool.handler(args, fleetContext));
8098
- }
8099
- const routerId = args.routerId;
8100
- if (!routerId) {
8101
- throw new MikroMCPError({
8102
- category: "VALIDATION" /* VALIDATION */,
8103
- code: "MISSING_ROUTER_ID",
8104
- message: "routerId is required",
8105
- recoverability: {
8106
- retryable: false,
8107
- suggestedAction: "Provide a valid routerId parameter."
8108
- }
8109
- });
8110
- }
8111
- checkAuthz(identity, tool.name, routerId);
8112
- const routerConfig = registry.getRouter(routerId);
8113
- if (tool.annotations.destructiveHint) {
8114
- const windows = routerConfig.maintenanceWindows;
8115
- if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
8116
- throw new MikroMCPError({
8117
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
8118
- code: "OUTSIDE_MAINTENANCE_WINDOW",
8119
- message: `Router "${routerId}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
8120
- details: { maintenanceWindows: windows },
8121
- recoverability: {
8122
- retryable: true,
8123
- suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
8124
- }
8125
- });
8126
- }
8127
- }
8128
- if (tool.annotations.destructiveHint && config.confirmationSecret) {
8129
- await checkConfirmation(tool.name, routerId, args, identity, config.confirmationSecret);
8130
- }
8131
- const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
8132
- if (shouldAudit) {
8133
- auditLog({
8134
- type: "audit",
8135
- ts: (/* @__PURE__ */ new Date()).toISOString(),
8136
- correlationId,
8137
- identityId: identity.id,
8138
- role: identity.role,
8139
- tool: tool.name,
8140
- routerId,
8141
- phase: "attempt",
8142
- params: args
8143
- });
8144
- }
8145
- const credentials = getCredentials(routerConfig);
8146
- const client = pool.getClient(routerConfig, credentials);
8147
- const sshClient = createSshClient(routerConfig, config.ssh);
8148
- const ftpClient = createFtpClient(routerConfig);
8149
- let cb = circuitBreakers.get(routerId);
8150
- if (!cb) {
8151
- cb = new CircuitBreaker(routerId, {
8152
- failureThreshold: config.circuitBreaker.failureThreshold,
8153
- cooldownMs: config.circuitBreaker.cooldownMs
8154
- });
8155
- circuitBreakers.set(routerId, cb);
8156
- }
8157
- const { confirmationToken: _ct, ...handlerArgs } = args;
8158
- const snapshotIds = [];
8159
- if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
8160
- for (const path of tool.snapshotPaths) {
8161
- try {
8162
- const meta = await takeSnapshot(client, routerId, path, config.snapshotDir);
8163
- snapshotIds.push(meta.id);
8164
- log34.debug({ snapshotId: meta.id, path }, "Snapshot taken");
8165
- } catch (err) {
8166
- log34.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
8167
- }
8168
- }
8169
- }
8170
- if (!tool.annotations.readOnlyHint && config.journalPath) {
8171
- journalId = recordAttempt({
8172
- journalPath: config.journalPath,
8173
- identityId: identity.id,
8174
- role: identity.role,
8175
- tool: tool.name,
8176
- routerId,
8177
- params: handlerArgs,
8178
- snapshotIds
8179
- });
8180
- }
8181
- const runHandler = () => tool.handler(handlerArgs, {
8182
- routerClient: client,
8183
- routerId,
8184
- correlationId,
8185
- routerConfig,
8186
- sshClient,
8187
- ftpClient,
8188
- identity,
8189
- routerRegistry: registry,
8190
- connectionPool: pool
8191
- });
8192
- const executeHandler = () => cb.execute(
8193
- tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
8194
- );
8195
- const result = await executeHandler();
8196
- if (journalId) {
8197
- recordOutcome({
8198
- journalPath: config.journalPath,
8199
- journalId,
8200
- phase: "success",
8201
- durationMs: Date.now() - startMs
8202
- });
8203
- }
8204
- if (shouldAudit) {
8205
- auditLog({
8206
- type: "audit",
8207
- ts: (/* @__PURE__ */ new Date()).toISOString(),
8208
- correlationId,
8209
- identityId: identity.id,
8210
- role: identity.role,
8211
- tool: tool.name,
8212
- routerId,
8213
- phase: "success",
8214
- params: args,
8215
- durationMs: Date.now() - startMs
8216
- });
8217
- }
8218
- log34.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
8219
- return formatToolResult(result);
8220
- } catch (err) {
8221
- const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
8222
- if (journalId) {
8223
- recordOutcome({
8224
- journalPath: config.journalPath,
8225
- journalId,
8226
- phase: "failure",
8227
- outcome: error.code,
8228
- durationMs: Date.now() - startMs
8229
- });
8230
- }
8231
- const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
8232
- if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
8233
- const routerId = args.routerId ?? "unknown";
8234
- const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
8235
- auditLog({
8236
- type: "audit",
8237
- ts: (/* @__PURE__ */ new Date()).toISOString(),
8238
- correlationId,
8239
- identityId: identity.id,
8240
- role: identity.role,
8241
- tool: tool.name,
8242
- routerId,
8243
- phase: "failure",
8244
- params: args,
8245
- outcome: error.code,
8246
- durationMs: Date.now() - startMs
8247
- });
8248
- }
8249
- log34.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
8250
- return formatError(error);
8251
- }
8252
- });
8253
- }
8997
+ (args) => executeToolCall(tool, args, deps)
8254
8998
  );
8255
- log34.debug({ tool: tool.name }, "Registered tool");
8999
+ log39.debug({ tool: tool.name }, "Registered tool");
8256
9000
  }
8257
- log34.info({ count: allTools.length }, "All tools registered");
9001
+ log39.info({ count: allTools.length }, "All tools registered");
8258
9002
  }
8259
9003
 
8260
9004
  // src/config/router-registry.ts
8261
- import { readFileSync as readFileSync3, existsSync } from "fs";
9005
+ import { readFileSync as readFileSync2, existsSync } from "fs";
8262
9006
  import { parse } from "yaml";
8263
- import { z as z33 } from "zod";
8264
- var log35 = createLogger("router-registry");
8265
- var RouterConfigSchema = z33.object({
8266
- host: z33.string().min(1, "host is required"),
8267
- port: z33.number().int().min(1).max(65535),
8268
- tls: z33.object({
8269
- enabled: z33.boolean(),
8270
- rejectUnauthorized: z33.boolean(),
8271
- ca: z33.string().optional(),
8272
- fingerprint: z33.string().optional()
9007
+ import { z as z37 } from "zod";
9008
+ var log40 = createLogger("router-registry");
9009
+ var RouterConfigSchema = z37.object({
9010
+ host: z37.string().min(1, "host is required"),
9011
+ port: z37.number().int().min(1).max(65535),
9012
+ tls: z37.object({
9013
+ enabled: z37.boolean(),
9014
+ rejectUnauthorized: z37.boolean(),
9015
+ ca: z37.string().optional(),
9016
+ fingerprint: z37.string().optional()
8273
9017
  }).strict(),
8274
- credentials: z33.object({
8275
- source: z33.enum(["env", "vault"]),
8276
- envPrefix: z33.string().optional(),
8277
- vaultPath: z33.string().optional()
9018
+ credentials: z37.object({
9019
+ source: z37.enum(["env", "vault"]),
9020
+ envPrefix: z37.string().optional(),
9021
+ vaultPath: z37.string().optional()
8278
9022
  }).strict(),
8279
- tags: z33.array(z33.string()).default([]),
8280
- rosVersion: z33.string().min(1),
8281
- sshPort: z33.number().int().min(1).max(65535).optional(),
8282
- sshFingerprint: z33.string().optional(),
8283
- cmdAllow: z33.array(z33.string()).optional(),
8284
- cmdDeny: z33.array(z33.string()).optional(),
8285
- maintenanceWindows: z33.array(
8286
- z33.object({
8287
- days: z33.array(
8288
- z33.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
9023
+ tags: z37.array(z37.string()).default([]),
9024
+ rosVersion: z37.string().min(1),
9025
+ sshPort: z37.number().int().min(1).max(65535).optional(),
9026
+ sshFingerprint: z37.string().optional(),
9027
+ cmdAllow: z37.array(z37.string()).optional(),
9028
+ cmdDeny: z37.array(z37.string()).optional(),
9029
+ maintenanceWindows: z37.array(
9030
+ z37.object({
9031
+ days: z37.array(
9032
+ z37.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
8289
9033
  ),
8290
- startTime: z33.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
8291
- endTime: z33.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
8292
- timezone: z33.string().min(1)
9034
+ startTime: z37.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
9035
+ endTime: z37.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
9036
+ timezone: z37.string().min(1)
8293
9037
  }).strict()
8294
9038
  ).optional()
8295
9039
  }).strict();
8296
- var ConfigFileSchema = z33.object({
8297
- routers: z33.record(z33.string(), RouterConfigSchema)
9040
+ var ConfigFileSchema = z37.object({
9041
+ routers: z37.record(z37.string(), RouterConfigSchema)
8298
9042
  }).strict();
8299
9043
  var RouterRegistry = class {
8300
9044
  routers;
8301
9045
  constructor(configPath) {
8302
9046
  this.routers = /* @__PURE__ */ new Map();
8303
9047
  if (!existsSync(configPath)) {
8304
- log35.warn({ configPath }, "Router config file not found; starting with empty registry");
9048
+ log40.warn({ configPath }, "Router config file not found; starting with empty registry");
8305
9049
  return;
8306
9050
  }
8307
- const raw = readFileSync3(configPath, "utf-8");
9051
+ const raw = readFileSync2(configPath, "utf-8");
8308
9052
  const parsed = parse(raw);
8309
9053
  const result = ConfigFileSchema.safeParse(parsed);
8310
9054
  if (!result.success) {
@@ -8315,14 +9059,14 @@ ${issues}`);
8315
9059
  for (const [id, config] of Object.entries(result.data.routers)) {
8316
9060
  const routerConfig = { ...config, id };
8317
9061
  if (routerConfig.tls.enabled && !routerConfig.tls.rejectUnauthorized) {
8318
- log35.warn(
9062
+ log40.warn(
8319
9063
  { routerId: id },
8320
9064
  "TLS certificate validation is DISABLED (rejectUnauthorized=false). Set tls.fingerprint to pin the server certificate instead."
8321
9065
  );
8322
9066
  }
8323
9067
  this.routers.set(id, routerConfig);
8324
9068
  }
8325
- log35.info({ count: this.routers.size }, "Loaded routers from config");
9069
+ log40.info({ count: this.routers.size }, "Loaded routers from config");
8326
9070
  }
8327
9071
  getRouter(id) {
8328
9072
  const router = this.routers.get(id);
@@ -8345,7 +9089,7 @@ ${issues}`);
8345
9089
  import { request, Agent } from "undici";
8346
9090
 
8347
9091
  // src/adapter/tls-manager.ts
8348
- import { readFileSync as readFileSync4 } from "fs";
9092
+ import { readFileSync as readFileSync3 } from "fs";
8349
9093
  function buildAgentOptions(tls) {
8350
9094
  if (!tls.enabled) {
8351
9095
  return {};
@@ -8354,7 +9098,7 @@ function buildAgentOptions(tls) {
8354
9098
  rejectUnauthorized: tls.rejectUnauthorized
8355
9099
  };
8356
9100
  if (tls.ca) {
8357
- connectOptions.ca = readFileSync4(tls.ca, "utf-8");
9101
+ connectOptions.ca = readFileSync3(tls.ca, "utf-8");
8358
9102
  }
8359
9103
  if (tls.fingerprint) {
8360
9104
  const expected = tls.fingerprint.replace(/:/g, "").toLowerCase();
@@ -8376,8 +9120,8 @@ function buildAgentOptions(tls) {
8376
9120
  // src/adapter/response-parser.ts
8377
9121
  var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
8378
9122
  var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
8379
- function parseRouterOSValue(key, value) {
8380
- if (key === ".id") {
9123
+ function parseRouterOSValue(key2, value) {
9124
+ if (key2 === ".id") {
8381
9125
  return value;
8382
9126
  }
8383
9127
  if (value === "true") return true;
@@ -8395,8 +9139,8 @@ function parseRouterOSValue(key, value) {
8395
9139
  }
8396
9140
  function parseRecord(raw) {
8397
9141
  const parsed = {};
8398
- for (const [key, value] of Object.entries(raw)) {
8399
- parsed[key] = parseRouterOSValue(key, value);
9142
+ for (const [key2, value] of Object.entries(raw)) {
9143
+ parsed[key2] = parseRouterOSValue(key2, value);
8400
9144
  }
8401
9145
  return parsed;
8402
9146
  }
@@ -8418,8 +9162,8 @@ function buildListQuery(options) {
8418
9162
  }
8419
9163
  if (hasFilter) {
8420
9164
  const queryParts = [];
8421
- for (const [key, value] of Object.entries(options.filter)) {
8422
- queryParts.push(`${key}=${value}`);
9165
+ for (const [key2, value] of Object.entries(options.filter)) {
9166
+ queryParts.push(`${key2}=${value}`);
8423
9167
  }
8424
9168
  body[".query"] = queryParts;
8425
9169
  }
@@ -8427,8 +9171,8 @@ function buildListQuery(options) {
8427
9171
  }
8428
9172
  if (hasFilter) {
8429
9173
  const queryParams = {};
8430
- for (const [key, value] of Object.entries(options.filter)) {
8431
- queryParams[key] = value;
9174
+ for (const [key2, value] of Object.entries(options.filter)) {
9175
+ queryParams[key2] = value;
8432
9176
  }
8433
9177
  return { method: "GET", queryParams };
8434
9178
  }
@@ -8606,6 +9350,9 @@ var ConnectionPool = class {
8606
9350
  }
8607
9351
  };
8608
9352
 
9353
+ // src/version.ts
9354
+ var VERSION = "1.2.0";
9355
+
8609
9356
  // src/mcp/server.ts
8610
9357
  function createServerFactory(config, identityRegistry) {
8611
9358
  const registry = new RouterRegistry(config.configPath);
@@ -8614,7 +9361,7 @@ function createServerFactory(config, identityRegistry) {
8614
9361
  const makeServer = () => {
8615
9362
  const server = new McpServer({
8616
9363
  name: "mikromcp",
8617
- version: "1.0.10"
9364
+ version: VERSION
8618
9365
  });
8619
9366
  registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry);
8620
9367
  return server;
@@ -8634,17 +9381,17 @@ import { randomUUID } from "crypto";
8634
9381
  import { createServer } from "http";
8635
9382
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8636
9383
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
8637
- var log36 = createLogger("transport-http");
9384
+ var log41 = createLogger("transport-http");
8638
9385
  var BodyTooLargeError = class extends Error {
8639
9386
  constructor() {
8640
9387
  super("Request body exceeds maximum allowed size");
8641
9388
  }
8642
9389
  };
8643
9390
  function createRateLimiter(rpm) {
8644
- if (rpm === 0) return () => true;
8645
9391
  const WINDOW_MS = 6e4;
8646
9392
  const windows = /* @__PURE__ */ new Map();
8647
- return (ip) => {
9393
+ const check = (ip) => {
9394
+ if (rpm === 0) return true;
8648
9395
  const now = Date.now();
8649
9396
  const entry = windows.get(ip);
8650
9397
  if (!entry || now - entry.windowStart >= WINDOW_MS) {
@@ -8657,6 +9404,17 @@ function createRateLimiter(rpm) {
8657
9404
  entry.count++;
8658
9405
  return true;
8659
9406
  };
9407
+ const limiter = check;
9408
+ limiter.sweep = () => {
9409
+ const now = Date.now();
9410
+ for (const [ip, entry] of windows) {
9411
+ if (now - entry.windowStart >= WINDOW_MS) {
9412
+ windows.delete(ip);
9413
+ }
9414
+ }
9415
+ };
9416
+ limiter.size = () => windows.size;
9417
+ return limiter;
8660
9418
  }
8661
9419
  async function readBody(req, maxBytes) {
8662
9420
  return new Promise((resolve, reject) => {
@@ -8696,18 +9454,30 @@ async function readBody(req, maxBytes) {
8696
9454
  async function connectHttp(makeServer, config, identityRegistry) {
8697
9455
  const { port, bindHost, maxBodyBytes, rateLimitRpm } = config;
8698
9456
  const checkRateLimit = createRateLimiter(rateLimitRpm);
9457
+ const sweepTimer = setInterval(() => checkRateLimit.sweep(), 6e4);
9458
+ sweepTimer.unref();
8699
9459
  const streamableSessions = /* @__PURE__ */ new Map();
8700
9460
  const sseTransports = /* @__PURE__ */ new Map();
8701
9461
  const httpServer = createServer(async (req, res) => {
8702
9462
  const ip = req.socket.remoteAddress ?? "unknown";
9463
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
9464
+ const { pathname } = url;
9465
+ if (pathname === "/healthz") {
9466
+ res.writeHead(200, { "Content-Type": "application/json" });
9467
+ res.end(JSON.stringify({ status: "ok" }));
9468
+ return;
9469
+ }
9470
+ if (pathname === "/metrics") {
9471
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
9472
+ res.end(renderPrometheus());
9473
+ return;
9474
+ }
8703
9475
  if (!checkRateLimit(ip)) {
8704
- log36.warn({ ip }, "Rate limit exceeded");
9476
+ log41.warn({ ip }, "Rate limit exceeded");
8705
9477
  res.writeHead(429, { "Content-Type": "application/json" });
8706
9478
  res.end(JSON.stringify({ error: "Too many requests. Retry after 60 seconds." }));
8707
9479
  return;
8708
9480
  }
8709
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
8710
- const { pathname } = url;
8711
9481
  try {
8712
9482
  if (pathname === "/mcp" || pathname === "/sse" || pathname === "/messages") {
8713
9483
  let identity;
@@ -8744,16 +9514,17 @@ async function connectHttp(makeServer, config, identityRegistry) {
8744
9514
  }
8745
9515
  return;
8746
9516
  }
8747
- log36.error({ err }, "HTTP transport error");
9517
+ log41.error({ err }, "HTTP transport error");
8748
9518
  if (!res.headersSent) {
8749
9519
  res.writeHead(500, { "Content-Type": "application/json" });
8750
9520
  res.end(JSON.stringify({ error: "Internal server error" }));
8751
9521
  }
8752
9522
  }
8753
9523
  });
9524
+ httpServer.on("close", () => clearInterval(sweepTimer));
8754
9525
  await new Promise((resolve, reject) => {
8755
9526
  httpServer.listen(port, bindHost, () => {
8756
- log36.info(
9527
+ log41.info(
8757
9528
  { port, bindHost },
8758
9529
  "HTTP transport listening (POST /mcp for Streamable HTTP, GET /sse for legacy SSE)"
8759
9530
  );
@@ -8761,6 +9532,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
8761
9532
  });
8762
9533
  httpServer.on("error", reject);
8763
9534
  });
9535
+ return httpServer;
8764
9536
  }
8765
9537
  async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports) {
8766
9538
  if (pathname === "/mcp") {
@@ -8776,13 +9548,13 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
8776
9548
  sessionIdGenerator: () => randomUUID(),
8777
9549
  onsessioninitialized: (id) => {
8778
9550
  streamableSessions.set(id, transport2);
8779
- log36.info({ sessionId: id }, "Streamable HTTP session created");
9551
+ log41.info({ sessionId: id }, "Streamable HTTP session created");
8780
9552
  }
8781
9553
  });
8782
9554
  transport2.onclose = () => {
8783
9555
  if (transport2.sessionId) {
8784
9556
  streamableSessions.delete(transport2.sessionId);
8785
- log36.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
9557
+ log41.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
8786
9558
  }
8787
9559
  };
8788
9560
  await makeServer().connect(transport2);
@@ -8799,7 +9571,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
8799
9571
  sseTransport.onclose = () => {
8800
9572
  sseTransports.delete(sseTransport.sessionId);
8801
9573
  };
8802
- log36.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
9574
+ log41.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
8803
9575
  await sseTransport.start();
8804
9576
  return;
8805
9577
  }
@@ -8818,31 +9590,31 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
8818
9590
  }
8819
9591
 
8820
9592
  // src/config/identity-registry.ts
8821
- import { readFileSync as readFileSync5, existsSync as existsSync2 } from "fs";
9593
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
8822
9594
  import { parse as parse2 } from "yaml";
8823
- import { z as z34 } from "zod";
9595
+ import { z as z38 } from "zod";
8824
9596
  import bcrypt from "bcryptjs";
8825
- var log37 = createLogger("identity-registry");
8826
- var IdentityConfigSchema = z34.object({
8827
- token: z34.string().min(1, "token hash is required"),
8828
- role: z34.enum(["readonly", "operator", "admin", "superadmin"]),
8829
- allowedRouters: z34.array(z34.string()).default([]),
8830
- allowedToolPatterns: z34.array(z34.string()).default([])
9597
+ var log42 = createLogger("identity-registry");
9598
+ var IdentityConfigSchema = z38.object({
9599
+ token: z38.string().min(1, "token hash is required"),
9600
+ role: z38.enum(["readonly", "operator", "admin", "superadmin"]),
9601
+ allowedRouters: z38.array(z38.string()).default([]),
9602
+ allowedToolPatterns: z38.array(z38.string()).default([])
8831
9603
  }).strict();
8832
- var ConfigFileSchema2 = z34.object({
8833
- identities: z34.record(z34.string(), IdentityConfigSchema)
9604
+ var ConfigFileSchema2 = z38.object({
9605
+ identities: z38.record(z38.string(), IdentityConfigSchema)
8834
9606
  }).strict();
8835
9607
  var IdentityRegistry = class {
8836
9608
  identities = [];
8837
9609
  constructor(configPath) {
8838
9610
  if (!existsSync2(configPath)) {
8839
- log37.warn(
9611
+ log42.warn(
8840
9612
  { configPath },
8841
9613
  "Identity config file not found; starting with empty registry (stdio callers do not require it)"
8842
9614
  );
8843
9615
  return;
8844
9616
  }
8845
- const raw = readFileSync5(configPath, "utf-8");
9617
+ const raw = readFileSync4(configPath, "utf-8");
8846
9618
  const parsed = parse2(raw);
8847
9619
  const result = ConfigFileSchema2.safeParse(parsed);
8848
9620
  if (!result.success) {
@@ -8859,7 +9631,7 @@ ${issues}`);
8859
9631
  allowedToolPatterns: config.allowedToolPatterns
8860
9632
  });
8861
9633
  }
8862
- log37.info({ count: this.identities.length }, "Loaded identities from config");
9634
+ log42.info({ count: this.identities.length }, "Loaded identities from config");
8863
9635
  }
8864
9636
  getIdentities() {
8865
9637
  return this.identities.map(({ id, role, allowedRouters, allowedToolPatterns }) => ({
@@ -8885,18 +9657,59 @@ ${issues}`);
8885
9657
  }
8886
9658
  };
8887
9659
 
9660
+ // src/domain/snapshot/retention.ts
9661
+ import { readdir, stat, unlink } from "fs/promises";
9662
+ import { join as join3 } from "path";
9663
+ var log43 = createLogger("snapshot-retention");
9664
+ async function pruneSnapshots(snapshotDir, maxAgeDays) {
9665
+ const cutoffMs = Date.now() - maxAgeDays * 864e5;
9666
+ let removed = 0;
9667
+ let routerDirs;
9668
+ try {
9669
+ routerDirs = await readdir(snapshotDir);
9670
+ } catch {
9671
+ return 0;
9672
+ }
9673
+ for (const routerDir of routerDirs) {
9674
+ const dirPath = join3(snapshotDir, routerDir);
9675
+ let files;
9676
+ try {
9677
+ files = await readdir(dirPath);
9678
+ } catch {
9679
+ continue;
9680
+ }
9681
+ for (const file of files) {
9682
+ const filePath = join3(dirPath, file);
9683
+ try {
9684
+ const info = await stat(filePath);
9685
+ if (info.isFile() && info.mtimeMs < cutoffMs) {
9686
+ await unlink(filePath);
9687
+ removed++;
9688
+ }
9689
+ } catch (err) {
9690
+ log43.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
9691
+ }
9692
+ }
9693
+ }
9694
+ if (removed > 0) {
9695
+ log43.info({ removed, maxAgeDays }, "Pruned expired snapshots");
9696
+ }
9697
+ return removed;
9698
+ }
9699
+
8888
9700
  // src/cli/serve.ts
8889
- var log38 = createLogger("main");
9701
+ var log44 = createLogger("main");
8890
9702
  async function runServe() {
8891
9703
  const config = loadAppConfig();
8892
- log38.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
9704
+ log44.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
9705
+ void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
8893
9706
  const identityRegistry = new IdentityRegistry(config.identitiesPath);
8894
9707
  if (config.transport === "http") {
8895
9708
  const hasLimitedRoles = identityRegistry.getIdentities().some(
8896
9709
  (i) => i.role === "readonly" || i.role === "operator"
8897
9710
  );
8898
9711
  if (hasLimitedRoles && !config.confirmationSecret) {
8899
- log38.error(
9712
+ log44.error(
8900
9713
  "MIKROMCP_CONFIRMATION_SECRET is required in HTTP mode when identities with role readonly or operator are configured"
8901
9714
  );
8902
9715
  process.exit(1);
@@ -8906,7 +9719,7 @@ async function runServe() {
8906
9719
  if (config.transport === "stdio") {
8907
9720
  const stdioIdentity = getStdioIdentity(config.stdioIdentity, identityRegistry);
8908
9721
  await withIdentity(stdioIdentity, () => connectStdio(makeServer()));
8909
- log38.info("MikroMCP server running via stdio");
9722
+ log44.info("MikroMCP server running via stdio");
8910
9723
  } else if (config.transport === "http") {
8911
9724
  await connectHttp(
8912
9725
  makeServer,
@@ -8918,13 +9731,13 @@ async function runServe() {
8918
9731
  },
8919
9732
  identityRegistry
8920
9733
  );
8921
- log38.info(
9734
+ log44.info(
8922
9735
  { port: config.port, bindHost: config.bindHost },
8923
9736
  "MikroMCP server running via HTTP/SSE"
8924
9737
  );
8925
9738
  }
8926
9739
  const shutdown = () => {
8927
- log38.info("Shutting down MikroMCP server");
9740
+ log44.info("Shutting down MikroMCP server");
8928
9741
  pool.closeAll();
8929
9742
  process.exit(0);
8930
9743
  };
@@ -8933,11 +9746,11 @@ async function runServe() {
8933
9746
  }
8934
9747
 
8935
9748
  // src/cli/doctor.ts
8936
- import { existsSync as existsSync3, readFileSync as readFileSync6 } from "fs";
8937
- import { homedir as homedir3 } from "os";
9749
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
9750
+ import { homedir as homedir2 } from "os";
8938
9751
  import * as net from "net";
8939
9752
  import chalk from "chalk";
8940
- var localVersion = "1.0.10";
9753
+ var localVersion = "1.2.0";
8941
9754
  var results = [];
8942
9755
  function ok(msg) {
8943
9756
  console.log(chalk.green("\u2705 ") + msg);
@@ -9117,8 +9930,8 @@ function checkIdentities(identitiesPath, transport2) {
9117
9930
  }
9118
9931
  function checkClaudeDesktop() {
9119
9932
  const candidatePaths = [
9120
- `${homedir3()}/Library/Application Support/Claude/claude_desktop_config.json`,
9121
- `${homedir3()}/.config/Claude/claude_desktop_config.json`
9933
+ `${homedir2()}/Library/Application Support/Claude/claude_desktop_config.json`,
9934
+ `${homedir2()}/.config/Claude/claude_desktop_config.json`
9122
9935
  ];
9123
9936
  const configPath = candidatePaths.find((p) => existsSync3(p));
9124
9937
  if (!configPath) {
@@ -9126,7 +9939,7 @@ function checkClaudeDesktop() {
9126
9939
  return;
9127
9940
  }
9128
9941
  try {
9129
- const raw = readFileSync6(configPath, "utf-8");
9942
+ const raw = readFileSync5(configPath, "utf-8");
9130
9943
  const config = JSON.parse(raw);
9131
9944
  const servers = config.mcpServers ?? {};
9132
9945
  if ("mikromcp" in servers || "mikrotik-mcp-server" in servers) {
@@ -9211,18 +10024,18 @@ import { input, confirm, select } from "@inquirer/prompts";
9211
10024
  import { randomBytes } from "crypto";
9212
10025
  import {
9213
10026
  existsSync as existsSync4,
9214
- readFileSync as readFileSync7,
9215
- writeFileSync as writeFileSync2,
9216
- mkdirSync as mkdirSync3,
10027
+ readFileSync as readFileSync6,
10028
+ writeFileSync,
10029
+ mkdirSync,
9217
10030
  copyFileSync
9218
10031
  } from "fs";
9219
- import { homedir as homedir4 } from "os";
10032
+ import { homedir as homedir3 } from "os";
9220
10033
  import { join as join4 } from "path";
9221
10034
  import { parse as yamlParse, Document, visit } from "yaml";
9222
10035
  import bcrypt2 from "bcryptjs";
9223
10036
  import chalk2 from "chalk";
9224
10037
  function mikromcpDir2() {
9225
- return join4(homedir4(), ".mikromcp");
10038
+ return join4(homedir3(), ".mikromcp");
9226
10039
  }
9227
10040
  function defaultAuditLogPath() {
9228
10041
  return join4(mikromcpDir2(), "audit.ndjson");
@@ -9230,8 +10043,8 @@ function defaultAuditLogPath() {
9230
10043
  function toYaml(obj) {
9231
10044
  const doc = new Document(obj);
9232
10045
  visit(doc, {
9233
- Scalar(key, node) {
9234
- if (key !== "key" && typeof node.value === "string") {
10046
+ Scalar(key2, node) {
10047
+ if (key2 !== "key" && typeof node.value === "string") {
9235
10048
  node.type = "QUOTE_DOUBLE";
9236
10049
  }
9237
10050
  },
@@ -9253,8 +10066,8 @@ function splitTags(val) {
9253
10066
  }
9254
10067
  function findClaudeDesktopConfig() {
9255
10068
  const candidates = [
9256
- join4(homedir4(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
9257
- join4(homedir4(), ".config", "Claude", "claude_desktop_config.json")
10069
+ join4(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
10070
+ join4(homedir3(), ".config", "Claude", "claude_desktop_config.json")
9258
10071
  ];
9259
10072
  return candidates.find((p) => existsSync4(p)) ?? null;
9260
10073
  }
@@ -9419,13 +10232,13 @@ function writeRoutersYaml(data) {
9419
10232
  };
9420
10233
  let parsed = { routers: {} };
9421
10234
  if (existsSync4(filePath)) {
9422
- const raw = readFileSync7(filePath, "utf-8");
10235
+ const raw = readFileSync6(filePath, "utf-8");
9423
10236
  parsed = yamlParse(raw) ?? { routers: {} };
9424
10237
  if (!parsed.routers) parsed.routers = {};
9425
10238
  }
9426
10239
  parsed.routers[data.routerId] = newEntry;
9427
- mkdirSync3(data.configDir, { recursive: true });
9428
- writeFileSync2(filePath, toYaml(parsed));
10240
+ mkdirSync(data.configDir, { recursive: true });
10241
+ writeFileSync(filePath, toYaml(parsed));
9429
10242
  return true;
9430
10243
  }
9431
10244
  function writeIdentitiesYaml(data) {
@@ -9433,18 +10246,18 @@ function writeIdentitiesYaml(data) {
9433
10246
  const newEntry = {
9434
10247
  token: data.tokenHash,
9435
10248
  role: data.role,
9436
- allowedRouters: data.allowedRouters === "*" ? ["*"] : data.allowedRouters.split(",").map((s) => s.trim()),
9437
- allowedToolPatterns: data.allowedToolPatterns === "*" ? ["*"] : data.allowedToolPatterns.split(",").map((s) => s.trim())
10249
+ allowedRouters: data.allowedRouters === "*" ? [] : data.allowedRouters.split(",").map((s) => s.trim()),
10250
+ allowedToolPatterns: data.allowedToolPatterns === "*" ? [] : data.allowedToolPatterns.split(",").map((s) => s.trim())
9438
10251
  };
9439
10252
  let parsed = { identities: {} };
9440
10253
  if (existsSync4(filePath)) {
9441
- const raw = readFileSync7(filePath, "utf-8");
10254
+ const raw = readFileSync6(filePath, "utf-8");
9442
10255
  parsed = yamlParse(raw) ?? { identities: {} };
9443
10256
  if (!parsed.identities) parsed.identities = {};
9444
10257
  }
9445
10258
  parsed.identities[data.identityId] = newEntry;
9446
- mkdirSync3(data.configDir, { recursive: true });
9447
- writeFileSync2(filePath, toYaml(parsed));
10259
+ mkdirSync(data.configDir, { recursive: true });
10260
+ writeFileSync(filePath, toYaml(parsed));
9448
10261
  return true;
9449
10262
  }
9450
10263
  function writeDotEnv(data) {
@@ -9478,8 +10291,8 @@ function writeDotEnv(data) {
9478
10291
  "# \u2500\u2500 RBAC (optional, for stdio with identity enforcement) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
9479
10292
  "# MIKROMCP_STDIO_IDENTITY="
9480
10293
  ];
9481
- mkdirSync3(data.configDir, { recursive: true });
9482
- writeFileSync2(data.envPath, lines.join("\n") + "\n");
10294
+ mkdirSync(data.configDir, { recursive: true });
10295
+ writeFileSync(data.envPath, lines.join("\n") + "\n");
9483
10296
  }
9484
10297
  function registerClaudeDesktop() {
9485
10298
  const configPath = findClaudeDesktopConfig();
@@ -9500,13 +10313,13 @@ function registerClaudeDesktop() {
9500
10313
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9501
10314
  const backupPath = `${configPath}.backup-${timestamp}`;
9502
10315
  copyFileSync(configPath, backupPath);
9503
- const raw = readFileSync7(configPath, "utf-8");
10316
+ const raw = readFileSync6(configPath, "utf-8");
9504
10317
  const config = JSON.parse(raw);
9505
10318
  if (!config.mcpServers || typeof config.mcpServers !== "object") {
9506
10319
  config.mcpServers = {};
9507
10320
  }
9508
10321
  config.mcpServers["mikromcp"] = entry;
9509
- writeFileSync2(configPath, JSON.stringify(config, null, 2));
10322
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
9510
10323
  console.log(chalk2.dim(` Backed up to ${backupPath}`));
9511
10324
  console.log(chalk2.dim(" Restart Claude to apply."));
9512
10325
  return { registered: true };
@@ -9589,7 +10402,7 @@ async function runInit() {
9589
10402
  }
9590
10403
 
9591
10404
  // src/cli/index.ts
9592
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.0.10");
10405
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.2.0");
9593
10406
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
9594
10407
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
9595
10408
  program.command("init").description("Interactive setup wizard").action(runInit);
@@ -9599,6 +10412,6 @@ program.command("update").description("Update mikromcp to the latest version via
9599
10412
  });
9600
10413
 
9601
10414
  // src/main.ts
9602
- loadDotenv({ path: join5(homedir5(), ".mikromcp", ".env"), override: false, quiet: true });
10415
+ loadDotenv({ path: join5(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
9603
10416
  program.parse();
9604
10417
  //# sourceMappingURL=main.js.map