mikromcp 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -126,7 +126,7 @@ function loadAppConfig() {
126
126
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
127
127
 
128
128
  // src/mcp/tool-registry.ts
129
- import { z as z47 } from "zod";
129
+ import { z as z48 } from "zod";
130
130
 
131
131
  // src/domain/tools/system-tools.ts
132
132
  import { z as z2 } from "zod";
@@ -301,9 +301,24 @@ function enrichError(error, context) {
301
301
  }
302
302
 
303
303
  // src/domain/tools/tool-definition.ts
304
+ function snapshotPathsFor(tool, params) {
305
+ return typeof tool.snapshotPaths === "function" ? tool.snapshotPaths(params) : tool.snapshotPaths ?? [];
306
+ }
304
307
  function toolError(err, context, tool) {
305
308
  return enrichError(err, { routerId: context.routerId, tool });
306
309
  }
310
+ function unavailableCapability(code, message, suggestedAction) {
311
+ return new Proxy({}, {
312
+ get() {
313
+ throw new MikroMCPError({
314
+ category: "INTERNAL" /* INTERNAL */,
315
+ code,
316
+ message,
317
+ recoverability: { retryable: false, suggestedAction }
318
+ });
319
+ }
320
+ });
321
+ }
307
322
 
308
323
  // src/observability/logger.ts
309
324
  import pino from "pino";
@@ -4950,11 +4965,11 @@ var listFilesTool = {
4950
4965
  if (parsed.name !== void 0) {
4951
4966
  const needle = parsed.name.toLowerCase();
4952
4967
  files = files.filter(
4953
- (f) => (f.name ?? "").toLowerCase().includes(needle)
4968
+ (f2) => (f2.name ?? "").toLowerCase().includes(needle)
4954
4969
  );
4955
4970
  }
4956
4971
  if (parsed.type !== void 0) {
4957
- files = files.filter((f) => f.type === parsed.type);
4972
+ files = files.filter((f2) => f2.type === parsed.type);
4958
4973
  }
4959
4974
  return {
4960
4975
  content: listContent(
@@ -4963,7 +4978,7 @@ var listFilesTool = {
4963
4978
  files,
4964
4979
  files.length,
4965
4980
  0,
4966
- (f) => compactFields(f, ["name", "type", "size", "creation-time"])
4981
+ (f2) => compactFields(f2, ["name", "type", "size", "creation-time"])
4967
4982
  ),
4968
4983
  structuredContent: { routerId: context.routerId, files, total: files.length }
4969
4984
  };
@@ -4995,7 +5010,7 @@ var getFileContentTool = {
4995
5010
  limit: void 0,
4996
5011
  offset: void 0
4997
5012
  });
4998
- const file = all.find((f) => f.name === parsed.name);
5013
+ const file = all.find((f2) => f2.name === parsed.name);
4999
5014
  if (!file) {
5000
5015
  throw new MikroMCPError({
5001
5016
  category: "NOT_FOUND" /* NOT_FOUND */,
@@ -5127,7 +5142,7 @@ var deleteFileTool = {
5127
5142
  limit: void 0,
5128
5143
  offset: void 0
5129
5144
  });
5130
- const file = all.find((f) => f.name === parsed.name);
5145
+ const file = all.find((f2) => f2.name === parsed.name);
5131
5146
  if (!file) {
5132
5147
  return {
5133
5148
  content: `File "${parsed.name}" not found on ${context.routerId}. Nothing to delete.`,
@@ -5398,221 +5413,1342 @@ var containerTools = [listContainersTool, manageContainerTool];
5398
5413
 
5399
5414
  // src/domain/tools/change-management-tools.ts
5400
5415
  import { z as z24 } from "zod";
5401
- import { readFileSync } from "fs";
5416
+ import { readFileSync as readFileSync2 } from "fs";
5402
5417
 
5403
5418
  // src/domain/snapshot/snapshot-engine.ts
5404
5419
  import { mkdir, writeFile, readFile } from "fs/promises";
5405
5420
  import { join as join2 } from "path";
5406
5421
  import { nanoid } from "nanoid";
5407
5422
 
5408
- // src/domain/snapshot/diff-engine.ts
5409
- var RUNTIME_FIELDS = /* @__PURE__ */ new Set([
5410
- "bytes",
5411
- "packets",
5412
- "dynamic",
5413
- "invalid",
5414
- "dead",
5415
- "expired",
5416
- "active",
5417
- "running",
5418
- "about",
5419
- ".about",
5420
- ".nextid",
5421
- "creation-time",
5422
- "last-logged-in",
5423
- "last-link-up-time",
5424
- "last-link-down-time",
5425
- "link-downs",
5426
- "rx-byte",
5427
- "tx-byte",
5428
- "rx-packet",
5429
- "tx-packet",
5430
- "uptime"
5431
- ]);
5432
- var ORDER_SENSITIVE_PATHS = /* @__PURE__ */ new Set([
5433
- "ip/firewall/filter",
5434
- "ip/firewall/nat",
5435
- "ip/firewall/mangle",
5436
- "routing/rule"
5437
- ]);
5438
- function normalizeForDiff(records) {
5439
- const out = [];
5440
- for (const r of records) {
5441
- if (isTrue(r.dynamic)) continue;
5442
- const clean = {};
5443
- for (const [k, v] of Object.entries(r)) {
5444
- if (!RUNTIME_FIELDS.has(k)) clean[k] = v;
5423
+ // src/adapter/swos-client.ts
5424
+ import { createHash, randomBytes } from "crypto";
5425
+ import { request as httpRequest } from "http";
5426
+
5427
+ // src/adapter/swos-protocol.ts
5428
+ var Kind = {
5429
+ BOOL: "bool",
5430
+ STR: "str",
5431
+ OPTION: "option",
5432
+ MAC: "mac",
5433
+ IP: "ip",
5434
+ INT: "int",
5435
+ CHECKBOX: "checkbox",
5436
+ HEX: "hex"
5437
+ };
5438
+ function f(name, keys, kind, opts = {}) {
5439
+ return { name, keys, kind, ...opts };
5440
+ }
5441
+ var KEY_QUOTE = /([{,])([a-zA-Z][a-zA-Z0-9]*)/g;
5442
+ var HEX_QUOTE = /(0x[0-9a-fA-F]+)/g;
5443
+ var HEX_UNQUOTE = /"0x([0-9a-fA-F]+)"/g;
5444
+ var KEY_UNQUOTE = /"([a-zA-Z][a-zA-Z0-9]*)":/g;
5445
+ var HEX_TOKEN = /^0x[0-9a-fA-F]+$/;
5446
+ function isWireHex(value) {
5447
+ return typeof value === "string" && HEX_TOKEN.test(value);
5448
+ }
5449
+ function quoteBrokenJson(text) {
5450
+ return text.replace(KEY_QUOTE, '$1"$2"').replace(/'/g, '"').replace(HEX_QUOTE, '"$1"');
5451
+ }
5452
+ function normalize(value) {
5453
+ if (typeof value === "string") {
5454
+ if (isWireHex(value)) {
5455
+ const parsed = Number.parseInt(value, 16);
5456
+ return Number.isNaN(parsed) ? value : parsed;
5445
5457
  }
5446
- out.push(clean);
5458
+ return value;
5447
5459
  }
5448
- return out;
5449
- }
5450
- var SEMANTIC_KEYS = {
5451
- "ip/route": ["dst-address", "gateway", "routing-table"],
5452
- "ip/firewall/filter": ["comment"],
5453
- "ip/firewall/nat": ["comment"],
5454
- "ip/firewall/mangle": ["comment"],
5455
- "ip/firewall/address-list": ["list", "address"],
5456
- "ip/address": ["address", "interface"],
5457
- "ip/dns/static": ["name", "type"],
5458
- "routing/rule": ["src-address", "routing-table"],
5459
- "routing/table": ["name"],
5460
- "interface/wireguard/peers": ["public-key"],
5461
- "interface/bridge": ["name"],
5462
- "interface/bridge/port": ["bridge", "interface"],
5463
- "system/scheduler": ["name"],
5464
- "system/script": ["name"],
5465
- "system/package": ["name"],
5466
- "container": ["name"],
5467
- "certificate": ["name"],
5468
- "file": ["name"],
5469
- "interface/vrrp": ["name"],
5470
- "ip/dhcp-server": ["name"],
5471
- "ip/ipsec/peer": ["name"],
5472
- "ip/pool": ["name"],
5473
- "queue/simple": ["name"],
5474
- "tool/netwatch": ["host"],
5475
- "user": ["name"]
5476
- };
5477
- function normalizeValue(v) {
5478
- if (v === true || v === "true" || v === "yes") return "true";
5479
- if (v === false || v === "false" || v === "no") return "false";
5480
- return String(v ?? "");
5481
- }
5482
- function semanticKey(record, keys) {
5483
- return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
5460
+ if (Array.isArray(value)) return value.map(normalize);
5461
+ if (value && typeof value === "object") {
5462
+ return Object.fromEntries(
5463
+ Object.entries(value).map(([k, v]) => [k, normalize(v)])
5464
+ );
5465
+ }
5466
+ return value;
5484
5467
  }
5485
- function recordSignature(record) {
5486
- return Object.entries(record).filter(([k]) => k !== ".id").sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${normalizeValue(v)}`).join(";");
5468
+ function parseWireBlob(text) {
5469
+ return JSON.parse(quoteBrokenJson(text));
5487
5470
  }
5488
- function recordsAreEqual(a, b) {
5489
- const keysA = Object.keys(a).filter((k) => k !== ".id");
5490
- const keysB = Object.keys(b).filter((k) => k !== ".id");
5491
- if (keysA.length !== keysB.length) return false;
5492
- return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
5471
+ function parseBlob(text) {
5472
+ return normalize(parseWireBlob(text));
5493
5473
  }
5494
- function hasDuplicateKeys(records, keys) {
5495
- const seen = /* @__PURE__ */ new Set();
5496
- for (const r of records) {
5497
- const k = semanticKey(r, keys);
5498
- if (seen.has(k)) return true;
5499
- seen.add(k);
5474
+ var MAX_WIRE_VALUE = 4294967295;
5475
+ function toWireHex(value) {
5476
+ if (!Number.isInteger(value) || value < 0 || value > MAX_WIRE_VALUE) {
5477
+ throw new MikroMCPError({
5478
+ category: "VALIDATION" /* VALIDATION */,
5479
+ code: "SWOS_VALUE_OUT_OF_RANGE",
5480
+ message: `Cannot encode ${value} as a SwOS wire value.`,
5481
+ details: { value, min: 0, max: MAX_WIRE_VALUE },
5482
+ recoverability: {
5483
+ retryable: false,
5484
+ suggestedAction: `Pass a whole number between 0 and ${MAX_WIRE_VALUE}.`
5485
+ }
5486
+ });
5500
5487
  }
5501
- return false;
5502
- }
5503
- function applyUserRestriction(path, plan) {
5504
- if (path === "user" && plan.toCreate.length > 0) {
5505
- plan.warnings.push(
5506
- `Refusing to recreate ${plan.toCreate.length} deleted user(s): passwords are not stored in snapshots and recreation would produce password-less logins. Recreate manually with manage_user.`
5488
+ const s = value.toString(16);
5489
+ return `0x${s.length % 2 ? `0${s}` : s}`;
5490
+ }
5491
+ function encodeLike(value, existing) {
5492
+ const token = toWireHex(value);
5493
+ if (!isWireHex(existing)) return token;
5494
+ const width = existing.length - 2;
5495
+ const digits = token.slice(2);
5496
+ return digits.length >= width ? token : `0x${digits.padStart(width, "0")}`;
5497
+ }
5498
+ function toHex(value) {
5499
+ if (typeof value === "number") return toWireHex(value);
5500
+ if (Array.isArray(value)) return value.map(toHex);
5501
+ if (value && typeof value === "object") {
5502
+ return Object.fromEntries(
5503
+ Object.entries(value).map(([k, v]) => [k, toHex(v)])
5507
5504
  );
5508
- plan.toCreate = [];
5509
5505
  }
5510
- return plan;
5506
+ return value;
5511
5507
  }
5512
- function computeRestorePlan(path, beforeRaw, currentRaw) {
5513
- const before = normalizeForDiff(beforeRaw);
5514
- const current = normalizeForDiff(currentRaw);
5515
- const warnings = [];
5516
- if (ORDER_SENSITIVE_PATHS.has(path)) {
5517
- warnings.push(
5518
- `Restored entries for ${path} are re-created at the end of the list \u2014 rule ORDER is not restored. Review ordering manually after rollback.`
5519
- );
5520
- }
5521
- const keys = SEMANTIC_KEYS[path];
5522
- const canUseKeys = keys !== void 0 && !hasDuplicateKeys(before, keys) && !hasDuplicateKeys(current, keys);
5523
- if (!canUseKeys) {
5524
- const beforeSigs = new Set(before.map(recordSignature));
5525
- const currentSigs = new Set(current.map(recordSignature));
5526
- return applyUserRestriction(path, {
5527
- path,
5528
- toCreate: before.filter((r) => !currentSigs.has(recordSignature(r))),
5529
- toRemove: current.filter((r) => !beforeSigs.has(recordSignature(r))).map((r) => r[".id"]),
5530
- toUpdate: [],
5531
- warnings
5532
- });
5508
+ function dumpBlob(data) {
5509
+ let s = JSON.stringify(toHex(data));
5510
+ s = s.replace(HEX_UNQUOTE, "0x$1");
5511
+ s = s.replace(KEY_UNQUOTE, "$1:");
5512
+ return s.replace(/"/g, "'");
5513
+ }
5514
+ var SPEED = ["10M", "100M", "1G", "10G", "200M", "2.5G", "5G", "down"];
5515
+ var POE_OUT = ["off", "on", "auto"];
5516
+ var POE_VOLTAGE = ["auto", "low", "high"];
5517
+ var VLAN_MODE = ["disabled", "optional", "strict"];
5518
+ var VLAN_RECV = ["any", "only_tagged", "only_untagged"];
5519
+ var POE_STATE = [
5520
+ "none",
5521
+ "disabled",
5522
+ "waiting_for_load",
5523
+ "powered_on",
5524
+ "overload",
5525
+ "short_circuit",
5526
+ "voltage_too_low",
5527
+ "current_too_low",
5528
+ "power_cycle",
5529
+ "voltage_too_high",
5530
+ "controller_error"
5531
+ ];
5532
+ var RSTP_ROLE = ["disabled", "alternate", "root", "designated", "backup"];
5533
+ var FLOW_CONTROL = ["off", "tx_only", "rx_only", "on"];
5534
+ var ACL_VLAN_PRESENT = ["any", "present", "not present"];
5535
+ var ACL_ACCOUNT_AS = ["none", "#1", "#2", "#3", "#4"];
5536
+ var ADDR_ACQ = ["DHCP with fallback", "static", "DHCP only"];
5537
+ var LINK = {
5538
+ path: "link.b",
5539
+ fields: [
5540
+ f("enabled", { swos: "en", swos_lite: "i01" }, Kind.BOOL),
5541
+ f("auto_neg", { swos: "an", swos_lite: "i02" }, Kind.BOOL),
5542
+ f("manual_duplex", { swos: "dpxc", swos_lite: "i03" }, Kind.BOOL),
5543
+ f("manual_speed", { swos: "spd", swos_lite: "i05" }, Kind.OPTION, {
5544
+ options: SPEED,
5545
+ perPort: true
5546
+ }),
5547
+ f("link", { swos: "lnk", swos_lite: "i06" }, Kind.BOOL),
5548
+ f("duplex", { swos: "dpx", swos_lite: "i07" }, Kind.BOOL),
5549
+ f("speed", { swos: "spdc", swos_lite: "i08" }, Kind.OPTION, { options: SPEED, perPort: true }),
5550
+ f("name", { swos: "nm", swos_lite: "i0a" }, Kind.STR, { perPort: true }),
5551
+ f("flow_rx", { swos: "fctr", swos_lite: "i12" }, Kind.BOOL),
5552
+ f("flow_tx", { swos: "fctc", swos_lite: "i16" }, Kind.BOOL),
5553
+ f("pause", { swos: "paus", swos_lite: "i15" }, Kind.BOOL),
5554
+ f("cable_pairs", { swos_lite: "i11" }, Kind.INT, {
5555
+ perPort: true,
5556
+ note: "per-port cable-pair status; 0xffff = all pairs OK"
5557
+ }),
5558
+ f("flow_control", { swos_lite: "i13" }, Kind.OPTION, {
5559
+ options: FLOW_CONTROL,
5560
+ note: "negotiated flow-control mode"
5561
+ }),
5562
+ f("flow_control_mask", { swos_lite: "i14" }, Kind.BOOL, {
5563
+ note: "per-port mask paired with flow_control (i13)"
5564
+ }),
5565
+ f("cable_length", { swos_lite: "i09" }, Kind.INT, {
5566
+ perPort: true,
5567
+ unknown: true,
5568
+ note: "0 when link down; tracks cable run length -> likely length, scale ~1e-3 m (heuristic)"
5569
+ }),
5570
+ f("unknown_i0b", { swos_lite: "i0b" }, Kind.HEX, { unknown: true }),
5571
+ f("unknown_i0c", { swos_lite: "i0c" }, Kind.HEX, { unknown: true }),
5572
+ f("diag_hops", { swos_lite: "i0d" }, Kind.HEX, { perPort: true, unknown: true }),
5573
+ f("diag_last_hop", { swos_lite: "i0e" }, Kind.HEX, { perPort: true, unknown: true }),
5574
+ f("diag_length", { swos_lite: "i0f" }, Kind.HEX, { perPort: true, unknown: true }),
5575
+ f("diag_fault_at", { swos_lite: "i10" }, Kind.HEX, { perPort: true, unknown: true })
5576
+ ]
5577
+ };
5578
+ var SYS = {
5579
+ path: "sys.b",
5580
+ fields: [
5581
+ f("uptime", { swos: "upt", swos_lite: "i01" }, Kind.INT, { note: "seconds" }),
5582
+ f("ip", { swos: "cip", swos_lite: "i02" }, Kind.IP),
5583
+ f("mac", { swos: "mac", swos_lite: "i03" }, Kind.MAC),
5584
+ f("serial", { swos: "sid", swos_lite: "i04" }, Kind.STR),
5585
+ f("identity", { swos: "id", swos_lite: "i05" }, Kind.STR),
5586
+ f("version", { swos: "ver", swos_lite: "i06" }, Kind.STR),
5587
+ f("model", { swos: "brd", swos_lite: "i07" }, Kind.STR),
5588
+ f("discovery_protocol", { swos: "dsc", swos_lite: "i08" }, Kind.BOOL, {
5589
+ note: "per-port bitmask of MikroTik discovery enabled"
5590
+ }),
5591
+ f("static_ip", { swos: "sip", swos_lite: "i09" }, Kind.IP),
5592
+ f("addr_acq", { swos: "iptp", swos_lite: "i0a" }, Kind.OPTION, { options: ADDR_ACQ }),
5593
+ f("build_number", { swos_lite: "i0b" }, Kind.INT, { unknown: true }),
5594
+ f("allow_from_ports", { swos: "allp", swos_lite: "i12" }, Kind.BOOL),
5595
+ f("dhcp_trusted_ports", { swos: "dtrp", swos_lite: "i13" }, Kind.BOOL),
5596
+ f("dhcp_info_option", { swos: "ainf", swos_lite: "i14" }, Kind.CHECKBOX),
5597
+ f("psu1_voltage", { swos: "p1v", swos_lite: "i15" }, Kind.INT, { scale: 100, note: "volts" }),
5598
+ f("psu1_current", { swos: "p1c", swos_lite: "i16" }, Kind.INT, { note: "mA" }),
5599
+ f("igmp_snooping", { swos: "igmp", swos_lite: "i17" }, Kind.CHECKBOX),
5600
+ f("allow_from_ip", { swos: "alla", swos_lite: "i19" }, Kind.IP),
5601
+ f("allow_from_mask", { swos: "allm", swos_lite: "i1a" }, Kind.INT),
5602
+ f("allow_from_vlan", { swos: "avln", swos_lite: "i1b" }, Kind.INT),
5603
+ f("psu2_voltage", { swos: "p2v", swos_lite: "i1e" }, Kind.INT, { scale: 100 }),
5604
+ f("psu2_current", { swos: "p2c", swos_lite: "i1f" }, Kind.INT),
5605
+ f("bridge_priority", { swos: "prio", swos_lite: "i0e" }, Kind.INT),
5606
+ f("port_cost_mode", { swos: "cost", swos_lite: "i0f" }, Kind.OPTION, {
5607
+ options: ["short", "long"]
5608
+ }),
5609
+ f("root_bridge_priority", { swos_lite: "i10" }, Kind.INT),
5610
+ f("root_bridge_mac", { swos_lite: "i11" }, Kind.MAC),
5611
+ f("igmp_fast_leave", { swos: "igfl", swos_lite: "i27" }, Kind.BOOL),
5612
+ f("igmp_version", { swos: "igve", swos_lite: "i28" }, Kind.OPTION, { options: ["v2", "v3"] }),
5613
+ f("igmp_querier", { swos: "igmq", swos_lite: "i29" }, Kind.CHECKBOX),
5614
+ f("fwd_reserved_mc", { swos: "frmc", swos_lite: "i2a" }, Kind.CHECKBOX),
5615
+ f("cpu_temp", { swos_lite: "i22" }, Kind.INT, { signed: true, bits: 16, note: "deg C" }),
5616
+ f("power_consumption", { swos_lite: "i26" }, Kind.INT, {
5617
+ scale: 10,
5618
+ note: "watts (switch itself, excl. PoE)"
5619
+ })
5620
+ ]
5621
+ };
5622
+ var POE = {
5623
+ path: "poe.b",
5624
+ fields: [
5625
+ f("out", { swos_lite: "i01" }, Kind.OPTION, { options: POE_OUT, perPort: true }),
5626
+ f("priority", { swos_lite: "i02" }, Kind.INT, {
5627
+ perPort: true,
5628
+ note: "wire = priority-1 (0..7 -> 1..8)"
5629
+ }),
5630
+ f("voltage_level", { swos_lite: "i03" }, Kind.OPTION, {
5631
+ options: POE_VOLTAGE,
5632
+ perPort: true
5633
+ }),
5634
+ f("state", { swos_lite: "i04" }, Kind.OPTION, { options: POE_STATE, perPort: true }),
5635
+ f("current", { swos_lite: "i05" }, Kind.INT, { perPort: true, note: "mA" }),
5636
+ f("voltage", { swos_lite: "i06" }, Kind.INT, { perPort: true, scale: 10, note: "volts" }),
5637
+ f("power", { swos_lite: "i07" }, Kind.INT, { perPort: true, scale: 10, note: "watts" }),
5638
+ f("total_raw", { swos_lite: "i08" }, Kind.INT, {
5639
+ unknown: true,
5640
+ note: "global PoE total (raw); semantics TBD"
5641
+ }),
5642
+ f("controller", { swos_lite: "i09" }, Kind.STR, {
5643
+ unknown: true,
5644
+ note: "PoE controller serial/fw string"
5645
+ }),
5646
+ f("lldp_enabled", { swos_lite: "i0a" }, Kind.CHECKBOX, {
5647
+ unknown: true,
5648
+ note: "global LLDP toggle (Lite); per-port in some firmwares"
5649
+ }),
5650
+ f("lldp_power", { swos_lite: "i0b" }, Kind.INT, {
5651
+ perPort: true,
5652
+ scale: 10,
5653
+ unknown: true,
5654
+ note: "per-port LLDP negotiated power"
5655
+ })
5656
+ ]
5657
+ };
5658
+ var LACP = {
5659
+ path: "lacp.b",
5660
+ fields: [
5661
+ f("active", { swos_lite: "i01" }, Kind.INT, {
5662
+ perPort: true,
5663
+ note: "per-port LACP active/member flag (0/1)"
5664
+ }),
5665
+ f("trunk_id", { swos_lite: "i02" }, Kind.INT, {
5666
+ perPort: true,
5667
+ note: "static LAG / trunk id (observed 1, 2 and 3)"
5668
+ }),
5669
+ f("group", { swos_lite: "i03" }, Kind.INT, { perPort: true, note: "LAG group, engine max 15" }),
5670
+ f("partner_mac", { swos_lite: "i04" }, Kind.MAC, {
5671
+ perPort: true,
5672
+ note: "LACP partner/peer MAC (same value for both members of a LAG)"
5673
+ })
5674
+ ]
5675
+ };
5676
+ var RSTP = {
5677
+ path: "rstp.b",
5678
+ fields: [
5679
+ f("enabled", { swos: "ena", swos_lite: "i01" }, Kind.BOOL, {
5680
+ note: "per-port RSTP-enabled mask (0x3ff = all)"
5681
+ }),
5682
+ f("role", { swos_lite: "i02" }, Kind.OPTION, {
5683
+ options: RSTP_ROLE,
5684
+ perPort: true,
5685
+ note: "per-port RSTP role"
5686
+ }),
5687
+ f("root_path_cost", { swos_lite: "i03" }, Kind.INT, {
5688
+ perPort: true,
5689
+ note: "per-port root path cost (0 = auto)"
5690
+ }),
5691
+ f("unknown_i04", { swos_lite: "i04" }, Kind.INT, {
5692
+ perPort: true,
5693
+ unknown: true,
5694
+ note: "observed 0/2/4/0x13; candidate = port priority / port number"
5695
+ }),
5696
+ f("mode_mask", { swos_lite: "i05" }, Kind.BOOL, {
5697
+ unknown: true,
5698
+ note: "0x3ff on this device; candidate = STP/RSTP mode mask"
5699
+ }),
5700
+ f("type_mask", { swos_lite: "i06" }, Kind.BOOL, {
5701
+ unknown: true,
5702
+ note: "0x3ff here; candidate = port type mask (paired with i07)"
5703
+ }),
5704
+ f("unknown_i07", { swos_lite: "i07" }, Kind.INT, {
5705
+ unknown: true,
5706
+ note: "0x324 here; paired mask for i06"
5707
+ }),
5708
+ f("forwarding", { swos_lite: "i08" }, Kind.BOOL, {
5709
+ note: "forwarding-state mask (port4 down -> bit clear)"
5710
+ }),
5711
+ f("learning", { swos_lite: "i09" }, Kind.BOOL, {
5712
+ note: "learning-state mask (port4 down -> bit clear)"
5713
+ })
5714
+ ]
5715
+ };
5716
+ var SNMP = {
5717
+ path: "snmp.b",
5718
+ fields: [
5719
+ f("enabled", { swos: "en", swos_lite: "i01" }, Kind.CHECKBOX),
5720
+ f("community", { swos: "com", swos_lite: "i02" }, Kind.STR),
5721
+ f("contact", { swos: "ci", swos_lite: "i03" }, Kind.STR),
5722
+ f("location", { swos: "loc", swos_lite: "i04" }, Kind.STR)
5723
+ ]
5724
+ };
5725
+ var FWD = {
5726
+ path: "fwd.b",
5727
+ fields: [
5728
+ ...Array.from(
5729
+ { length: 10 },
5730
+ (_, i) => f(
5731
+ `isolation_p${i + 1}`,
5732
+ { swos_lite: `i${(i + 1).toString(16).padStart(2, "0")}` },
5733
+ Kind.BOOL,
5734
+ {
5735
+ note: "forwarding/isolation mask; bit n cleared"
5736
+ }
5737
+ )
5738
+ ),
5739
+ f("port_lock", { swos_lite: "i10" }, Kind.BOOL, {
5740
+ perPort: true,
5741
+ note: "port lock (disable learning)"
5742
+ }),
5743
+ f("lock_on_first", { swos_lite: "i11" }, Kind.BOOL, {
5744
+ perPort: true,
5745
+ note: "lock on first learned MAC"
5746
+ }),
5747
+ f("mirror_ingress", { swos_lite: "i12" }, Kind.BOOL, { perPort: true }),
5748
+ f("mirror_egress", { swos_lite: "i13" }, Kind.BOOL, { perPort: true }),
5749
+ f("mirror_to", { swos_lite: "i14" }, Kind.BOOL, {
5750
+ perPort: true,
5751
+ note: "egress mirror target port (scalar mask on this device)"
5752
+ }),
5753
+ f("vlan_mode", { swos_lite: "i15" }, Kind.OPTION, { options: VLAN_MODE, perPort: true }),
5754
+ f("vlan_recv_mode", { swos_lite: "i17" }, Kind.OPTION, { options: VLAN_RECV, perPort: true }),
5755
+ f("default_vid", { swos_lite: "i18" }, Kind.INT, {
5756
+ perPort: true,
5757
+ note: "default VLAN id per port"
5758
+ }),
5759
+ f("force_vid", { swos_lite: "i19" }, Kind.BOOL, { perPort: true, note: "force VLAN id" }),
5760
+ f("storm_rate", { swos_lite: "i1a" }, Kind.INT, {
5761
+ perPort: true,
5762
+ scale: 1e5,
5763
+ note: "storm limit (fraction of link)"
5764
+ }),
5765
+ f("limit_unknown_ucast", { swos_lite: "i1b" }, Kind.BOOL, {
5766
+ perPort: true,
5767
+ note: "limit unknown unicast"
5768
+ }),
5769
+ f("flood_unknown_mcast", { swos_lite: "i1c" }, Kind.BOOL, {
5770
+ perPort: true,
5771
+ note: "flood unknown multicast"
5772
+ }),
5773
+ f("ingress_rate", { swos_lite: "i1d" }, Kind.INT, {
5774
+ perPort: true,
5775
+ scale: 1e5,
5776
+ note: "ingress rate limit (fraction of link)"
5777
+ }),
5778
+ f("egress_rate", { swos_lite: "i1e" }, Kind.INT, {
5779
+ perPort: true,
5780
+ scale: 1e5,
5781
+ note: "egress rate limit (fraction of link)"
5782
+ })
5783
+ ]
5784
+ };
5785
+ var VLAN = {
5786
+ path: "vlan.b",
5787
+ isArray: true,
5788
+ note: "SwOS Lite vlan.b keys unverified (device had none)",
5789
+ fields: [
5790
+ f("vid", { swos: "vid" }, Kind.INT),
5791
+ f("name", { swos: "nm" }, Kind.STR),
5792
+ f("port_isolation", { swos: "piso" }, Kind.CHECKBOX),
5793
+ f("learning", { swos: "lrn" }, Kind.CHECKBOX),
5794
+ f("mirror", { swos: "mrr" }, Kind.CHECKBOX),
5795
+ f("igmp", { swos: "igmp" }, Kind.CHECKBOX),
5796
+ f("members", { swos: "mbr" }, Kind.BOOL)
5797
+ ]
5798
+ };
5799
+ var HOST = {
5800
+ path: "host.b",
5801
+ isArray: true,
5802
+ note: "Static host table (device had none)",
5803
+ fields: [
5804
+ f("mac", { swos_lite: "i01" }, Kind.MAC),
5805
+ f("port", { swos_lite: "i02" }, Kind.INT, {
5806
+ note: "port id; 0x80 bit = SFP/combo, low nibble = 1-based port (interpretation unverified)"
5807
+ })
5808
+ ]
5809
+ };
5810
+ var DHOST = {
5811
+ path: "!dhost.b",
5812
+ isArray: true,
5813
+ note: "Dynamically learned MAC table",
5814
+ fields: HOST.fields
5815
+ };
5816
+ var IGMP = {
5817
+ path: "!igmp.b",
5818
+ isArray: true,
5819
+ note: "IGMP snooping groups (device had none)",
5820
+ fields: [
5821
+ f("group_address", { swos_lite: "i01" }, Kind.INT, { note: "multicast group IP (raw int)" }),
5822
+ f("member_ports", { swos_lite: "i02" }, Kind.BOOL, { note: "per-port membership bitmask" }),
5823
+ f("vlan", { swos_lite: "i03" }, Kind.INT, { note: "VLAN id" })
5824
+ ]
5825
+ };
5826
+ var ACL = {
5827
+ path: "acl.b",
5828
+ isArray: true,
5829
+ note: "ACL rules (device had none)",
5830
+ fields: [
5831
+ f("from", { swos_lite: "i01" }, Kind.BOOL, { note: "source port bitmask" }),
5832
+ f("mac_src", { swos_lite: "i02" }, Kind.MAC),
5833
+ f("mac_src_mask", { swos_lite: "i03" }, Kind.MAC),
5834
+ f("mac_dst", { swos_lite: "i04" }, Kind.MAC),
5835
+ f("mac_dst_mask", { swos_lite: "i05" }, Kind.MAC),
5836
+ f("ethertype", { swos_lite: "i06" }, Kind.INT),
5837
+ f("vlan_present", { swos_lite: "i07" }, Kind.OPTION, { options: ACL_VLAN_PRESENT }),
5838
+ f("vlan_id", { swos_lite: "i08" }, Kind.INT),
5839
+ f("priority", { swos_lite: "i09" }, Kind.INT),
5840
+ f("ip_src", { swos_lite: "i0a" }, Kind.INT, { note: "source IP (raw int)" }),
5841
+ f("ip_src_mask_hi", { swos_lite: "i0b" }, Kind.INT),
5842
+ f("ip_src_mask_lo", { swos_lite: "i0c" }, Kind.INT),
5843
+ f("ip_dst", { swos_lite: "i0d" }, Kind.INT, { note: "dest IP (raw int)" }),
5844
+ f("ip_dst_mask_hi", { swos_lite: "i0e" }, Kind.INT),
5845
+ f("ip_dst_mask_lo", { swos_lite: "i0f" }, Kind.INT),
5846
+ f("protocol", { swos_lite: "i10" }, Kind.INT),
5847
+ f("dscp", { swos_lite: "i11" }, Kind.INT),
5848
+ f("drop", { swos_lite: "i12" }, Kind.CHECKBOX, { note: "drop matching packets" }),
5849
+ f("mirror_to", { swos_lite: "i13" }, Kind.OPTION, { note: "egress port / off" }),
5850
+ f("redirect_to", { swos_lite: "i14" }, Kind.OPTION, { note: "egress port / off" }),
5851
+ f("set_vlan_id", { swos_lite: "i15" }, Kind.INT),
5852
+ f("set_priority", { swos_lite: "i16" }, Kind.INT),
5853
+ f("set_dscp", { swos_lite: "i17" }, Kind.INT),
5854
+ f("account_as", { swos_lite: "i18" }, Kind.OPTION, { options: ACL_ACCOUNT_AS })
5855
+ ]
5856
+ };
5857
+ var ACLSTATS = {
5858
+ path: "!aclstats.b",
5859
+ fields: [
5860
+ f("counter_1", { swos_lite: "i01" }, Kind.INT, { perPort: true }),
5861
+ f("counter_2", { swos_lite: "i02" }, Kind.INT, { perPort: true }),
5862
+ f("counter_3", { swos_lite: "i03" }, Kind.INT, { perPort: true }),
5863
+ f("counter_4", { swos_lite: "i04" }, Kind.INT, { perPort: true })
5864
+ ]
5865
+ };
5866
+ var SFP = {
5867
+ path: "sfp.b",
5868
+ note: "per-cage SFP DOM diagnostics (text fields are UTF-8 over hex)",
5869
+ fields: [
5870
+ f("vendor", { swos_lite: "i01" }, Kind.STR, { perPort: true }),
5871
+ f("part_number", { swos_lite: "i02" }, Kind.STR, { perPort: true }),
5872
+ f("revision", { swos_lite: "i03" }, Kind.STR, { perPort: true }),
5873
+ f("serial", { swos_lite: "i04" }, Kind.STR, { perPort: true }),
5874
+ f("date", { swos_lite: "i05" }, Kind.STR, { perPort: true }),
5875
+ f("type", { swos_lite: "i06" }, Kind.STR, { perPort: true, note: "xa:1 = hex-ascii" }),
5876
+ f("diag_extra", { swos_lite: "i07" }, Kind.INT, {
5877
+ perPort: true,
5878
+ unknown: true,
5879
+ note: "firmware-specific raw value (85000 on the populated cage); purpose unconfirmed"
5880
+ }),
5881
+ // Signed like sys.b cpu_temp. A cage with no DOM (passive copper DAC)
5882
+ // reports 0xff80 -> -128, which reads as "no reading" rather than 65408.
5883
+ f("temperature", { swos_lite: "i08" }, Kind.INT, {
5884
+ perPort: true,
5885
+ signed: true,
5886
+ bits: 16,
5887
+ note: "deg C; -128 = no DOM data (e.g. passive DAC)"
5888
+ }),
5889
+ f("voltage", { swos_lite: "i09" }, Kind.INT, { perPort: true, scale: 1e3, note: "volts" }),
5890
+ f("tx_bias", { swos_lite: "i0a" }, Kind.INT, { perPort: true, note: "mA" }),
5891
+ f("tx_power", { swos_lite: "i0b" }, Kind.INT, { perPort: true, scale: 1e4, note: "dBm" }),
5892
+ f("rx_power", { swos_lite: "i0c" }, Kind.INT, { perPort: true, scale: 1e4, note: "dBm" })
5893
+ ]
5894
+ };
5895
+ var STATS = {
5896
+ path: "!stats.b",
5897
+ fields: [],
5898
+ genericPerPort: true,
5899
+ note: "Per-port cumulative counters (rx/tx bytes, packets, errors, drops...). iNN keys preserved as-is."
5900
+ };
5901
+ var STATS_METRICS = {
5902
+ i01: "rx_bytes",
5903
+ i0f: "tx_bytes",
5904
+ i05: "rx_unicasts",
5905
+ i11: "tx_unicasts",
5906
+ i07: "rx_broadcasts",
5907
+ i14: "tx_broadcasts",
5908
+ i08: "rx_multicasts",
5909
+ i13: "tx_multicasts",
5910
+ i23: "rx_total_packets",
5911
+ i24: "tx_total_packets",
5912
+ i21: "rx_rate",
5913
+ i22: "tx_rate",
5914
+ i25: "rx_packet_rate",
5915
+ i26: "tx_packet_rate",
5916
+ i17: "rx_pauses",
5917
+ i1d: "rx_errors",
5918
+ i1e: "rx_fcs_errors",
5919
+ i1c: "rx_jabber",
5920
+ i19: "rx_runt",
5921
+ i1a: "rx_fragments",
5922
+ i1b: "rx_too_long",
5923
+ i16: "tx_pauses",
5924
+ i04: "tx_fcs_errors",
5925
+ i1f: "tx_collisions",
5926
+ i15: "tx_single_collisions",
5927
+ i18: "tx_multiple_collisions",
5928
+ i12: "tx_excessive_collisions",
5929
+ i20: "tx_late_collisions",
5930
+ i06: "tx_deferred",
5931
+ i09: "pkt_64",
5932
+ i0a: "pkt_65_127",
5933
+ i0b: "pkt_128_255",
5934
+ i0c: "pkt_256_511",
5935
+ i0d: "pkt_512_1023",
5936
+ i0e: "pkt_1024_max"
5937
+ };
5938
+ var SWOS_SCHEMA = {
5939
+ "link.b": LINK,
5940
+ "sys.b": SYS,
5941
+ "poe.b": POE,
5942
+ "lacp.b": LACP,
5943
+ "rstp.b": RSTP,
5944
+ "snmp.b": SNMP,
5945
+ "fwd.b": FWD,
5946
+ "vlan.b": VLAN,
5947
+ // Only the "!" form exists on the device; a plain /stats.b 303s to /index.html.
5948
+ "!stats.b": STATS,
5949
+ "sfp.b": SFP,
5950
+ "host.b": HOST,
5951
+ "!dhost.b": DHOST,
5952
+ "!igmp.b": IGMP,
5953
+ "acl.b": ACL,
5954
+ "!aclstats.b": ACLSTATS
5955
+ };
5956
+ var SWOS_ENDPOINTS = Object.keys(SWOS_SCHEMA).sort();
5957
+ var VERIFIED_FIRMWARE = [
5958
+ { model: "CSS610-8P-2S+", version: "2.21" }
5959
+ ];
5960
+ var verifiedList = () => VERIFIED_FIRMWARE.map((v) => `${v.model} ${v.version}`).join(", ");
5961
+ function firmwareSupport(model, version) {
5962
+ const m = typeof model === "string" ? model : void 0;
5963
+ const v = typeof version === "string" ? version : void 0;
5964
+ if (m === void 0 || v === void 0) {
5965
+ return {
5966
+ model: m,
5967
+ version: v,
5968
+ verified: false,
5969
+ note: `Firmware could not be identified; the schema is verified against ${verifiedList()}.`
5970
+ };
5533
5971
  }
5534
- const currentByKey = new Map(current.map((r) => [semanticKey(r, keys), r]));
5535
- const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
5536
- const toCreate = [];
5537
- const toUpdate = [];
5538
- for (const [key2, beforeRecord] of beforeByKey) {
5539
- const currentRecord = currentByKey.get(key2);
5540
- if (!currentRecord) {
5541
- toCreate.push(beforeRecord);
5542
- } else if (!recordsAreEqual(beforeRecord, currentRecord)) {
5543
- const { ".id": _, ...data } = beforeRecord;
5544
- toUpdate.push({ currentId: currentRecord[".id"], data });
5545
- }
5972
+ if (VERIFIED_FIRMWARE.some((entry) => entry.model === m && entry.version === v)) {
5973
+ return {
5974
+ model: m,
5975
+ version: v,
5976
+ verified: true,
5977
+ note: `${m} ${v} \u2014 schema verified on this firmware.`
5978
+ };
5546
5979
  }
5547
- const toRemove = current.filter((r) => !beforeByKey.has(semanticKey(r, keys))).map((r) => r[".id"]);
5548
- return applyUserRestriction(path, { path, toCreate, toRemove, toUpdate, warnings });
5980
+ const sameModel = VERIFIED_FIRMWARE.filter((entry) => entry.model === m).map((e) => e.version);
5981
+ return {
5982
+ model: m,
5983
+ version: v,
5984
+ verified: false,
5985
+ note: sameModel.length > 0 ? `${m} runs ${v}; the schema was verified on ${sameModel.join(", ")}. Field meanings may have changed \u2014 review the diff before applying.` : `${m} ${v} is not in the verified set (${verifiedList()}). Field meanings are inferred \u2014 review the diff before applying.`
5986
+ };
5549
5987
  }
5550
- async function applyRestorePlan(plan, client) {
5551
- for (const id of plan.toRemove) {
5552
- await client.remove(plan.path, id);
5553
- }
5554
- for (const record of plan.toCreate) {
5555
- const { ".id": _, ...data } = record;
5556
- await client.create(plan.path, data);
5557
- }
5558
- for (const { currentId, data } of plan.toUpdate) {
5559
- await client.update(plan.path, currentId, data);
5988
+ function unmappedWireKeys(endpoint, wire) {
5989
+ const ep = SWOS_SCHEMA[endpoint];
5990
+ if (!ep || ep.isArray || ep.genericPerPort) return [];
5991
+ const mapped = new Set(ep.fields.map((field) => selectKey(field, wire)));
5992
+ return Object.keys(wire).filter((key2) => !mapped.has(key2));
5993
+ }
5994
+ function inferPortCount(data) {
5995
+ for (const v of Object.values(data)) {
5996
+ if (Array.isArray(v) && v.length > 0 && !Array.isArray(v[0]) && typeof v[0] !== "object") {
5997
+ return v.length;
5998
+ }
5999
+ }
6000
+ return 0;
6001
+ }
6002
+ function inferPortCountFromMasks(ep, data) {
6003
+ let widest = 0;
6004
+ for (const fieldDef of ep.fields) {
6005
+ if (fieldDef.kind !== Kind.BOOL) continue;
6006
+ const key2 = selectKey(fieldDef, data);
6007
+ const value = key2 === void 0 ? void 0 : data[key2];
6008
+ if (typeof value === "number" && value > 0) {
6009
+ widest = Math.max(widest, value.toString(2).length);
6010
+ }
6011
+ }
6012
+ return widest;
6013
+ }
6014
+ function selectKey(fieldDef, data) {
6015
+ return Object.values(fieldDef.keys).find((key2) => key2 in data);
6016
+ }
6017
+ function hexToBoolList(value, length) {
6018
+ const bits = value.toString(2).padStart(length, "0").split("").reverse().map((b) => b === "1");
6019
+ return bits.slice(0, length);
6020
+ }
6021
+ function decodeScalar(fieldDef, value, portCount) {
6022
+ switch (fieldDef.kind) {
6023
+ case Kind.BOOL:
6024
+ return hexToBoolList(value, portCount);
6025
+ case Kind.STR: {
6026
+ const hex = value;
6027
+ const buf = Buffer.from(hex.length % 2 ? `0${hex}` : hex, "hex");
6028
+ return buf.toString("utf8").replace(/[\0\s]+$/g, "");
6029
+ }
6030
+ case Kind.OPTION: {
6031
+ const idx = value;
6032
+ return fieldDef.options && idx < fieldDef.options.length ? fieldDef.options[idx] : idx;
6033
+ }
6034
+ case Kind.MAC:
6035
+ return value.toUpperCase().match(/../g)?.join(":") ?? "";
6036
+ case Kind.IP:
6037
+ if (!value) return "0.0.0.0";
6038
+ return Buffer.from(value.toString(16).padStart(8, "0"), "hex").reverse().join(".");
6039
+ case Kind.CHECKBOX:
6040
+ return value === 1;
6041
+ case Kind.INT: {
6042
+ let num = value;
6043
+ if (fieldDef.signed && fieldDef.bits) {
6044
+ const half = 1 << fieldDef.bits - 1;
6045
+ if (num >= half) num -= 1 << fieldDef.bits;
6046
+ }
6047
+ return fieldDef.scale ? num / fieldDef.scale : num;
6048
+ }
6049
+ default:
6050
+ return value;
6051
+ }
6052
+ }
6053
+ function decodeObject(ep, data) {
6054
+ const portCount = inferPortCount(data) || inferPortCountFromMasks(ep, data);
6055
+ const out = {};
6056
+ const knownKeys = /* @__PURE__ */ new Set();
6057
+ for (const fieldDef of ep.fields) {
6058
+ const key2 = selectKey(fieldDef, data);
6059
+ if (key2 === void 0) continue;
6060
+ knownKeys.add(key2);
6061
+ const v = data[key2];
6062
+ if (fieldDef.perPort || fieldDef.kind === Kind.BOOL) {
6063
+ out[fieldDef.name] = Array.isArray(v) ? v.map((x) => decodeScalar(fieldDef, x, portCount)) : decodeScalar(fieldDef, v, portCount);
6064
+ } else {
6065
+ out[fieldDef.name] = decodeScalar(fieldDef, v, portCount);
6066
+ }
5560
6067
  }
6068
+ out["_raw"] = Object.fromEntries(Object.entries(data).filter(([k]) => !knownKeys.has(k)));
6069
+ return out;
5561
6070
  }
5562
-
5563
- // src/domain/snapshot/snapshot-engine.ts
5564
- function pathToSlug(rosPath) {
5565
- return rosPath.replace(/\//g, "-");
5566
- }
5567
- function timestampPrefix() {
5568
- return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
6071
+ function decodeRecords(ep, data) {
6072
+ return data.map((item) => {
6073
+ const row = {};
6074
+ const itemObj = item;
6075
+ const knownKeys = /* @__PURE__ */ new Set();
6076
+ for (const fieldDef of ep.fields) {
6077
+ const key2 = selectKey(fieldDef, itemObj);
6078
+ if (key2 === void 0) continue;
6079
+ knownKeys.add(key2);
6080
+ row[fieldDef.name] = decodeScalar(fieldDef, itemObj[key2], 0);
6081
+ }
6082
+ row["_raw"] = Object.fromEntries(Object.entries(itemObj).filter(([k]) => !knownKeys.has(k)));
6083
+ return row;
6084
+ });
5569
6085
  }
5570
- async function takeSnapshot(client, routerId2, path, snapshotDir) {
5571
- const records = normalizeForDiff(await client.get(path, {}));
5572
- const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
5573
- const dir = join2(snapshotDir, routerId2);
5574
- const filePath = join2(dir, `${id}.json`);
5575
- const ts = (/* @__PURE__ */ new Date()).toISOString();
5576
- await mkdir(dir, { recursive: true });
5577
- const stored = { id, routerId: routerId2, path, ts, records };
5578
- await writeFile(filePath, JSON.stringify(stored, null, 2));
5579
- return { id, routerId: routerId2, path, ts, filePath, recordCount: records.length };
6086
+ function decodeGenericPerPort(data) {
6087
+ const portCount = inferPortCount(data);
6088
+ const ports = {};
6089
+ for (let n = 1; n <= portCount; n++) ports[String(n)] = {};
6090
+ const scalars = {};
6091
+ for (const [k, v] of Object.entries(data)) {
6092
+ if (Array.isArray(v) && v.length === portCount && !Array.isArray(v[0]) && typeof v[0] !== "object") {
6093
+ for (let n = 0; n < portCount; n++) {
6094
+ const port = ports[String(n + 1)];
6095
+ port[k] = v[n];
6096
+ if (STATS_METRICS[k]) port[STATS_METRICS[k]] = v[n];
6097
+ }
6098
+ } else {
6099
+ scalars[k] = v;
6100
+ }
6101
+ }
6102
+ ports["_scalars"] = scalars;
6103
+ return ports;
5580
6104
  }
5581
- async function loadSnapshot(filePath) {
5582
- return JSON.parse(await readFile(filePath, "utf-8"));
6105
+ function decodeEndpoint(path, rawText) {
6106
+ const ep = SWOS_SCHEMA[path];
6107
+ if (!ep) throw new Error(`no SwOS schema for ${path}`);
6108
+ const data = parseBlob(rawText);
6109
+ if (ep.isArray) return decodeRecords(ep, data);
6110
+ if (ep.genericPerPort) return decodeGenericPerPort(data);
6111
+ return decodeObject(ep, data);
5583
6112
  }
5584
6113
 
5585
- // src/domain/snapshot/write-journal.ts
5586
- import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
5587
- import { dirname } from "path";
5588
- import { nanoid as nanoid2 } from "nanoid";
6114
+ // src/adapter/rest-client.ts
6115
+ import { request, Agent as Agent2 } from "undici";
5589
6116
 
5590
- // src/observability/audit-log.ts
5591
- import { appendFile } from "fs/promises";
5592
- var log22 = createLogger("audit");
5593
- var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
5594
- "password",
5595
- "pass",
5596
- "token",
5597
- "secret",
5598
- "authorization",
5599
- "credentials",
5600
- "privatekey",
5601
- "presharedkey",
5602
- "psk",
5603
- "passphrase",
5604
- "community",
5605
- "apikey",
5606
- "confirmationsecret"
5607
- ]);
5608
- function redactValue(value) {
5609
- if (Array.isArray(value)) {
5610
- return value.map(redactValue);
5611
- }
5612
- if (value !== null && typeof value === "object") {
5613
- return redactParams(value);
5614
- }
5615
- return value;
6117
+ // src/adapter/tls-manager.ts
6118
+ import { readFileSync } from "fs";
6119
+ import { buildConnector } from "undici";
6120
+ function makePinnedConnector(inner, expectedFingerprint) {
6121
+ const expected = expectedFingerprint.replace(/:/g, "").toLowerCase();
6122
+ return (opts, cb) => {
6123
+ inner(opts, (err, socket) => {
6124
+ if (err || !socket) {
6125
+ cb(err ?? new Error("TLS connection failed"), null);
6126
+ return;
6127
+ }
6128
+ const peer = socket;
6129
+ const cert = typeof peer.getPeerCertificate === "function" ? peer.getPeerCertificate() : void 0;
6130
+ const actual = (cert?.fingerprint256 ?? "").replace(/:/g, "").toLowerCase();
6131
+ if (actual !== expected) {
6132
+ socket.destroy();
6133
+ cb(
6134
+ new Error(
6135
+ `TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual || "none"}`
6136
+ ),
6137
+ null
6138
+ );
6139
+ return;
6140
+ }
6141
+ cb(null, socket);
6142
+ });
6143
+ };
6144
+ }
6145
+ function buildAgentOptions(tls) {
6146
+ if (!tls.enabled) {
6147
+ return {};
6148
+ }
6149
+ const connectOptions = {
6150
+ rejectUnauthorized: tls.rejectUnauthorized
6151
+ };
6152
+ if (tls.ca) {
6153
+ connectOptions.ca = readFileSync(tls.ca, "utf-8");
6154
+ }
6155
+ if (tls.fingerprint) {
6156
+ const inner = buildConnector(connectOptions);
6157
+ return { connect: makePinnedConnector(inner, tls.fingerprint) };
6158
+ }
6159
+ return {
6160
+ connect: connectOptions
6161
+ };
6162
+ }
6163
+
6164
+ // src/adapter/query-builder.ts
6165
+ function buildListQuery(options) {
6166
+ if (!options) {
6167
+ return { method: "GET" };
6168
+ }
6169
+ const hasFilter = options.filter && Object.keys(options.filter).length > 0;
6170
+ const hasProplist = options.proplist && options.proplist.length > 0;
6171
+ if (hasProplist) {
6172
+ const body = {};
6173
+ if (hasProplist) {
6174
+ body[".proplist"] = options.proplist.join(",");
6175
+ }
6176
+ if (hasFilter) {
6177
+ const queryParts = [];
6178
+ for (const [key2, value] of Object.entries(options.filter)) {
6179
+ queryParts.push(`${key2}=${value}`);
6180
+ }
6181
+ body[".query"] = queryParts;
6182
+ }
6183
+ return { method: "POST", body };
6184
+ }
6185
+ if (hasFilter) {
6186
+ const queryParams = {};
6187
+ for (const [key2, value] of Object.entries(options.filter)) {
6188
+ queryParams[key2] = value;
6189
+ }
6190
+ return { method: "GET", queryParams };
6191
+ }
6192
+ return { method: "GET" };
6193
+ }
6194
+ function applyPagination(items, limit2, offset2) {
6195
+ const total = items.length;
6196
+ const start = offset2 ?? 0;
6197
+ const sliced = limit2 !== void 0 ? items.slice(start, start + limit2) : items.slice(start);
6198
+ return {
6199
+ items: sliced,
6200
+ total,
6201
+ hasMore: start + sliced.length < total
6202
+ };
6203
+ }
6204
+
6205
+ // src/adapter/rest-client.ts
6206
+ var HttpError = class extends Error {
6207
+ statusCode;
6208
+ responseBody;
6209
+ constructor(statusCode, responseBody) {
6210
+ super(`HTTP ${statusCode}: ${responseBody}`);
6211
+ this.name = "HttpError";
6212
+ this.statusCode = statusCode;
6213
+ this.responseBody = responseBody;
6214
+ }
6215
+ };
6216
+ var REQUEST_TIMEOUT_MS = 3e4;
6217
+ var RouterOSRestClient = class {
6218
+ baseUrl;
6219
+ authHeader;
6220
+ agent;
6221
+ constructor(config, credentials) {
6222
+ const scheme = config.tls.enabled ? "https" : "http";
6223
+ this.baseUrl = `${scheme}://${config.host}:${config.port}/rest`;
6224
+ this.authHeader = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64");
6225
+ this.agent = new Agent2(buildAgentOptions(config.tls));
6226
+ }
6227
+ // ---------- public CRUD methods ----------
6228
+ /**
6229
+ * List resources at the given path.
6230
+ *
6231
+ * Applies filtering via GET query params or POST body depending on the
6232
+ * query complexity, then runs client-side pagination.
6233
+ */
6234
+ async get(path, options) {
6235
+ const query = buildListQuery(options);
6236
+ let raw;
6237
+ if (query.method === "POST") {
6238
+ raw = await this.doRequest("POST", `${this.baseUrl}/${path}`, query.body);
6239
+ } else {
6240
+ const qs = query.queryParams ? "?" + new URLSearchParams(query.queryParams).toString() : "";
6241
+ raw = await this.doRequest("GET", `${this.baseUrl}/${path}${qs}`);
6242
+ }
6243
+ const rawArray = Array.isArray(raw) ? raw : [raw];
6244
+ const parsed = parseRecords(rawArray);
6245
+ if (options?.limit !== void 0 || options?.offset !== void 0) {
6246
+ const page = applyPagination(parsed, options?.limit, options?.offset);
6247
+ return page.items;
6248
+ }
6249
+ return parsed;
6250
+ }
6251
+ /** Fetch a single resource by its RouterOS `.id`. */
6252
+ async getOne(path, id) {
6253
+ const raw = await this.doRequest("GET", `${this.baseUrl}/${path}/${id}`);
6254
+ return parseRecord(raw);
6255
+ }
6256
+ /** Create a new resource (PUT). Returns the created record including `.id`. */
6257
+ async create(path, data) {
6258
+ const raw = await this.doRequest("PUT", `${this.baseUrl}/${path}`, data);
6259
+ return raw;
6260
+ }
6261
+ /** Update an existing resource by `.id` (PATCH). */
6262
+ async update(path, id, data) {
6263
+ await this.doRequest("PATCH", `${this.baseUrl}/${path}/${id}`, data);
6264
+ }
6265
+ /** Remove a resource by `.id` (DELETE). */
6266
+ async remove(path, id) {
6267
+ await this.doRequest("DELETE", `${this.baseUrl}/${path}/${id}`);
6268
+ }
6269
+ /** Execute a command (POST), e.g. `/ip/firewall/filter/print`. */
6270
+ async execute(path, data) {
6271
+ const result = await this.doRequest("POST", `${this.baseUrl}/${path}`, data);
6272
+ return result;
6273
+ }
6274
+ // ---------- lifecycle ----------
6275
+ /** Close the underlying HTTP agent and free resources. */
6276
+ close() {
6277
+ this.agent.close();
6278
+ }
6279
+ // ---------- internal ----------
6280
+ /**
6281
+ * Core HTTP request method.
6282
+ *
6283
+ * - Sends JSON body when provided.
6284
+ * - Attaches Basic Auth and content-type headers.
6285
+ * - Throws `HttpError` on non-2xx status codes.
6286
+ * - Returns parsed JSON (or `undefined` for empty bodies).
6287
+ */
6288
+ async doRequest(method, url, body) {
6289
+ const headers = {
6290
+ Accept: "application/json",
6291
+ Authorization: this.authHeader
6292
+ };
6293
+ let requestBody;
6294
+ if (body !== void 0) {
6295
+ headers["Content-Type"] = "application/json";
6296
+ requestBody = JSON.stringify(body);
6297
+ }
6298
+ const response = await request(url, {
6299
+ method,
6300
+ headers,
6301
+ body: requestBody,
6302
+ dispatcher: this.agent,
6303
+ bodyTimeout: REQUEST_TIMEOUT_MS,
6304
+ headersTimeout: REQUEST_TIMEOUT_MS
6305
+ });
6306
+ const text = await response.body.text();
6307
+ if (response.statusCode < 200 || response.statusCode >= 300) {
6308
+ throw new HttpError(response.statusCode, text);
6309
+ }
6310
+ if (!text || text.length === 0) {
6311
+ return void 0;
6312
+ }
6313
+ return JSON.parse(text);
6314
+ }
6315
+ };
6316
+
6317
+ // src/adapter/swos-client.ts
6318
+ var REQUEST_TIMEOUT_MS2 = 15e3;
6319
+ function md5(input2) {
6320
+ return createHash("md5").update(input2).digest("hex");
6321
+ }
6322
+ function parseDigestChallenge(header) {
6323
+ if (!header.toLowerCase().startsWith("digest")) return void 0;
6324
+ const fields = {};
6325
+ const quoted = /([a-zA-Z0-9_-]+)\s*=\s*("([^"]*)"|([^\s,]+))/g;
6326
+ let m;
6327
+ while ((m = quoted.exec(header)) !== null) {
6328
+ fields[m[1]] = m[3] ?? m[4];
6329
+ }
6330
+ if (!fields.realm || !fields.nonce) return void 0;
6331
+ return {
6332
+ realm: fields.realm,
6333
+ nonce: fields.nonce,
6334
+ opaque: fields.opaque,
6335
+ qop: fields.qop,
6336
+ algorithm: fields.algorithm
6337
+ };
6338
+ }
6339
+ function buildDigestHeader(method, path, challenge, credentials, nonceCount) {
6340
+ const ha1 = md5(`${credentials.username}:${challenge.realm}:${credentials.password}`);
6341
+ const ha2 = md5(`${method}:${path}`);
6342
+ const nc = nonceCount.toString(16).padStart(8, "0");
6343
+ const cnonce = randomBytes(8).toString("hex");
6344
+ const qop = challenge.qop?.split(",").map((s) => s.trim()).find((s) => s === "auth");
6345
+ const response = qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`);
6346
+ const parts = [
6347
+ `username="${credentials.username}"`,
6348
+ `realm="${challenge.realm}"`,
6349
+ `nonce="${challenge.nonce}"`,
6350
+ `uri="${path}"`,
6351
+ `response="${response}"`
6352
+ ];
6353
+ if (challenge.algorithm) parts.push(`algorithm=${challenge.algorithm}`);
6354
+ if (challenge.opaque) parts.push(`opaque="${challenge.opaque}"`);
6355
+ if (qop) parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
6356
+ return `Digest ${parts.join(", ")}`;
6357
+ }
6358
+ function assertKnownEndpoint(endpoint) {
6359
+ if (endpoint in SWOS_SCHEMA) return;
6360
+ throw new MikroMCPError({
6361
+ category: "VALIDATION" /* VALIDATION */,
6362
+ code: "SWOS_UNKNOWN_ENDPOINT",
6363
+ message: `No SwOS schema for "${endpoint}".`,
6364
+ details: { endpoint, known: Object.keys(SWOS_SCHEMA) },
6365
+ recoverability: {
6366
+ retryable: false,
6367
+ suggestedAction: "Call list_swos_endpoints to see the supported endpoints."
6368
+ }
6369
+ });
6370
+ }
6371
+ var SwosClient = class {
6372
+ host;
6373
+ port;
6374
+ credentials;
6375
+ requestTimeoutMs;
6376
+ challenge;
6377
+ nonceCount = 0;
6378
+ constructor(host, port, credentials, requestTimeoutMs = REQUEST_TIMEOUT_MS2) {
6379
+ this.host = host;
6380
+ this.port = port;
6381
+ this.credentials = credentials;
6382
+ this.requestTimeoutMs = requestTimeoutMs;
6383
+ }
6384
+ // ---------- raw transport ----------
6385
+ /**
6386
+ * Uses `node:http` with `insecureHTTPParser` rather than undici, because the
6387
+ * SwOS firmware terminates the status line of a POST response with a bare LF
6388
+ * instead of CRLF ("HTTP/1.0 200 OK\n"). A strict parser rejects that, which
6389
+ * would report a write that actually succeeded as a failure. The leniency is
6390
+ * scoped to this client — the RouterOS REST client keeps the strict parser.
6391
+ */
6392
+ rawRequest(method, path, headers, body) {
6393
+ return new Promise((resolve, reject) => {
6394
+ const req = httpRequest(
6395
+ {
6396
+ host: this.host,
6397
+ port: this.port,
6398
+ path,
6399
+ method,
6400
+ headers,
6401
+ insecureHTTPParser: true
6402
+ },
6403
+ (res) => {
6404
+ res.setEncoding("utf8");
6405
+ let data = "";
6406
+ res.on("data", (chunk) => data += chunk);
6407
+ res.on(
6408
+ "end",
6409
+ () => resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body: data })
6410
+ );
6411
+ res.on("error", reject);
6412
+ }
6413
+ );
6414
+ req.on("error", reject);
6415
+ req.setTimeout(this.requestTimeoutMs, () => {
6416
+ const isWrite = method === "POST";
6417
+ req.destroy(
6418
+ new MikroMCPError({
6419
+ category: "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
6420
+ code: "SWOS_REQUEST_TIMEOUT",
6421
+ message: `SwOS ${method} request to ${path} timed out after ${this.requestTimeoutMs}ms${isWrite ? " \u2014 the write may still have been applied" : ""}`,
6422
+ recoverability: isWrite ? {
6423
+ retryable: false,
6424
+ suggestedAction: "SwOS firmware does not acknowledge writes, so a longer timeout will not help."
6425
+ } : {
6426
+ retryable: true,
6427
+ retryAfterMs: 3e3,
6428
+ suggestedAction: "The switch did not respond in time \u2014 retry the read."
6429
+ }
6430
+ })
6431
+ );
6432
+ });
6433
+ if (body !== void 0) req.write(body);
6434
+ req.end();
6435
+ });
6436
+ }
6437
+ async doRequest(method, path, body) {
6438
+ for (let attempt = 0; attempt < 3; attempt++) {
6439
+ const headers = {
6440
+ Accept: "text/plain",
6441
+ "Accept-Encoding": "identity"
6442
+ };
6443
+ if (body !== void 0) {
6444
+ headers["Content-Type"] = "text/plain";
6445
+ headers["Content-Length"] = Buffer.byteLength(body);
6446
+ }
6447
+ if (this.challenge) {
6448
+ this.nonceCount += 1;
6449
+ headers["Authorization"] = buildDigestHeader(
6450
+ method,
6451
+ path,
6452
+ this.challenge,
6453
+ this.credentials,
6454
+ this.nonceCount
6455
+ );
6456
+ }
6457
+ const response = await this.rawRequest(method, path, headers, body);
6458
+ const rawChallengeHeader = response.headers["www-authenticate"];
6459
+ const rawChallenge = Array.isArray(rawChallengeHeader) ? rawChallengeHeader[0] : rawChallengeHeader;
6460
+ if (response.statusCode === 401 && rawChallenge) {
6461
+ const parsed = parseDigestChallenge(rawChallenge);
6462
+ if (parsed) {
6463
+ this.challenge = parsed;
6464
+ this.nonceCount = 0;
6465
+ continue;
6466
+ }
6467
+ }
6468
+ if (response.statusCode < 200 || response.statusCode >= 300) {
6469
+ throw new HttpError(response.statusCode, response.body);
6470
+ }
6471
+ return response.body;
6472
+ }
6473
+ throw new HttpError(401, "digest authentication failed");
6474
+ }
6475
+ /** GET a .b endpoint, return the raw broken-JSON text. */
6476
+ async getRaw(endpoint) {
6477
+ return this.doRequest("GET", `/${endpoint}`);
6478
+ }
6479
+ /** POST a re-encoded .b blob back to the switch. */
6480
+ async postRaw(endpoint, body) {
6481
+ return this.doRequest("POST", `/${endpoint}`, body);
6482
+ }
6483
+ // ---------- decoded helpers ----------
6484
+ /** GET + decode into the schema. Unknown keys are preserved under `_raw`. */
6485
+ async get(endpoint) {
6486
+ assertKnownEndpoint(endpoint);
6487
+ return decodeEndpoint(endpoint, await this.getRaw(endpoint));
6488
+ }
6489
+ /**
6490
+ * GET and parse into a plain wire dict for local mutation. Hex tokens keep
6491
+ * their verbatim `"0x…"` form so untouched fields re-serialize byte-for-byte.
6492
+ */
6493
+ async readBlob(endpoint) {
6494
+ assertKnownEndpoint(endpoint);
6495
+ return parseWireBlob(await this.getRaw(endpoint));
6496
+ }
6497
+ /**
6498
+ * GET for mutation, proving the codec is lossless for *this* device first.
6499
+ * The fixtures only prove it for the hardware they came from; a firmware that
6500
+ * serializes differently must fail here rather than during a whole-blob write.
6501
+ */
6502
+ async readBlobForWrite(endpoint) {
6503
+ assertKnownEndpoint(endpoint);
6504
+ const raw = await this.getRaw(endpoint);
6505
+ const wire = parseWireBlob(raw);
6506
+ const reencoded = dumpBlob(wire);
6507
+ if (reencoded !== raw.trim()) {
6508
+ throw new MikroMCPError({
6509
+ category: "VALIDATION" /* VALIDATION */,
6510
+ code: "SWOS_ROUNDTRIP_MISMATCH",
6511
+ message: `MikroMCP cannot re-encode ${endpoint} on this device byte-for-byte; refusing to write.`,
6512
+ details: { endpoint, received: raw.trim(), reencoded },
6513
+ recoverability: {
6514
+ retryable: false,
6515
+ suggestedAction: "This firmware serializes a value in a form the codec does not preserve. Reads are unaffected; please open an issue with the blob."
6516
+ }
6517
+ });
6518
+ }
6519
+ return wire;
6520
+ }
6521
+ /** POST a wire dict (re-encoded via the codec) back to the switch. */
6522
+ async writeBlob(endpoint, wire) {
6523
+ assertKnownEndpoint(endpoint);
6524
+ return this.postRaw(endpoint, dumpBlob(wire));
6525
+ }
6526
+ /** No-op; SwOS uses plain HTTP with per-request connections. */
6527
+ close() {
6528
+ }
6529
+ };
6530
+
6531
+ // src/domain/snapshot/diff-engine.ts
6532
+ var RUNTIME_FIELDS = /* @__PURE__ */ new Set([
6533
+ "bytes",
6534
+ "packets",
6535
+ "dynamic",
6536
+ "invalid",
6537
+ "dead",
6538
+ "expired",
6539
+ "active",
6540
+ "running",
6541
+ "about",
6542
+ ".about",
6543
+ ".nextid",
6544
+ "creation-time",
6545
+ "last-logged-in",
6546
+ "last-link-up-time",
6547
+ "last-link-down-time",
6548
+ "link-downs",
6549
+ "rx-byte",
6550
+ "tx-byte",
6551
+ "rx-packet",
6552
+ "tx-packet",
6553
+ "uptime"
6554
+ ]);
6555
+ var ORDER_SENSITIVE_PATHS = /* @__PURE__ */ new Set([
6556
+ "ip/firewall/filter",
6557
+ "ip/firewall/nat",
6558
+ "ip/firewall/mangle",
6559
+ "routing/rule"
6560
+ ]);
6561
+ function normalizeForDiff(records) {
6562
+ const out = [];
6563
+ for (const r of records) {
6564
+ if (isTrue(r.dynamic)) continue;
6565
+ const clean = {};
6566
+ for (const [k, v] of Object.entries(r)) {
6567
+ if (!RUNTIME_FIELDS.has(k)) clean[k] = v;
6568
+ }
6569
+ out.push(clean);
6570
+ }
6571
+ return out;
6572
+ }
6573
+ var SEMANTIC_KEYS = {
6574
+ "ip/route": ["dst-address", "gateway", "routing-table"],
6575
+ "ip/firewall/filter": ["comment"],
6576
+ "ip/firewall/nat": ["comment"],
6577
+ "ip/firewall/mangle": ["comment"],
6578
+ "ip/firewall/address-list": ["list", "address"],
6579
+ "ip/address": ["address", "interface"],
6580
+ "ip/dns/static": ["name", "type"],
6581
+ "routing/rule": ["src-address", "routing-table"],
6582
+ "routing/table": ["name"],
6583
+ "interface/wireguard/peers": ["public-key"],
6584
+ "interface/bridge": ["name"],
6585
+ "interface/bridge/port": ["bridge", "interface"],
6586
+ "system/scheduler": ["name"],
6587
+ "system/script": ["name"],
6588
+ "system/package": ["name"],
6589
+ "container": ["name"],
6590
+ "certificate": ["name"],
6591
+ "file": ["name"],
6592
+ "interface/vrrp": ["name"],
6593
+ "ip/dhcp-server": ["name"],
6594
+ "ip/ipsec/peer": ["name"],
6595
+ "ip/pool": ["name"],
6596
+ "queue/simple": ["name"],
6597
+ "tool/netwatch": ["host"],
6598
+ "user": ["name"]
6599
+ };
6600
+ function normalizeValue(v) {
6601
+ if (v === true || v === "true" || v === "yes") return "true";
6602
+ if (v === false || v === "false" || v === "no") return "false";
6603
+ return String(v ?? "");
6604
+ }
6605
+ function semanticKey(record, keys) {
6606
+ return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
6607
+ }
6608
+ function recordSignature(record) {
6609
+ return Object.entries(record).filter(([k]) => k !== ".id").sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${normalizeValue(v)}`).join(";");
6610
+ }
6611
+ function recordsAreEqual(a, b) {
6612
+ const keysA = Object.keys(a).filter((k) => k !== ".id");
6613
+ const keysB = Object.keys(b).filter((k) => k !== ".id");
6614
+ if (keysA.length !== keysB.length) return false;
6615
+ return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
6616
+ }
6617
+ function hasDuplicateKeys(records, keys) {
6618
+ const seen = /* @__PURE__ */ new Set();
6619
+ for (const r of records) {
6620
+ const k = semanticKey(r, keys);
6621
+ if (seen.has(k)) return true;
6622
+ seen.add(k);
6623
+ }
6624
+ return false;
6625
+ }
6626
+ function applyUserRestriction(path, plan) {
6627
+ if (path === "user" && plan.toCreate.length > 0) {
6628
+ plan.warnings.push(
6629
+ `Refusing to recreate ${plan.toCreate.length} deleted user(s): passwords are not stored in snapshots and recreation would produce password-less logins. Recreate manually with manage_user.`
6630
+ );
6631
+ plan.toCreate = [];
6632
+ }
6633
+ return plan;
6634
+ }
6635
+ function computeRestorePlan(path, beforeRaw, currentRaw) {
6636
+ const before = normalizeForDiff(beforeRaw);
6637
+ const current = normalizeForDiff(currentRaw);
6638
+ const warnings = [];
6639
+ if (ORDER_SENSITIVE_PATHS.has(path)) {
6640
+ warnings.push(
6641
+ `Restored entries for ${path} are re-created at the end of the list \u2014 rule ORDER is not restored. Review ordering manually after rollback.`
6642
+ );
6643
+ }
6644
+ const keys = SEMANTIC_KEYS[path];
6645
+ const canUseKeys = keys !== void 0 && !hasDuplicateKeys(before, keys) && !hasDuplicateKeys(current, keys);
6646
+ if (!canUseKeys) {
6647
+ const beforeSigs = new Set(before.map(recordSignature));
6648
+ const currentSigs = new Set(current.map(recordSignature));
6649
+ return applyUserRestriction(path, {
6650
+ path,
6651
+ toCreate: before.filter((r) => !currentSigs.has(recordSignature(r))),
6652
+ toRemove: current.filter((r) => !beforeSigs.has(recordSignature(r))).map((r) => r[".id"]),
6653
+ toUpdate: [],
6654
+ warnings
6655
+ });
6656
+ }
6657
+ const currentByKey = new Map(current.map((r) => [semanticKey(r, keys), r]));
6658
+ const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
6659
+ const toCreate = [];
6660
+ const toUpdate = [];
6661
+ for (const [key2, beforeRecord] of beforeByKey) {
6662
+ const currentRecord = currentByKey.get(key2);
6663
+ if (!currentRecord) {
6664
+ toCreate.push(beforeRecord);
6665
+ } else if (!recordsAreEqual(beforeRecord, currentRecord)) {
6666
+ const { ".id": _, ...data } = beforeRecord;
6667
+ toUpdate.push({ currentId: currentRecord[".id"], data });
6668
+ }
6669
+ }
6670
+ const toRemove = current.filter((r) => !beforeByKey.has(semanticKey(r, keys))).map((r) => r[".id"]);
6671
+ return applyUserRestriction(path, { path, toCreate, toRemove, toUpdate, warnings });
6672
+ }
6673
+ async function applyRestorePlan(plan, client) {
6674
+ for (const id of plan.toRemove) {
6675
+ await client.remove(plan.path, id);
6676
+ }
6677
+ for (const record of plan.toCreate) {
6678
+ const { ".id": _, ...data } = record;
6679
+ await client.create(plan.path, data);
6680
+ }
6681
+ for (const { currentId, data } of plan.toUpdate) {
6682
+ await client.update(plan.path, currentId, data);
6683
+ }
6684
+ }
6685
+
6686
+ // src/domain/snapshot/snapshot-engine.ts
6687
+ function pathToSlug(rosPath) {
6688
+ return rosPath.replace(/\//g, "-");
6689
+ }
6690
+ function timestampPrefix() {
6691
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
6692
+ }
6693
+ async function persist(stored, snapshotDir) {
6694
+ const dir = join2(snapshotDir, stored.routerId);
6695
+ const filePath = join2(dir, `${stored.id}.json`);
6696
+ await mkdir(dir, { recursive: true });
6697
+ await writeFile(filePath, JSON.stringify(stored, null, 2));
6698
+ return {
6699
+ id: stored.id,
6700
+ routerId: stored.routerId,
6701
+ path: stored.path,
6702
+ ts: stored.ts,
6703
+ filePath,
6704
+ recordCount: stored.records.length
6705
+ };
6706
+ }
6707
+ async function takeSnapshot(client, routerId2, path, snapshotDir) {
6708
+ const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
6709
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
6710
+ if (client instanceof SwosClient) {
6711
+ const blob = await client.getRaw(path);
6712
+ return persist({ id, routerId: routerId2, path, ts, records: [], blob }, snapshotDir);
6713
+ }
6714
+ const records = normalizeForDiff(await client.get(path, {}));
6715
+ return persist({ id, routerId: routerId2, path, ts, records }, snapshotDir);
6716
+ }
6717
+ async function loadSnapshot(filePath) {
6718
+ return JSON.parse(await readFile(filePath, "utf-8"));
6719
+ }
6720
+
6721
+ // src/domain/snapshot/write-journal.ts
6722
+ import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
6723
+ import { dirname } from "path";
6724
+ import { nanoid as nanoid2 } from "nanoid";
6725
+
6726
+ // src/observability/audit-log.ts
6727
+ import { appendFile } from "fs/promises";
6728
+ var log22 = createLogger("audit");
6729
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
6730
+ "password",
6731
+ "pass",
6732
+ "token",
6733
+ "secret",
6734
+ "authorization",
6735
+ "credentials",
6736
+ "privatekey",
6737
+ "presharedkey",
6738
+ "psk",
6739
+ "passphrase",
6740
+ "community",
6741
+ "apikey",
6742
+ "confirmationsecret"
6743
+ ]);
6744
+ function redactValue(value) {
6745
+ if (Array.isArray(value)) {
6746
+ return value.map(redactValue);
6747
+ }
6748
+ if (value !== null && typeof value === "object") {
6749
+ return redactParams(value);
6750
+ }
6751
+ return value;
5616
6752
  }
5617
6753
  function redactParams(params) {
5618
6754
  const out = {};
@@ -5687,6 +6823,36 @@ var rollbackChangeInputSchema = z24.object({
5687
6823
  journalId: z24.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
5688
6824
  dryRun: z24.boolean().default(false).describe("Preview the restore plan without applying changes")
5689
6825
  }).strict();
6826
+ async function restoreSwosBlobs(snapshots, journalId, dryRun2, context) {
6827
+ const client = context.swosClient;
6828
+ if (!client) {
6829
+ throw new MikroMCPError({
6830
+ category: "VALIDATION" /* VALIDATION */,
6831
+ code: "PLATFORM_MISMATCH",
6832
+ message: `Journal entry "${journalId}" holds SwOS blob snapshots, but router "${context.routerId}" is not a SwOS switch.`,
6833
+ recoverability: {
6834
+ retryable: false,
6835
+ suggestedAction: "Roll back against the switch the write was made on."
6836
+ }
6837
+ });
6838
+ }
6839
+ const targets = snapshots.map((s) => ({ endpoint: s.path, blob: s.blob }));
6840
+ if (dryRun2) {
6841
+ return {
6842
+ content: `Rollback dry run for journal entry "${journalId}": would restore ${targets.length} SwOS endpoint(s).
6843
+ ` + targets.map((t) => ` ${t.endpoint}:
6844
+ ${t.blob}`).join("\n"),
6845
+ structuredContent: { action: "dry_run", journalId, targets }
6846
+ };
6847
+ }
6848
+ for (const target of targets) {
6849
+ await client.postRaw(target.endpoint, target.blob);
6850
+ }
6851
+ return {
6852
+ content: `Rolled back journal entry "${journalId}": restored ${targets.map((t) => t.endpoint).join(", ")}.`,
6853
+ structuredContent: { action: "rolled_back", journalId, targets }
6854
+ };
6855
+ }
5690
6856
  function createChangeManagementTools(baseTools2) {
5691
6857
  const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
5692
6858
  function requireTool(name) {
@@ -5722,7 +6888,7 @@ function createChangeManagementTools(baseTools2) {
5722
6888
  for (const [i, step] of parsed.steps.entries()) {
5723
6889
  const tool = requireTool(step.tool);
5724
6890
  const currentState = {};
5725
- for (const path of tool.snapshotPaths ?? []) {
6891
+ for (const path of snapshotPathsFor(tool, { ...step.params, routerId: parsed.routerId })) {
5726
6892
  try {
5727
6893
  currentState[path] = await context.routerClient.get(path, {});
5728
6894
  } catch {
@@ -5779,14 +6945,12 @@ function createChangeManagementTools(baseTools2) {
5779
6945
  const tool = requireTool(step.tool);
5780
6946
  const stepParams = { ...step.params, routerId: parsed.routerId };
5781
6947
  const snapshotIds = [];
5782
- if (tool.snapshotPaths && tool.snapshotPaths.length > 0) {
5783
- for (const path of tool.snapshotPaths) {
5784
- try {
5785
- const meta = await takeSnapshot(context.routerClient, context.routerId, path, snapshotDir);
5786
- snapshotIds.push(meta.id);
5787
- } catch (err) {
5788
- log24.warn({ err, path, routerId: context.routerId, step: i }, "apply_plan step snapshot failed \u2014 proceeding without snapshot");
5789
- }
6948
+ for (const path of snapshotPathsFor(tool, stepParams)) {
6949
+ try {
6950
+ const meta = await takeSnapshot(context.deviceClient, context.routerId, path, snapshotDir);
6951
+ snapshotIds.push(meta.id);
6952
+ } catch (err) {
6953
+ log24.warn({ err, path, routerId: context.routerId, step: i }, "apply_plan step snapshot failed \u2014 proceeding without snapshot");
5790
6954
  }
5791
6955
  }
5792
6956
  const stepJournalId = recordAttempt({
@@ -5838,7 +7002,7 @@ function createChangeManagementTools(baseTools2) {
5838
7002
  }
5839
7003
  };
5840
7004
  function findJournalEntry(journalPath, journalId) {
5841
- const raw = readFileSync(journalPath, "utf-8");
7005
+ const raw = readFileSync2(journalPath, "utf-8");
5842
7006
  const lines = raw.split("\n").filter(Boolean);
5843
7007
  const entry = lines.map((l) => JSON.parse(l)).find((e) => e.id === journalId && e.phase === "attempt");
5844
7008
  if (!entry) {
@@ -5857,7 +7021,7 @@ function createChangeManagementTools(baseTools2) {
5857
7021
  const rollbackChangeTool = {
5858
7022
  name: "rollback_change",
5859
7023
  title: "Rollback Change",
5860
- description: "Restore RouterOS state to before a write, identified by its journal ID: reads the before-snapshot, diffs against live state, and applies the reverse. Use dryRun=true to preview the restore plan. Requires MIKROMCP_DATA_DIR (defaults to data/).",
7024
+ description: "Restore device state to before a write, identified by its journal ID. RouterOS: reads the before-snapshot, diffs against live state, and applies the reverse. SwOS: re-POSTs the exact pre-write '.b' blob. Use dryRun=true to preview. Requires MIKROMCP_DATA_DIR (defaults to data/).",
5861
7025
  inputSchema: rollbackChangeInputSchema,
5862
7026
  annotations: {
5863
7027
  readOnlyHint: false,
@@ -5865,6 +7029,7 @@ function createChangeManagementTools(baseTools2) {
5865
7029
  idempotentHint: false,
5866
7030
  openWorldHint: false
5867
7031
  },
7032
+ platform: "any",
5868
7033
  async handler(params, context) {
5869
7034
  const parsed = rollbackChangeInputSchema.parse(params);
5870
7035
  const journalPath = context.appConfig.journalPath;
@@ -5877,10 +7042,17 @@ function createChangeManagementTools(baseTools2) {
5877
7042
  structuredContent: { action: "no_snapshot", journalId: parsed.journalId }
5878
7043
  };
5879
7044
  }
7045
+ const snapshots = await Promise.all(
7046
+ snapshotIds.map(
7047
+ (snapshotId) => loadSnapshot(`${snapshotDir}/${context.routerId}/${snapshotId}.json`)
7048
+ )
7049
+ );
7050
+ const swosSnapshots = snapshots.filter((s) => s.blob !== void 0);
7051
+ if (swosSnapshots.length > 0) {
7052
+ return restoreSwosBlobs(swosSnapshots, parsed.journalId, parsed.dryRun, context);
7053
+ }
5880
7054
  const restorePlans = [];
5881
- for (const snapshotId of snapshotIds) {
5882
- const filePath = `${snapshotDir}/${context.routerId}/${snapshotId}.json`;
5883
- const stored = await loadSnapshot(filePath);
7055
+ for (const stored of snapshots) {
5884
7056
  const current = await context.routerClient.get(stored.path, {});
5885
7057
  const plan = computeRestorePlan(stored.path, stored.records, current);
5886
7058
  restorePlans.push(plan);
@@ -11019,27 +12191,437 @@ var manageInterfaceListMemberTool = {
11019
12191
  await context.routerClient.remove("interface/list/member", existing[".id"]);
11020
12192
  log45.info({ list: parsed.list, interface: parsed.interface }, "Interface list member removed");
11021
12193
  return {
11022
- content: `Removed "${parsed.interface}" from list "${parsed.list}".`,
12194
+ content: `Removed "${parsed.interface}" from list "${parsed.list}".`,
12195
+ structuredContent: {
12196
+ action: "removed",
12197
+ list: parsed.list,
12198
+ interface: parsed.interface,
12199
+ id: existing[".id"]
12200
+ }
12201
+ };
12202
+ } catch (err) {
12203
+ throw toolError(err, context, "manage_interface_list_member");
12204
+ }
12205
+ }
12206
+ };
12207
+ var interfaceListTools = [
12208
+ listInterfaceListsTool,
12209
+ manageInterfaceListTool,
12210
+ manageInterfaceListMemberTool
12211
+ ];
12212
+
12213
+ // src/domain/tools/swos-tools.ts
12214
+ import { z as z46 } from "zod";
12215
+ var log46 = createLogger("swos-tools");
12216
+ var endpointEnum = z46.enum(SWOS_ENDPOINTS);
12217
+ var MAX_CONTENT_CHARS = 8e3;
12218
+ function requireSwosClient(context) {
12219
+ if (!context.swosClient) {
12220
+ throw new MikroMCPError({
12221
+ category: "VALIDATION" /* VALIDATION */,
12222
+ code: "PLATFORM_MISMATCH",
12223
+ message: `Router "${context.routerId}" is not configured as a SwOS switch (deviceType: "swos").`,
12224
+ recoverability: {
12225
+ retryable: false,
12226
+ suggestedAction: 'Set deviceType: "swos" for this device in routers.yaml, or target a switch.'
12227
+ }
12228
+ });
12229
+ }
12230
+ return context.swosClient;
12231
+ }
12232
+ async function readFirmwareSupport(client) {
12233
+ try {
12234
+ const sys = await client.get("sys.b");
12235
+ return firmwareSupport(sys.model, sys.version);
12236
+ } catch (err) {
12237
+ log46.warn({ err }, "Could not read sys.b for the firmware compatibility check");
12238
+ return {
12239
+ verified: false,
12240
+ note: `Firmware could not be read (${err instanceof Error ? err.message : String(err)}); compatibility unknown.`
12241
+ };
12242
+ }
12243
+ }
12244
+ var listEndpointsSchema = z46.object({
12245
+ routerId
12246
+ }).strict();
12247
+ var listSwosEndpointsTool = {
12248
+ name: "list_swos_endpoints",
12249
+ title: "List SwOS Endpoints",
12250
+ description: "List the SwOS/SwOS Lite '.b' API endpoints supported by this server, with the decoded field names per endpoint. Read-only schema introspection \u2014 no device call.",
12251
+ inputSchema: listEndpointsSchema,
12252
+ annotations: {
12253
+ readOnlyHint: true,
12254
+ destructiveHint: false,
12255
+ idempotentHint: true,
12256
+ openWorldHint: false
12257
+ },
12258
+ platform: "swos",
12259
+ async handler(params, context) {
12260
+ listEndpointsSchema.parse(params);
12261
+ const endpoints = SWOS_ENDPOINTS.map((ep) => ({
12262
+ endpoint: ep,
12263
+ fields: SWOS_SCHEMA[ep].fields.map((f2) => f2.name)
12264
+ }));
12265
+ const content = endpoints.map(
12266
+ (e) => `${e.endpoint}
12267
+ fields: ${e.fields.length > 0 ? e.fields.join(", ") : "(decoded generically per port)"}`
12268
+ ).join("\n");
12269
+ return {
12270
+ content: `SwOS endpoints:
12271
+ ${content}`,
12272
+ structuredContent: { routerId: context.routerId, endpoints }
12273
+ };
12274
+ }
12275
+ };
12276
+ var STATUS_ENDPOINTS = ["link.b", "sys.b", "poe.b", "sfp.b"];
12277
+ var getStatusSchema = z46.object({
12278
+ routerId,
12279
+ include: z46.array(z46.enum(STATUS_ENDPOINTS)).default([...STATUS_ENDPOINTS]).describe("Which status endpoints to fetch")
12280
+ }).strict();
12281
+ function sectionOf(sections, endpoint) {
12282
+ const section = sections[endpoint];
12283
+ return section && !("_error" in section) ? section : void 0;
12284
+ }
12285
+ function formatStatus(sections) {
12286
+ const lines = [];
12287
+ const sys = sectionOf(sections, "sys.b");
12288
+ if (sys) {
12289
+ const firmware = firmwareSupport(sys.model, sys.version);
12290
+ lines.push("Switch:");
12291
+ if (sys.identity) lines.push(` Identity: ${sys.identity}`);
12292
+ if (sys.model) lines.push(` Model: ${sys.model}`);
12293
+ if (sys.version) {
12294
+ lines.push(
12295
+ ` Firmware: ${sys.version}${firmware.verified ? " (schema verified)" : " (schema not verified for this firmware \u2014 see the note on writes)"}`
12296
+ );
12297
+ }
12298
+ if (sys.serial) lines.push(` Serial: ${sys.serial}`);
12299
+ if (sys.uptime) lines.push(` Uptime: ${String(sys.uptime)}s`);
12300
+ if (sys.ip) lines.push(` IP: ${sys.ip}`);
12301
+ if (sys.mac) lines.push(` MAC: ${sys.mac}`);
12302
+ }
12303
+ const link = sectionOf(sections, "link.b");
12304
+ if (link) {
12305
+ const names = link.name;
12306
+ const speeds = link.speed;
12307
+ const links = link.link;
12308
+ const dups = link.duplex;
12309
+ const count = Math.max(names?.length ?? 0, speeds?.length ?? 0, links?.length ?? 0);
12310
+ lines.push("Ports:");
12311
+ for (let i = 0; i < count; i++) {
12312
+ const name = names?.[i] ?? `port${i + 1}`;
12313
+ const speed = speeds?.[i] ?? "?";
12314
+ const linkState = links?.[i] ? "up" : "down";
12315
+ const dup = dups?.[i] === void 0 ? "?" : dups[i] ? "full" : "half";
12316
+ lines.push(` ${String(name)}: ${linkState} (${String(speed)} ${dup})`);
12317
+ }
12318
+ }
12319
+ const poe = sectionOf(sections, "poe.b");
12320
+ if (poe) {
12321
+ const out = poe.out;
12322
+ const state = poe.state;
12323
+ const power = poe.power;
12324
+ const count = Math.max(out?.length ?? 0, state?.length ?? 0);
12325
+ lines.push("PoE:");
12326
+ for (let i = 0; i < count; i++) {
12327
+ const mode = out?.[i] ?? "?";
12328
+ const st = state?.[i] ?? "?";
12329
+ const pw = power?.[i] !== void 0 ? `${String(power[i])}W` : "";
12330
+ lines.push(` port${i + 1}: ${String(mode)} / ${String(st)} ${pw}`.trimEnd());
12331
+ }
12332
+ }
12333
+ const sfp = sectionOf(sections, "sfp.b");
12334
+ if (sfp) {
12335
+ const cages = Math.max(
12336
+ ...["vendor", "part_number", "serial", "temperature"].map(
12337
+ (key2) => sfp[key2]?.length ?? 0
12338
+ )
12339
+ );
12340
+ if (cages > 0) {
12341
+ lines.push("SFP modules:");
12342
+ for (let i = 0; i < cages; i++) {
12343
+ const vendor = sfp.vendor?.[i];
12344
+ const part = sfp.part_number?.[i];
12345
+ if (!vendor && !part) continue;
12346
+ const serial = sfp.serial?.[i];
12347
+ const temp = sfp.temperature?.[i];
12348
+ lines.push(
12349
+ ` sfp${i + 1}: ${String(vendor ?? "?")} ${String(part ?? "?")}${serial ? ` sn=${String(serial)}` : ""}${temp !== void 0 ? ` ${String(temp)}\xB0C` : ""}`
12350
+ );
12351
+ }
12352
+ }
12353
+ }
12354
+ const failed = Object.entries(sections).filter(([, v]) => v && typeof v === "object" && "_error" in v).map(([ep, v]) => ` ${ep}: ${v._error}`);
12355
+ if (failed.length > 0) lines.push("Sections that failed to load:", ...failed);
12356
+ return lines.length > 0 ? lines.join("\n") : "No status retrieved.";
12357
+ }
12358
+ var getSwosStatusTool = {
12359
+ name: "get_swos_status",
12360
+ title: "Get SwOS Status",
12361
+ description: "Retrieve status from a MikroTik SwOS switch (SwOS or SwOS Lite): identity, model, firmware, uptime, per-port link state/speed/duplex, PoE mode/state/power, and SFP modules.",
12362
+ inputSchema: getStatusSchema,
12363
+ annotations: {
12364
+ readOnlyHint: true,
12365
+ destructiveHint: false,
12366
+ idempotentHint: true,
12367
+ openWorldHint: false
12368
+ },
12369
+ platform: "swos",
12370
+ async handler(params, context) {
12371
+ const parsed = getStatusSchema.parse(params);
12372
+ const client = requireSwosClient(context);
12373
+ const sections = {};
12374
+ for (const ep of parsed.include) {
12375
+ try {
12376
+ sections[ep] = await client.get(ep);
12377
+ } catch (err) {
12378
+ log46.warn({ ep, err }, "SwOS status section failed, skipping");
12379
+ sections[ep] = { _error: err instanceof Error ? err.message : String(err) };
12380
+ }
12381
+ }
12382
+ const sys = sectionOf(sections, "sys.b");
12383
+ return {
12384
+ content: formatStatus(sections),
12385
+ structuredContent: {
12386
+ routerId: context.routerId,
12387
+ sections,
12388
+ ...sys ? { compatibility: firmwareSupport(sys.model, sys.version) } : {}
12389
+ }
12390
+ };
12391
+ }
12392
+ };
12393
+ var getEndpointSchema = z46.object({
12394
+ routerId,
12395
+ endpoint: endpointEnum.describe("'.b' endpoint to fetch (see list_swos_endpoints)")
12396
+ }).strict();
12397
+ var getSwosEndpointTool = {
12398
+ name: "get_swos_endpoint",
12399
+ title: "Get SwOS Endpoint",
12400
+ description: "Fetch and decode a single SwOS '.b' endpoint (link.b, sys.b, poe.b, lacp.b, rstp.b, snmp.b, fwd.b, vlan.b, stats.b, sfp.b, host.b, acl.b, ...) as structured data. Unknown keys are preserved under '_raw'.",
12401
+ inputSchema: getEndpointSchema,
12402
+ annotations: {
12403
+ readOnlyHint: true,
12404
+ destructiveHint: false,
12405
+ idempotentHint: true,
12406
+ openWorldHint: false
12407
+ },
12408
+ platform: "swos",
12409
+ async handler(params, context) {
12410
+ const parsed = getEndpointSchema.parse(params);
12411
+ const client = requireSwosClient(context);
12412
+ try {
12413
+ const decoded = await client.get(parsed.endpoint);
12414
+ const rendered = JSON.stringify(decoded, null, 2);
12415
+ const content = rendered.length > MAX_CONTENT_CHARS ? `${rendered.slice(0, MAX_CONTENT_CHARS)}
12416
+ \u2026 truncated (${rendered.length} chars). Full decode is in structuredContent.` : rendered;
12417
+ return {
12418
+ content,
12419
+ structuredContent: { routerId: context.routerId, endpoint: parsed.endpoint, data: decoded }
12420
+ };
12421
+ } catch (err) {
12422
+ throw toolError(err, context, "get_swos_endpoint");
12423
+ }
12424
+ }
12425
+ };
12426
+ var WRITABLE_ENDPOINTS = SWOS_ENDPOINTS.filter(
12427
+ (ep) => !ep.startsWith("!") && !SWOS_SCHEMA[ep].isArray
12428
+ );
12429
+ var writeBlobSchema = z46.object({
12430
+ routerId,
12431
+ endpoint: z46.enum(WRITABLE_ENDPOINTS).describe("'.b' endpoint to write"),
12432
+ fields: z46.record(
12433
+ z46.string(),
12434
+ z46.union([
12435
+ z46.string(),
12436
+ z46.number(),
12437
+ z46.boolean(),
12438
+ z46.array(z46.union([z46.string(), z46.number(), z46.boolean()]))
12439
+ ])
12440
+ ).describe(
12441
+ "Values to set, keyed by decoded field name (from list_swos_endpoints) or by a raw wire key already present in the blob \u2014 anything else is rejected. Option fields take the enum name (e.g. 'auto'); text becomes a UTF-8 hex string; numbers and '0x...' strings are written as hex, padded to the width of the value they replace. A per-port field given a single value applies it to EVERY port."
12442
+ ),
12443
+ dryRun: z46.boolean().default(true).describe("Preview the resulting blob without writing to the switch.")
12444
+ }).strict();
12445
+ function resolveField(epKey, key2) {
12446
+ const ep = SWOS_SCHEMA[epKey];
12447
+ if (!ep) return void 0;
12448
+ return ep.fields.find((field) => field.name === key2 || Object.values(field.keys).includes(key2));
12449
+ }
12450
+ function resolveWireKey(field, wire) {
12451
+ return Object.values(field.keys).find((key2) => key2 in wire);
12452
+ }
12453
+ function validationError(code, message, suggestedAction) {
12454
+ return new MikroMCPError({
12455
+ category: "VALIDATION" /* VALIDATION */,
12456
+ code,
12457
+ message,
12458
+ recoverability: { retryable: false, suggestedAction }
12459
+ });
12460
+ }
12461
+ function coerceValue(field, value, existing) {
12462
+ if (Array.isArray(value)) {
12463
+ const existingItems = Array.isArray(existing) ? existing : [];
12464
+ return value.map((v, i) => coerceScalar(field, v, existingItems[i]));
12465
+ }
12466
+ return coerceScalar(field, value, existing);
12467
+ }
12468
+ function coerceScalar(field, value, existing) {
12469
+ if (typeof value === "boolean") {
12470
+ if (field.kind === Kind.BOOL) {
12471
+ throw validationError(
12472
+ "SWOS_FIELD_SHAPE_MISMATCH",
12473
+ `Field "${field.name}" is a per-port bitmask, not a single flag.`,
12474
+ "Pass the mask as a number \u2014 e.g. 1023 for all 10 ports, 0 for none."
12475
+ );
12476
+ }
12477
+ return encodeLike(value ? 1 : 0, existing);
12478
+ }
12479
+ if (typeof value === "number") return encodeLike(value, existing);
12480
+ if (field.kind === Kind.OPTION) {
12481
+ const idx = (field.options ?? []).indexOf(String(value));
12482
+ if (idx < 0) {
12483
+ throw validationError(
12484
+ "SWOS_INVALID_OPTION",
12485
+ `Invalid option "${String(value)}" for field "${field.name}".`,
12486
+ `Use one of: ${(field.options ?? []).join(", ")}.`
12487
+ );
12488
+ }
12489
+ return encodeLike(idx, existing);
12490
+ }
12491
+ const text = String(value);
12492
+ return isWireHex(text) ? text : Buffer.from(text, "utf8").toString("hex");
12493
+ }
12494
+ function assertShapeMatches(perPort, key2, next, existing) {
12495
+ if (existing === void 0) return;
12496
+ if (Array.isArray(next) && Array.isArray(existing)) {
12497
+ if (next.length === existing.length) return;
12498
+ throw validationError(
12499
+ "SWOS_FIELD_SHAPE_MISMATCH",
12500
+ `Field "${key2}" holds ${existing.length} value(s) on the wire, but ${next.length} were given.`,
12501
+ `Pass exactly ${existing.length} value(s), or a single value to apply to all of them.`
12502
+ );
12503
+ }
12504
+ if (!Array.isArray(next) && !Array.isArray(existing)) return;
12505
+ if (!Array.isArray(next) && perPort) return;
12506
+ throw validationError(
12507
+ "SWOS_FIELD_SHAPE_MISMATCH",
12508
+ Array.isArray(next) ? `Field "${key2}" is a single per-port bitmask on the wire, but an array was given.` : `Field "${key2}" is a per-port array on the wire, but a single value was given.`,
12509
+ Array.isArray(next) ? "Pass the bitmask as one number (e.g. 1023 for all 10 ports)." : "Pass one value per port."
12510
+ );
12511
+ }
12512
+ var writeSwosBlobTool = {
12513
+ name: "write_swos_blob",
12514
+ title: "Write SwOS Blob",
12515
+ description: "Mutate a SwOS '.b' endpoint on a MikroTik switch. The full endpoint blob is read, the given fields are merged in, and the entire blob is written back (the firmware only accepts whole-blob writes); untouched fields are re-sent byte-for-byte, and a field this firmware does not expose is refused rather than injected. dryRun defaults to true \u2014 preview first. Every result reports whether the schema has been verified against the switch's firmware. The pre-write blob is snapshotted, so the change can be undone with rollback_change. Returns no_change when the values already match.",
12516
+ inputSchema: writeBlobSchema,
12517
+ annotations: {
12518
+ readOnlyHint: false,
12519
+ destructiveHint: true,
12520
+ idempotentHint: true,
12521
+ openWorldHint: false
12522
+ },
12523
+ platform: "swos",
12524
+ // The endpoint is an argument, so the snapshot path is too. The executor
12525
+ // captures the pre-write blob verbatim; rollback_change re-POSTs it.
12526
+ snapshotPaths: (params) => params.endpoint ? [String(params.endpoint)] : [],
12527
+ async handler(params, context) {
12528
+ const parsed = writeBlobSchema.parse(params);
12529
+ const client = requireSwosClient(context);
12530
+ try {
12531
+ const wire = await client.readBlobForWrite(parsed.endpoint);
12532
+ const changes = {};
12533
+ for (const [key2, value] of Object.entries(parsed.fields)) {
12534
+ const field = resolveField(parsed.endpoint, key2);
12535
+ const targetKey = field ? resolveWireKey(field, wire) : key2;
12536
+ if (targetKey === void 0 || !(targetKey in wire)) {
12537
+ throw validationError(
12538
+ "SWOS_UNKNOWN_FIELD",
12539
+ field ? `Field "${key2}" is not present in ${parsed.endpoint} on this device \u2014 its firmware does not expose it.` : `Unknown field "${key2}" for endpoint ${parsed.endpoint}.`,
12540
+ `Read the endpoint first; only keys the device actually sent can be written: ${Object.keys(wire).join(", ")}.`
12541
+ );
12542
+ }
12543
+ const existing = wire[targetKey];
12544
+ const next = field ? coerceValue(field, value, existing) : value;
12545
+ assertShapeMatches(field?.perPort ?? false, key2, next, existing);
12546
+ if (field?.perPort && !Array.isArray(next) && Array.isArray(existing)) {
12547
+ wire[targetKey] = existing.map(() => next);
12548
+ } else {
12549
+ wire[targetKey] = next;
12550
+ }
12551
+ changes[key2] = { from: existing, to: wire[targetKey] };
12552
+ }
12553
+ const modified = Object.entries(changes).filter(
12554
+ ([, c]) => JSON.stringify(c.from) !== JSON.stringify(c.to)
12555
+ );
12556
+ if (modified.length === 0) {
12557
+ return {
12558
+ content: `No change: ${parsed.endpoint} on ${context.routerId} already has these values.`,
12559
+ structuredContent: {
12560
+ routerId: context.routerId,
12561
+ endpoint: parsed.endpoint,
12562
+ action: "no_change",
12563
+ changes
12564
+ }
12565
+ };
12566
+ }
12567
+ const diff = modified.map(([key2, c]) => ` ${key2}: ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}`).join("\n");
12568
+ const preview = dumpBlob(wire);
12569
+ const firmware = await readFirmwareSupport(client);
12570
+ const unmapped = unmappedWireKeys(parsed.endpoint, wire);
12571
+ const banner = `${firmware.verified ? "Firmware" : "\u26A0\uFE0F Firmware"}: ${firmware.note}` + (unmapped.length > 0 ? `
12572
+ \u26A0\uFE0F ${unmapped.length} key(s) in ${parsed.endpoint} are not in the schema and are re-sent unchanged: ${unmapped.join(", ")}` : "");
12573
+ const compatibility = { ...firmware, unmappedKeys: unmapped };
12574
+ if (parsed.dryRun) {
12575
+ return {
12576
+ content: `DRY RUN \u2014 no write performed. ${modified.length} field(s) would change on ${parsed.endpoint}:
12577
+ ${diff}
12578
+
12579
+ ${banner}
12580
+
12581
+ Resulting blob:
12582
+ ${preview}`,
12583
+ structuredContent: {
12584
+ routerId: context.routerId,
12585
+ endpoint: parsed.endpoint,
12586
+ action: "dry_run",
12587
+ dryRun: true,
12588
+ changes,
12589
+ compatibility,
12590
+ blob: preview
12591
+ }
12592
+ };
12593
+ }
12594
+ const response = await client.writeBlob(parsed.endpoint, wire);
12595
+ return {
12596
+ content: `Wrote ${parsed.endpoint} on ${context.routerId}:
12597
+ ${diff}
12598
+ ${banner}
12599
+ Response: ${response || "(empty)"}
12600
+ The pre-write blob was snapshotted \u2014 undo with rollback_change using this call's journal ID.`,
11023
12601
  structuredContent: {
11024
- action: "removed",
11025
- list: parsed.list,
11026
- interface: parsed.interface,
11027
- id: existing[".id"]
12602
+ routerId: context.routerId,
12603
+ endpoint: parsed.endpoint,
12604
+ action: "written",
12605
+ dryRun: false,
12606
+ changes,
12607
+ compatibility,
12608
+ response
11028
12609
  }
11029
12610
  };
11030
12611
  } catch (err) {
11031
- throw toolError(err, context, "manage_interface_list_member");
12612
+ throw toolError(err, context, "write_swos_blob");
11032
12613
  }
11033
12614
  }
11034
12615
  };
11035
- var interfaceListTools = [
11036
- listInterfaceListsTool,
11037
- manageInterfaceListTool,
11038
- manageInterfaceListMemberTool
12616
+ var swosTools = [
12617
+ listSwosEndpointsTool,
12618
+ getSwosStatusTool,
12619
+ getSwosEndpointTool,
12620
+ writeSwosBlobTool
11039
12621
  ];
11040
12622
 
11041
12623
  // src/domain/tools/fleet-tools.ts
11042
- import { z as z46 } from "zod";
12624
+ import { z as z47 } from "zod";
11043
12625
 
11044
12626
  // src/middleware/authz.ts
11045
12627
  function matchesPattern(name, pattern) {
@@ -11121,7 +12703,7 @@ function getCredentials(router) {
11121
12703
  }
11122
12704
 
11123
12705
  // src/adapter/ssh-client.ts
11124
- import { createHash } from "crypto";
12706
+ import { createHash as createHash2 } from "crypto";
11125
12707
  import { Client } from "ssh2";
11126
12708
  var SshClient = class {
11127
12709
  constructor(config, credentials, options) {
@@ -11209,7 +12791,7 @@ var SshClient = class {
11209
12791
  if (this.config.sshFingerprint) {
11210
12792
  const expected = this.config.sshFingerprint.toLowerCase();
11211
12793
  connectOptions.hostVerifier = (key2) => {
11212
- const actual = createHash("sha256").update(key2).digest("hex");
12794
+ const actual = createHash2("sha256").update(key2).digest("hex");
11213
12795
  return actual === expected;
11214
12796
  };
11215
12797
  }
@@ -11221,7 +12803,7 @@ var SshClient = class {
11221
12803
  // src/adapter/ftp-client.ts
11222
12804
  import { Client as Client2 } from "basic-ftp";
11223
12805
  import { Readable } from "stream";
11224
- var log46 = createLogger("ftp-client");
12806
+ var log47 = createLogger("ftp-client");
11225
12807
  var FtpClient = class {
11226
12808
  #host;
11227
12809
  #port;
@@ -11234,7 +12816,7 @@ var FtpClient = class {
11234
12816
  this.#password = password;
11235
12817
  }
11236
12818
  async upload(remoteName, content) {
11237
- log46.info({ host: this.#host, remoteName }, "Uploading file via FTP");
12819
+ log47.info({ host: this.#host, remoteName }, "Uploading file via FTP");
11238
12820
  const client = new Client2();
11239
12821
  try {
11240
12822
  await client.access({
@@ -11244,7 +12826,7 @@ var FtpClient = class {
11244
12826
  password: this.#password
11245
12827
  });
11246
12828
  await client.uploadFrom(Readable.from(Buffer.from(content, "utf-8")), remoteName);
11247
- log46.info({ host: this.#host, remoteName }, "FTP upload complete");
12829
+ log47.info({ host: this.#host, remoteName }, "FTP upload complete");
11248
12830
  } finally {
11249
12831
  client.close();
11250
12832
  }
@@ -11266,7 +12848,7 @@ var FtpClient = class {
11266
12848
  };
11267
12849
 
11268
12850
  // src/adapter/sftp-client.ts
11269
- import { createHash as createHash2 } from "crypto";
12851
+ import { createHash as createHash3 } from "crypto";
11270
12852
  import { Client as Client3 } from "ssh2";
11271
12853
  var SftpClient = class {
11272
12854
  constructor(config, credentials) {
@@ -11308,7 +12890,7 @@ var SftpClient = class {
11308
12890
  if (this.config.sshFingerprint) {
11309
12891
  const expected = this.config.sshFingerprint.toLowerCase();
11310
12892
  connectOptions.hostVerifier = (key2) => {
11311
- const actual = createHash2("sha256").update(key2).digest("hex");
12893
+ const actual = createHash3("sha256").update(key2).digest("hex");
11312
12894
  return actual === expected;
11313
12895
  };
11314
12896
  }
@@ -11335,8 +12917,21 @@ function createSftpClient(routerConfig) {
11335
12917
  function buildRouterToolContext(args) {
11336
12918
  const { routerConfig, correlationId, identity, pool, config, registry } = args;
11337
12919
  const credentials = getCredentials(routerConfig);
12920
+ const deviceClient = pool.getClient(routerConfig, credentials);
12921
+ const swosClient = deviceClient instanceof SwosClient ? deviceClient : void 0;
12922
+ const restClient = swosClient ? void 0 : deviceClient;
11338
12923
  return {
11339
- routerClient: pool.getClient(routerConfig, credentials),
12924
+ // Platform gating (tool-executor / bulk_execute) guarantees a RouterOS tool
12925
+ // never reaches a SwOS device, so `routerClient` is only ever dereferenced
12926
+ // when the pool actually returned a REST client. `deviceClient` carries the
12927
+ // honest type for platform-aware callers.
12928
+ routerClient: restClient ?? unavailableCapability(
12929
+ "ROUTEROS_CLIENT_UNAVAILABLE",
12930
+ `Router "${routerConfig.id}" is a SwOS switch and has no RouterOS REST API.`,
12931
+ "Use a swos_* tool, or target a RouterOS router."
12932
+ ),
12933
+ deviceClient,
12934
+ swosClient,
11340
12935
  routerId: routerConfig.id,
11341
12936
  correlationId,
11342
12937
  routerConfig,
@@ -11435,13 +13030,13 @@ function getStdioIdentity(stdioIdentityName, registry) {
11435
13030
  }
11436
13031
 
11437
13032
  // src/middleware/confirmation.ts
11438
- import { createHmac, createHash as createHash3, timingSafeEqual } from "crypto";
13033
+ import { createHmac, createHash as createHash4, timingSafeEqual } from "crypto";
11439
13034
  var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
11440
13035
  var BYPASS_ROLES = ["admin", "superadmin"];
11441
13036
  var usedTokens = /* @__PURE__ */ new Map();
11442
13037
  function computeParamHash(params) {
11443
13038
  const { confirmationToken: _, ...rest } = params;
11444
- return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
13039
+ return createHash4("sha256").update(JSON.stringify(rest)).digest("hex");
11445
13040
  }
11446
13041
  function sign(tool, routerId2, identityId, paramHash, expiresAtMs, secret) {
11447
13042
  const payload = `${tool}|${routerId2}|${identityId}|${paramHash}|${expiresAtMs}`;
@@ -11756,7 +13351,7 @@ var CircuitBreaker = class {
11756
13351
  };
11757
13352
 
11758
13353
  // src/mcp/tool-executor.ts
11759
- var log47 = createLogger("tool-executor");
13354
+ var log48 = createLogger("tool-executor");
11760
13355
  var AMBIGUOUS_WRITE_CATEGORIES = /* @__PURE__ */ new Set([
11761
13356
  "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
11762
13357
  "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */
@@ -11810,17 +13405,24 @@ function assertMaintenanceWindow(isDestructive, routerConfig, routerId2) {
11810
13405
  }
11811
13406
  }
11812
13407
  function fleetUnavailable(what) {
11813
- return new Proxy({}, {
11814
- get() {
11815
- throw new MikroMCPError({
11816
- category: "INTERNAL" /* INTERNAL */,
11817
- code: "FLEET_CONTEXT_UNAVAILABLE",
11818
- message: `${what} is not available in a fleet-tool context (skipRouterContext). Target a specific router instead.`,
11819
- recoverability: {
11820
- retryable: false,
11821
- suggestedAction: "Use a router-scoped tool, or pass a routerId to operate on one router."
11822
- }
11823
- });
13408
+ return unavailableCapability(
13409
+ "FLEET_CONTEXT_UNAVAILABLE",
13410
+ `${what} is not available in a fleet-tool context (skipRouterContext). Target a specific router instead.`,
13411
+ "Use a router-scoped tool, or pass a routerId to operate on one router."
13412
+ );
13413
+ }
13414
+ function assertPlatformMatch(tool, routerConfig) {
13415
+ const toolPlatform = tool.platform ?? "routeros";
13416
+ const devicePlatform = routerConfig.deviceType ?? "routeros";
13417
+ if (toolPlatform === "any" || toolPlatform === devicePlatform) return;
13418
+ throw new MikroMCPError({
13419
+ category: "VALIDATION" /* VALIDATION */,
13420
+ code: "PLATFORM_MISMATCH",
13421
+ message: `Tool "${tool.name}" runs on ${toolPlatform} devices, but router "${routerConfig.id}" is a ${devicePlatform} device.`,
13422
+ details: { toolPlatform, devicePlatform },
13423
+ recoverability: {
13424
+ retryable: false,
13425
+ suggestedAction: toolPlatform === "swos" ? "Target a SwOS switch instead \u2014 check list_routers for the deviceType of each device." : "Target a RouterOS device instead \u2014 check list_routers for the deviceType of each device."
11824
13426
  }
11825
13427
  });
11826
13428
  }
@@ -11835,6 +13437,7 @@ async function executeToolCall(tool, args, deps) {
11835
13437
  if (tool.skipRouterContext) {
11836
13438
  const fleetContext = {
11837
13439
  routerClient: fleetUnavailable("routerClient"),
13440
+ deviceClient: fleetUnavailable("deviceClient"),
11838
13441
  routerId: "",
11839
13442
  correlationId,
11840
13443
  routerConfig: fleetUnavailable("routerConfig"),
@@ -11855,6 +13458,7 @@ async function executeToolCall(tool, args, deps) {
11855
13458
  args.routerId = routerId2;
11856
13459
  checkAuthz(identity, tool.name, routerId2);
11857
13460
  const routerConfig = registry.getRouter(routerId2);
13461
+ assertPlatformMatch(tool, routerConfig);
11858
13462
  assertMaintenanceWindow(tool.annotations.destructiveHint, routerConfig, routerId2);
11859
13463
  if (tool.annotations.destructiveHint && config.confirmationSecret) {
11860
13464
  await checkConfirmation(tool.name, routerId2, args, identity, config.confirmationSecret);
@@ -11885,14 +13489,14 @@ async function executeToolCall(tool, args, deps) {
11885
13489
  toolContext.circuitBreaker = cb;
11886
13490
  const { confirmationToken: _ct, ...handlerArgs } = args;
11887
13491
  const snapshotIds = [];
11888
- if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
11889
- for (const path of tool.snapshotPaths) {
13492
+ if (!tool.annotations.readOnlyHint) {
13493
+ for (const path of snapshotPathsFor(tool, handlerArgs)) {
11890
13494
  try {
11891
- const meta = await takeSnapshot(toolContext.routerClient, routerId2, path, config.snapshotDir);
13495
+ const meta = await takeSnapshot(toolContext.deviceClient, routerId2, path, config.snapshotDir);
11892
13496
  snapshotIds.push(meta.id);
11893
- log47.debug({ snapshotId: meta.id, path }, "Snapshot taken");
13497
+ log48.debug({ snapshotId: meta.id, path }, "Snapshot taken");
11894
13498
  } catch (err) {
11895
- log47.warn({ err, path, routerId: routerId2 }, "Snapshot failed \u2014 proceeding without snapshot");
13499
+ log48.warn({ err, path, routerId: routerId2 }, "Snapshot failed \u2014 proceeding without snapshot");
11896
13500
  }
11897
13501
  }
11898
13502
  }
@@ -11933,7 +13537,7 @@ async function executeToolCall(tool, args, deps) {
11933
13537
  durationMs: Date.now() - startMs
11934
13538
  }, config.auditLogPath);
11935
13539
  }
11936
- log47.info({ tool: tool.name, routerId: routerId2, correlationId }, "Tool executed successfully");
13540
+ log48.info({ tool: tool.name, routerId: routerId2, correlationId }, "Tool executed successfully");
11937
13541
  recordToolCall(tool.name, "success");
11938
13542
  return formatToolResult(result);
11939
13543
  } catch (err) {
@@ -11945,7 +13549,7 @@ async function executeToolCall(tool, args, deps) {
11945
13549
  const failedRouterId = args.routerId;
11946
13550
  if (failedRouterId) {
11947
13551
  pool.removeClient(failedRouterId);
11948
- log47.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
13552
+ log48.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
11949
13553
  }
11950
13554
  }
11951
13555
  if (journalId) {
@@ -11975,7 +13579,7 @@ async function executeToolCall(tool, args, deps) {
11975
13579
  durationMs: Date.now() - startMs
11976
13580
  }, config.auditLogPath);
11977
13581
  }
11978
- log47.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
13582
+ log48.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
11979
13583
  recordToolCall(tool.name, "error");
11980
13584
  return formatError(error);
11981
13585
  }
@@ -11983,7 +13587,7 @@ async function executeToolCall(tool, args, deps) {
11983
13587
  }
11984
13588
 
11985
13589
  // src/middleware/fleet-confirmation.ts
11986
- import { createHmac as createHmac2, createHash as createHash4, timingSafeEqual as timingSafeEqual2 } from "crypto";
13590
+ import { createHmac as createHmac2, createHash as createHash5, timingSafeEqual as timingSafeEqual2 } from "crypto";
11987
13591
  var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
11988
13592
  var usedTokens2 = /* @__PURE__ */ new Map();
11989
13593
  function fingerprint(args) {
@@ -11994,7 +13598,7 @@ function fingerprint(args) {
11994
13598
  params: args.params,
11995
13599
  identityId: args.identityId
11996
13600
  });
11997
- return createHash4("sha256").update(payload).digest("hex");
13601
+ return createHash5("sha256").update(payload).digest("hex");
11998
13602
  }
11999
13603
  function sign2(fp, expiresAtMs, secret) {
12000
13604
  const mac = createHmac2("sha256", secret).update(`${fp}|${expiresAtMs}`).digest("hex");
@@ -12053,22 +13657,29 @@ function checkFleetConfirmation(args, secret) {
12053
13657
  }
12054
13658
 
12055
13659
  // src/domain/tools/fleet-tools.ts
12056
- var log48 = createLogger("fleet-tools");
12057
- var checkHealthInputSchema = z46.object({
13660
+ var log49 = createLogger("fleet-tools");
13661
+ function swosResourceRecord(sys) {
13662
+ const decoded = sys ?? {};
13663
+ return {
13664
+ version: String(decoded.version ?? ""),
13665
+ uptime: `${String(decoded.uptime ?? 0)}s`
13666
+ };
13667
+ }
13668
+ var checkHealthInputSchema = z47.object({
12058
13669
  routerId
12059
13670
  }).strict();
12060
- var bulkExecuteInputSchema = z46.object({
12061
- toolName: z46.string().describe("Name of the tool to fan out (must be a single-router tool)"),
12062
- routerIds: z46.array(z46.string()).optional().describe("Explicit list of router IDs to target"),
12063
- tags: z46.array(z46.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
12064
- params: z46.record(z46.string(), z46.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
12065
- concurrency: z46.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
12066
- confirmationToken: z46.string().optional().describe(
13671
+ var bulkExecuteInputSchema = z47.object({
13672
+ toolName: z47.string().describe("Name of the tool to fan out (must be a single-router tool)"),
13673
+ routerIds: z47.array(z47.string()).optional().describe("Explicit list of router IDs to target"),
13674
+ tags: z47.array(z47.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
13675
+ params: z47.record(z47.string(), z47.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
13676
+ concurrency: z47.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
13677
+ confirmationToken: z47.string().optional().describe(
12067
13678
  "Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
12068
13679
  )
12069
13680
  }).strict();
12070
- var listRoutersInputSchema = z46.object({
12071
- tags: z46.array(z46.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
13681
+ var listRoutersInputSchema = z47.object({
13682
+ tags: z47.array(z47.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
12072
13683
  }).strict();
12073
13684
  var listRoutersTool = {
12074
13685
  name: "list_routers",
@@ -12086,7 +13697,7 @@ var listRoutersTool = {
12086
13697
  const parsed = listRoutersInputSchema.parse(params);
12087
13698
  const { identity, appConfig } = context;
12088
13699
  const defaultRouter = appConfig.defaultRouter;
12089
- log48.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
13700
+ log49.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
12090
13701
  let routers = context.routerRegistry.listRouters(parsed.tags);
12091
13702
  if (identity.allowedRouters.length > 0) {
12092
13703
  routers = routers.filter((r) => identity.allowedRouters.includes(r.id));
@@ -12096,6 +13707,7 @@ var listRoutersTool = {
12096
13707
  id: r.id,
12097
13708
  host: r.host,
12098
13709
  port: r.port,
13710
+ deviceType: r.deviceType ?? "routeros",
12099
13711
  tlsEnabled: r.tls.enabled,
12100
13712
  tags: r.tags,
12101
13713
  rosVersion: r.rosVersion,
@@ -12106,7 +13718,7 @@ var listRoutersTool = {
12106
13718
  const lines = rows.map(
12107
13719
  (r) => ` ${compactFields(
12108
13720
  { ...r, tags: r.tags.join(",") },
12109
- ["id", "host", "port", "tlsEnabled", "tags", "rosVersion", "isDefault"]
13721
+ ["id", "host", "port", "deviceType", "tlsEnabled", "tags", "rosVersion", "isDefault"]
12110
13722
  )}`
12111
13723
  );
12112
13724
  return {
@@ -12120,7 +13732,7 @@ function createFleetTools(baseTools2) {
12120
13732
  const checkRouterHealthTool = {
12121
13733
  name: "check_router_health",
12122
13734
  title: "Check Router Health",
12123
- description: "Probe a router by fetching system/resource. Returns health status, ROS version, uptime, CPU load, and memory info. Unlike other tools, this never throws \u2014 unreachable routers are reported as healthy=false.",
13735
+ description: "Probe a device: RouterOS routers via system/resource, SwOS switches via sys.b. Returns health status, firmware version, uptime, and (RouterOS only) CPU load and memory info. Unlike other tools, this never throws \u2014 unreachable devices are reported as healthy=false.",
12124
13736
  inputSchema: checkHealthInputSchema,
12125
13737
  annotations: {
12126
13738
  readOnlyHint: true,
@@ -12128,13 +13740,13 @@ function createFleetTools(baseTools2) {
12128
13740
  idempotentHint: true,
12129
13741
  openWorldHint: false
12130
13742
  },
13743
+ platform: "any",
12131
13744
  async handler(params, context) {
12132
13745
  checkHealthInputSchema.parse(params);
12133
- log48.info({ routerId: context.routerId }, "Checking router health");
13746
+ log49.info({ routerId: context.routerId }, "Checking router health");
12134
13747
  const startMs = Date.now();
12135
13748
  try {
12136
- const resources = await context.routerClient.get("system/resource");
12137
- const resource = resources[0];
13749
+ const resource = context.swosClient ? swosResourceRecord(await context.swosClient.get("sys.b")) : (await context.routerClient.get("system/resource"))[0];
12138
13750
  const latencyMs = Date.now() - startMs;
12139
13751
  const result = {
12140
13752
  routerId: context.routerId,
@@ -12192,7 +13804,7 @@ function createFleetTools(baseTools2) {
12192
13804
  }
12193
13805
  });
12194
13806
  }
12195
- log48.info(
13807
+ log49.info(
12196
13808
  { toolName: parsed.toolName, concurrency: parsed.concurrency },
12197
13809
  "bulk_execute invoked"
12198
13810
  );
@@ -12239,7 +13851,7 @@ function createFleetTools(baseTools2) {
12239
13851
  try {
12240
13852
  resolved.push(context.routerRegistry.getRouter(id));
12241
13853
  } catch {
12242
- log48.warn({ routerId: id }, "bulk_execute: router not found");
13854
+ log49.warn({ routerId: id }, "bulk_execute: router not found");
12243
13855
  preErrors.push({
12244
13856
  routerId: id,
12245
13857
  status: "error",
@@ -12309,6 +13921,7 @@ function createFleetTools(baseTools2) {
12309
13921
  let journalId;
12310
13922
  try {
12311
13923
  checkAuthz(context.identity, parsed.toolName, router.id);
13924
+ assertPlatformMatch(targetTool, router);
12312
13925
  assertMaintenanceWindow(targetTool.annotations.destructiveHint, router, router.id);
12313
13926
  const routerContext = buildRouterToolContext({
12314
13927
  routerConfig: router,
@@ -12320,18 +13933,18 @@ function createFleetTools(baseTools2) {
12320
13933
  });
12321
13934
  const toolParams = { ...parsed.params, routerId: router.id };
12322
13935
  const snapshotIds = [];
12323
- if (isWrite && targetTool.snapshotPaths && targetTool.snapshotPaths.length > 0) {
12324
- for (const path of targetTool.snapshotPaths) {
13936
+ if (isWrite) {
13937
+ for (const path of snapshotPathsFor(targetTool, toolParams)) {
12325
13938
  try {
12326
13939
  const meta = await takeSnapshot(
12327
- routerContext.routerClient,
13940
+ routerContext.deviceClient,
12328
13941
  router.id,
12329
13942
  path,
12330
13943
  snapshotDir
12331
13944
  );
12332
13945
  snapshotIds.push(meta.id);
12333
13946
  } catch (err) {
12334
- log48.warn(
13947
+ log49.warn(
12335
13948
  { err, path, routerId: router.id },
12336
13949
  "bulk_execute snapshot failed \u2014 proceeding without snapshot"
12337
13950
  );
@@ -12472,7 +14085,8 @@ var baseTools = [
12472
14085
  ...logTools,
12473
14086
  ...networkTestTools,
12474
14087
  ...containerConfigTools,
12475
- ...interfaceListTools
14088
+ ...interfaceListTools,
14089
+ ...swosTools
12476
14090
  ];
12477
14091
  var allTools = [
12478
14092
  ...baseTools,
@@ -12481,12 +14095,12 @@ var allTools = [
12481
14095
  ];
12482
14096
 
12483
14097
  // src/mcp/tool-registry.ts
12484
- var log49 = createLogger("tool-registry");
14098
+ var log50 = createLogger("tool-registry");
12485
14099
  function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
12486
14100
  const deps = { registry, pool, circuitBreakers, config, identityRegistry };
12487
14101
  for (const tool of allTools) {
12488
14102
  const registrationSchema = tool.annotations.destructiveHint ? tool.inputSchema.extend({
12489
- confirmationToken: z47.string().optional().describe(
14103
+ confirmationToken: z48.string().optional().describe(
12490
14104
  "Token from a prior APPROVAL_REQUIRED response. Re-submit the identical call with this token to confirm the destructive action."
12491
14105
  )
12492
14106
  }) : tool.inputSchema;
@@ -12500,59 +14114,93 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
12500
14114
  },
12501
14115
  (args) => executeToolCall(tool, args, deps)
12502
14116
  );
12503
- log49.debug({ tool: tool.name }, "Registered tool");
14117
+ log50.debug({ tool: tool.name }, "Registered tool");
12504
14118
  }
12505
- log49.info({ count: allTools.length }, "All tools registered");
14119
+ log50.info({ count: allTools.length }, "All tools registered");
12506
14120
  }
12507
14121
 
12508
14122
  // src/config/router-registry.ts
12509
- import { readFileSync as readFileSync2, existsSync } from "fs";
14123
+ import { readFileSync as readFileSync3, existsSync } from "fs";
12510
14124
  import { parse } from "yaml";
12511
- import { z as z48 } from "zod";
12512
- var log50 = createLogger("router-registry");
12513
- var RouterConfigSchema = z48.object({
12514
- host: z48.string().min(1, "host is required"),
12515
- port: z48.number().int().min(1).max(65535),
12516
- tls: z48.object({
12517
- enabled: z48.boolean(),
12518
- rejectUnauthorized: z48.boolean(),
12519
- ca: z48.string().optional(),
12520
- fingerprint: z48.string().optional()
12521
- }).strict(),
12522
- credentials: z48.object({
12523
- source: z48.enum(["env", "vault"]),
12524
- envPrefix: z48.string().optional(),
12525
- vaultPath: z48.string().optional()
14125
+ import { z as z49 } from "zod";
14126
+ var log51 = createLogger("router-registry");
14127
+ var DEVICE_TYPE_ALIASES = {
14128
+ routeros: "routeros",
14129
+ swos: "swos",
14130
+ "swos-lite": "swos"
14131
+ };
14132
+ var RouterConfigSchema = z49.object({
14133
+ host: z49.string().min(1, "host is required"),
14134
+ port: z49.number().int().min(1).max(65535),
14135
+ deviceType: z49.enum(Object.keys(DEVICE_TYPE_ALIASES)).optional(),
14136
+ tls: z49.object({
14137
+ enabled: z49.boolean(),
14138
+ rejectUnauthorized: z49.boolean(),
14139
+ ca: z49.string().optional(),
14140
+ fingerprint: z49.string().optional()
14141
+ }).strict().optional(),
14142
+ credentials: z49.object({
14143
+ source: z49.enum(["env", "vault"]),
14144
+ envPrefix: z49.string().optional(),
14145
+ vaultPath: z49.string().optional()
12526
14146
  }).strict(),
12527
- tags: z48.array(z48.string()).default([]),
12528
- rosVersion: z48.string().min(1),
12529
- sshPort: z48.number().int().min(1).max(65535).optional(),
12530
- sshFingerprint: z48.string().optional(),
12531
- cmdAllow: z48.array(z48.string()).optional(),
12532
- cmdDeny: z48.array(z48.string()).optional(),
12533
- maintenanceWindows: z48.array(
12534
- z48.object({
12535
- days: z48.array(
12536
- z48.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
14147
+ tags: z49.array(z49.string()).default([]),
14148
+ rosVersion: z49.string().min(1).optional(),
14149
+ sshPort: z49.number().int().min(1).max(65535).optional(),
14150
+ sshFingerprint: z49.string().optional(),
14151
+ cmdAllow: z49.array(z49.string()).optional(),
14152
+ cmdDeny: z49.array(z49.string()).optional(),
14153
+ maintenanceWindows: z49.array(
14154
+ z49.object({
14155
+ days: z49.array(
14156
+ z49.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
12537
14157
  ),
12538
- startTime: z48.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
12539
- endTime: z48.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
12540
- timezone: z48.string().min(1)
14158
+ startTime: z49.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
14159
+ endTime: z49.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
14160
+ timezone: z49.string().min(1)
12541
14161
  }).strict()
12542
14162
  ).optional()
12543
- }).strict();
12544
- var ConfigFileSchema = z48.object({
12545
- routers: z48.record(z48.string(), RouterConfigSchema)
14163
+ }).strict().superRefine((config, ctx) => {
14164
+ if (deviceTypeOf(config.deviceType) === "routeros") {
14165
+ if (!config.tls) {
14166
+ ctx.addIssue({
14167
+ code: z49.ZodIssueCode.custom,
14168
+ path: ["tls"],
14169
+ message: "tls is required for RouterOS devices"
14170
+ });
14171
+ }
14172
+ if (!config.rosVersion) {
14173
+ ctx.addIssue({
14174
+ code: z49.ZodIssueCode.custom,
14175
+ path: ["rosVersion"],
14176
+ message: "rosVersion is required for RouterOS devices"
14177
+ });
14178
+ }
14179
+ return;
14180
+ }
14181
+ if (config.tls?.enabled) {
14182
+ ctx.addIssue({
14183
+ code: z49.ZodIssueCode.custom,
14184
+ path: ["tls", "enabled"],
14185
+ message: "SwOS devices speak plain HTTP only; tls.enabled must be false"
14186
+ });
14187
+ }
14188
+ });
14189
+ function deviceTypeOf(configured) {
14190
+ return configured ? DEVICE_TYPE_ALIASES[configured] : "routeros";
14191
+ }
14192
+ var ConfigFileSchema = z49.object({
14193
+ routers: z49.record(z49.string(), RouterConfigSchema)
12546
14194
  }).strict();
12547
14195
  var RouterRegistry = class {
12548
14196
  routers;
12549
14197
  constructor(configPath) {
12550
14198
  this.routers = /* @__PURE__ */ new Map();
12551
14199
  if (!existsSync(configPath)) {
12552
- log50.warn({ configPath }, "Router config file not found; starting with empty registry");
14200
+ log51.warn({ configPath }, "Router config file not found; starting with empty registry");
12553
14201
  return;
12554
14202
  }
12555
- const raw = readFileSync2(configPath, "utf-8");
14203
+ const raw = readFileSync3(configPath, "utf-8");
12556
14204
  const parsed = parse(raw);
12557
14205
  const result = ConfigFileSchema.safeParse(parsed);
12558
14206
  if (!result.success) {
@@ -12561,16 +14209,23 @@ var RouterRegistry = class {
12561
14209
  ${issues}`);
12562
14210
  }
12563
14211
  for (const [id, config] of Object.entries(result.data.routers)) {
12564
- const routerConfig = { ...config, id };
14212
+ const deviceType = deviceTypeOf(config.deviceType);
14213
+ const routerConfig = {
14214
+ ...config,
14215
+ id,
14216
+ deviceType,
14217
+ tls: config.tls ?? { enabled: false, rejectUnauthorized: true },
14218
+ rosVersion: config.rosVersion ?? "swos"
14219
+ };
12565
14220
  if (routerConfig.tls.enabled && !routerConfig.tls.rejectUnauthorized) {
12566
- log50.warn(
14221
+ log51.warn(
12567
14222
  { routerId: id },
12568
14223
  "TLS certificate validation is DISABLED (rejectUnauthorized=false). Set tls.fingerprint to pin the server certificate instead."
12569
14224
  );
12570
14225
  }
12571
14226
  this.routers.set(id, routerConfig);
12572
14227
  }
12573
- log50.info({ count: this.routers.size }, "Loaded routers from config");
14228
+ log51.info({ count: this.routers.size }, "Loaded routers from config");
12574
14229
  }
12575
14230
  getRouter(id) {
12576
14231
  const router = this.routers.get(id);
@@ -12598,219 +14253,20 @@ ${issues}`);
12598
14253
  };
12599
14254
 
12600
14255
  // src/adapter/connection-pool.ts
12601
- import { createHash as createHash5 } from "crypto";
12602
-
12603
- // src/adapter/rest-client.ts
12604
- import { request, Agent as Agent2 } from "undici";
12605
-
12606
- // src/adapter/tls-manager.ts
12607
- import { readFileSync as readFileSync3 } from "fs";
12608
- import { buildConnector } from "undici";
12609
- function makePinnedConnector(inner, expectedFingerprint) {
12610
- const expected = expectedFingerprint.replace(/:/g, "").toLowerCase();
12611
- return (opts, cb) => {
12612
- inner(opts, (err, socket) => {
12613
- if (err || !socket) {
12614
- cb(err ?? new Error("TLS connection failed"), null);
12615
- return;
12616
- }
12617
- const peer = socket;
12618
- const cert = typeof peer.getPeerCertificate === "function" ? peer.getPeerCertificate() : void 0;
12619
- const actual = (cert?.fingerprint256 ?? "").replace(/:/g, "").toLowerCase();
12620
- if (actual !== expected) {
12621
- socket.destroy();
12622
- cb(
12623
- new Error(
12624
- `TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual || "none"}`
12625
- ),
12626
- null
12627
- );
12628
- return;
12629
- }
12630
- cb(null, socket);
12631
- });
12632
- };
12633
- }
12634
- function buildAgentOptions(tls) {
12635
- if (!tls.enabled) {
12636
- return {};
12637
- }
12638
- const connectOptions = {
12639
- rejectUnauthorized: tls.rejectUnauthorized
12640
- };
12641
- if (tls.ca) {
12642
- connectOptions.ca = readFileSync3(tls.ca, "utf-8");
12643
- }
12644
- if (tls.fingerprint) {
12645
- const inner = buildConnector(connectOptions);
12646
- return { connect: makePinnedConnector(inner, tls.fingerprint) };
12647
- }
12648
- return {
12649
- connect: connectOptions
12650
- };
12651
- }
12652
-
12653
- // src/adapter/query-builder.ts
12654
- function buildListQuery(options) {
12655
- if (!options) {
12656
- return { method: "GET" };
12657
- }
12658
- const hasFilter = options.filter && Object.keys(options.filter).length > 0;
12659
- const hasProplist = options.proplist && options.proplist.length > 0;
12660
- if (hasProplist) {
12661
- const body = {};
12662
- if (hasProplist) {
12663
- body[".proplist"] = options.proplist.join(",");
12664
- }
12665
- if (hasFilter) {
12666
- const queryParts = [];
12667
- for (const [key2, value] of Object.entries(options.filter)) {
12668
- queryParts.push(`${key2}=${value}`);
12669
- }
12670
- body[".query"] = queryParts;
12671
- }
12672
- return { method: "POST", body };
12673
- }
12674
- if (hasFilter) {
12675
- const queryParams = {};
12676
- for (const [key2, value] of Object.entries(options.filter)) {
12677
- queryParams[key2] = value;
12678
- }
12679
- return { method: "GET", queryParams };
12680
- }
12681
- return { method: "GET" };
12682
- }
12683
- function applyPagination(items, limit2, offset2) {
12684
- const total = items.length;
12685
- const start = offset2 ?? 0;
12686
- const sliced = limit2 !== void 0 ? items.slice(start, start + limit2) : items.slice(start);
12687
- return {
12688
- items: sliced,
12689
- total,
12690
- hasMore: start + sliced.length < total
12691
- };
14256
+ import { createHash as createHash6 } from "crypto";
14257
+ function credentialHash(credentials) {
14258
+ return createHash6("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
12692
14259
  }
12693
-
12694
- // src/adapter/rest-client.ts
12695
- var HttpError = class extends Error {
12696
- statusCode;
12697
- responseBody;
12698
- constructor(statusCode, responseBody) {
12699
- super(`HTTP ${statusCode}: ${responseBody}`);
12700
- this.name = "HttpError";
12701
- this.statusCode = statusCode;
12702
- this.responseBody = responseBody;
12703
- }
12704
- };
12705
- var REQUEST_TIMEOUT_MS = 3e4;
12706
- var RouterOSRestClient = class {
12707
- baseUrl;
12708
- authHeader;
12709
- agent;
12710
- constructor(config, credentials) {
12711
- const scheme = config.tls.enabled ? "https" : "http";
12712
- this.baseUrl = `${scheme}://${config.host}:${config.port}/rest`;
12713
- this.authHeader = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64");
12714
- this.agent = new Agent2(buildAgentOptions(config.tls));
12715
- }
12716
- // ---------- public CRUD methods ----------
12717
- /**
12718
- * List resources at the given path.
12719
- *
12720
- * Applies filtering via GET query params or POST body depending on the
12721
- * query complexity, then runs client-side pagination.
12722
- */
12723
- async get(path, options) {
12724
- const query = buildListQuery(options);
12725
- let raw;
12726
- if (query.method === "POST") {
12727
- raw = await this.doRequest("POST", `${this.baseUrl}/${path}`, query.body);
12728
- } else {
12729
- const qs = query.queryParams ? "?" + new URLSearchParams(query.queryParams).toString() : "";
12730
- raw = await this.doRequest("GET", `${this.baseUrl}/${path}${qs}`);
12731
- }
12732
- const rawArray = Array.isArray(raw) ? raw : [raw];
12733
- const parsed = parseRecords(rawArray);
12734
- if (options?.limit !== void 0 || options?.offset !== void 0) {
12735
- const page = applyPagination(parsed, options?.limit, options?.offset);
12736
- return page.items;
12737
- }
12738
- return parsed;
12739
- }
12740
- /** Fetch a single resource by its RouterOS `.id`. */
12741
- async getOne(path, id) {
12742
- const raw = await this.doRequest("GET", `${this.baseUrl}/${path}/${id}`);
12743
- return parseRecord(raw);
12744
- }
12745
- /** Create a new resource (PUT). Returns the created record including `.id`. */
12746
- async create(path, data) {
12747
- const raw = await this.doRequest("PUT", `${this.baseUrl}/${path}`, data);
12748
- return raw;
12749
- }
12750
- /** Update an existing resource by `.id` (PATCH). */
12751
- async update(path, id, data) {
12752
- await this.doRequest("PATCH", `${this.baseUrl}/${path}/${id}`, data);
12753
- }
12754
- /** Remove a resource by `.id` (DELETE). */
12755
- async remove(path, id) {
12756
- await this.doRequest("DELETE", `${this.baseUrl}/${path}/${id}`);
12757
- }
12758
- /** Execute a command (POST), e.g. `/ip/firewall/filter/print`. */
12759
- async execute(path, data) {
12760
- const result = await this.doRequest("POST", `${this.baseUrl}/${path}`, data);
12761
- return result;
12762
- }
12763
- // ---------- lifecycle ----------
12764
- /** Close the underlying HTTP agent and free resources. */
12765
- close() {
12766
- this.agent.close();
12767
- }
12768
- // ---------- internal ----------
12769
- /**
12770
- * Core HTTP request method.
12771
- *
12772
- * - Sends JSON body when provided.
12773
- * - Attaches Basic Auth and content-type headers.
12774
- * - Throws `HttpError` on non-2xx status codes.
12775
- * - Returns parsed JSON (or `undefined` for empty bodies).
12776
- */
12777
- async doRequest(method, url, body) {
12778
- const headers = {
12779
- Accept: "application/json",
12780
- Authorization: this.authHeader
12781
- };
12782
- let requestBody;
12783
- if (body !== void 0) {
12784
- headers["Content-Type"] = "application/json";
12785
- requestBody = JSON.stringify(body);
12786
- }
12787
- const response = await request(url, {
12788
- method,
12789
- headers,
12790
- body: requestBody,
12791
- dispatcher: this.agent,
12792
- bodyTimeout: REQUEST_TIMEOUT_MS,
12793
- headersTimeout: REQUEST_TIMEOUT_MS
12794
- });
12795
- const text = await response.body.text();
12796
- if (response.statusCode < 200 || response.statusCode >= 300) {
12797
- throw new HttpError(response.statusCode, text);
12798
- }
12799
- if (!text || text.length === 0) {
12800
- return void 0;
12801
- }
12802
- return JSON.parse(text);
14260
+ function createDeviceClient(config, credentials) {
14261
+ if (config.deviceType === "swos") {
14262
+ return new SwosClient(config.host, config.port, credentials);
12803
14263
  }
12804
- };
12805
-
12806
- // src/adapter/connection-pool.ts
12807
- function credentialHash(credentials) {
12808
- return createHash5("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
14264
+ return new RouterOSRestClient(config, credentials);
12809
14265
  }
12810
14266
  var ConnectionPool = class {
12811
14267
  clients = /* @__PURE__ */ new Map();
12812
14268
  /**
12813
- * Get an existing client for the given router or create a new one. Clients are
14269
+ * Get an existing client for the given device or create a new one. Clients are
12814
14270
  * keyed by `config.id`, but a client whose credentials no longer match the
12815
14271
  * request is closed and rebuilt so rotated credentials take effect.
12816
14272
  */
@@ -12823,7 +14279,7 @@ var ConnectionPool = class {
12823
14279
  }
12824
14280
  existing.client.close();
12825
14281
  }
12826
- const client = new RouterOSRestClient(config, credentials);
14282
+ const client = createDeviceClient(config, credentials);
12827
14283
  this.clients.set(config.id, { client, credHash });
12828
14284
  return client;
12829
14285
  }
@@ -12861,16 +14317,17 @@ var ConnectionPool = class {
12861
14317
  };
12862
14318
 
12863
14319
  // src/version.ts
12864
- var VERSION = "1.8.0";
14320
+ var VERSION = "1.9.0";
12865
14321
 
12866
14322
  // src/mcp/server.ts
12867
14323
  var SERVER_INSTRUCTIONS = [
12868
- "MikroMCP manages MikroTik RouterOS devices.",
14324
+ "MikroMCP manages MikroTik RouterOS routers and SwOS switches.",
12869
14325
  "Reads are safe; treat writes as consequential \u2014 preview a write with dryRun:true and review the diff before applying.",
12870
14326
  "Destructive operations may require a two-step confirmation token.",
12871
14327
  "Writes are idempotent: already_exists and no_change are success, not errors.",
12872
14328
  "Prefer dedicated tools over run_command.",
12873
14329
  "routerId is optional when a default router is configured.",
14330
+ "Check list_routers first: swos devices take the swos_* tools, RouterOS devices take everything else.",
12874
14331
  "For the full operating playbook, install the MikroMCP usage skill."
12875
14332
  ].join(" ");
12876
14333
  function createServerFactory(config, identityRegistry) {
@@ -12903,7 +14360,7 @@ import { randomUUID } from "crypto";
12903
14360
  import { createServer } from "http";
12904
14361
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12905
14362
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
12906
- var log51 = createLogger("transport-http");
14363
+ var log52 = createLogger("transport-http");
12907
14364
  var SESSION_TTL_MS = 30 * 6e4;
12908
14365
  var BodyTooLargeError = class extends Error {
12909
14366
  constructor() {
@@ -12988,7 +14445,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
12988
14445
  if (now - session.lastSeenMs > SESSION_TTL_MS) {
12989
14446
  streamableSessions.delete(id);
12990
14447
  session.transport.close();
12991
- log51.info({ sessionId: id }, "Streamable HTTP session expired (idle TTL)");
14448
+ log52.info({ sessionId: id }, "Streamable HTTP session expired (idle TTL)");
12992
14449
  }
12993
14450
  }
12994
14451
  }, 6e4);
@@ -13023,7 +14480,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
13023
14480
  return;
13024
14481
  }
13025
14482
  if (!checkRateLimit(ip)) {
13026
- log51.warn({ ip }, "Rate limit exceeded");
14483
+ log52.warn({ ip }, "Rate limit exceeded");
13027
14484
  res.writeHead(429, { "Content-Type": "application/json" });
13028
14485
  res.end(JSON.stringify({ error: "Too many requests. Retry after 60 seconds." }));
13029
14486
  return;
@@ -13068,7 +14525,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
13068
14525
  }
13069
14526
  return;
13070
14527
  }
13071
- log51.error({ err }, "HTTP transport error");
14528
+ log52.error({ err }, "HTTP transport error");
13072
14529
  if (!res.headersSent) {
13073
14530
  res.writeHead(500, { "Content-Type": "application/json" });
13074
14531
  res.end(JSON.stringify({ error: "Internal server error" }));
@@ -13081,7 +14538,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
13081
14538
  });
13082
14539
  await new Promise((resolve, reject) => {
13083
14540
  httpServer.listen(port, bindHost, () => {
13084
- log51.info(
14541
+ log52.info(
13085
14542
  { port, bindHost },
13086
14543
  "HTTP transport listening (POST /mcp for Streamable HTTP, GET /sse for legacy SSE)"
13087
14544
  );
@@ -13114,13 +14571,13 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
13114
14571
  sessionIdGenerator: () => randomUUID(),
13115
14572
  onsessioninitialized: (id) => {
13116
14573
  streamableSessions.set(id, { transport: transport2, identityId, lastSeenMs: Date.now() });
13117
- log51.info({ sessionId: id, identityId }, "Streamable HTTP session created");
14574
+ log52.info({ sessionId: id, identityId }, "Streamable HTTP session created");
13118
14575
  }
13119
14576
  });
13120
14577
  transport2.onclose = () => {
13121
14578
  if (transport2.sessionId) {
13122
14579
  streamableSessions.delete(transport2.sessionId);
13123
- log51.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
14580
+ log52.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
13124
14581
  }
13125
14582
  };
13126
14583
  await makeServer().connect(transport2);
@@ -13137,7 +14594,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
13137
14594
  sseTransport.onclose = () => {
13138
14595
  sseTransports.delete(sseTransport.sessionId);
13139
14596
  };
13140
- log51.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
14597
+ log52.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
13141
14598
  await sseTransport.start();
13142
14599
  return;
13143
14600
  }
@@ -13157,19 +14614,19 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
13157
14614
 
13158
14615
  // src/config/identity-registry.ts
13159
14616
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
13160
- import { createHash as createHash6 } from "crypto";
14617
+ import { createHash as createHash7 } from "crypto";
13161
14618
  import { parse as parse2 } from "yaml";
13162
- import { z as z49 } from "zod";
14619
+ import { z as z50 } from "zod";
13163
14620
  import bcrypt from "bcryptjs";
13164
- var log52 = createLogger("identity-registry");
13165
- var IdentityConfigSchema = z49.object({
13166
- token: z49.string().min(1, "token hash is required"),
13167
- role: z49.enum(["readonly", "operator", "admin", "superadmin"]),
13168
- allowedRouters: z49.array(z49.string()).default([]),
13169
- allowedToolPatterns: z49.array(z49.string()).default([])
14621
+ var log53 = createLogger("identity-registry");
14622
+ var IdentityConfigSchema = z50.object({
14623
+ token: z50.string().min(1, "token hash is required"),
14624
+ role: z50.enum(["readonly", "operator", "admin", "superadmin"]),
14625
+ allowedRouters: z50.array(z50.string()).default([]),
14626
+ allowedToolPatterns: z50.array(z50.string()).default([])
13170
14627
  }).strict();
13171
- var ConfigFileSchema2 = z49.object({
13172
- identities: z49.record(z49.string(), IdentityConfigSchema)
14628
+ var ConfigFileSchema2 = z50.object({
14629
+ identities: z50.record(z50.string(), IdentityConfigSchema)
13173
14630
  }).strict();
13174
14631
  var IdentityRegistry = class {
13175
14632
  identities = [];
@@ -13178,7 +14635,7 @@ var IdentityRegistry = class {
13178
14635
  tokenCache = /* @__PURE__ */ new Map();
13179
14636
  constructor(configPath) {
13180
14637
  if (!existsSync2(configPath)) {
13181
- log52.warn(
14638
+ log53.warn(
13182
14639
  { configPath },
13183
14640
  "Identity config file not found; starting with empty registry (stdio callers do not require it)"
13184
14641
  );
@@ -13201,7 +14658,7 @@ ${issues}`);
13201
14658
  allowedToolPatterns: config.allowedToolPatterns
13202
14659
  });
13203
14660
  }
13204
- log52.info({ count: this.identities.length }, "Loaded identities from config");
14661
+ log53.info({ count: this.identities.length }, "Loaded identities from config");
13205
14662
  }
13206
14663
  getIdentities() {
13207
14664
  return this.identities.map(({ id, role, allowedRouters, allowedToolPatterns }) => ({
@@ -13212,7 +14669,7 @@ ${issues}`);
13212
14669
  }));
13213
14670
  }
13214
14671
  async findIdentityByToken(token) {
13215
- const cacheKey = createHash6("sha256").update(token).digest("hex");
14672
+ const cacheKey = createHash7("sha256").update(token).digest("hex");
13216
14673
  const toIdentity = (stored) => ({
13217
14674
  id: stored.id,
13218
14675
  role: stored.role,
@@ -13239,7 +14696,7 @@ ${issues}`);
13239
14696
  // src/domain/snapshot/retention.ts
13240
14697
  import { readdir, stat, unlink } from "fs/promises";
13241
14698
  import { join as join3 } from "path";
13242
- var log53 = createLogger("snapshot-retention");
14699
+ var log54 = createLogger("snapshot-retention");
13243
14700
  async function pruneSnapshots(snapshotDir, maxAgeDays) {
13244
14701
  const cutoffMs = Date.now() - maxAgeDays * 864e5;
13245
14702
  let removed = 0;
@@ -13266,21 +14723,21 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
13266
14723
  removed++;
13267
14724
  }
13268
14725
  } catch (err) {
13269
- log53.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
14726
+ log54.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
13270
14727
  }
13271
14728
  }
13272
14729
  }
13273
14730
  if (removed > 0) {
13274
- log53.info({ removed, maxAgeDays }, "Pruned expired snapshots");
14731
+ log54.info({ removed, maxAgeDays }, "Pruned expired snapshots");
13275
14732
  }
13276
14733
  return removed;
13277
14734
  }
13278
14735
 
13279
14736
  // src/cli/serve.ts
13280
- var log54 = createLogger("main");
14737
+ var log55 = createLogger("main");
13281
14738
  async function runServe() {
13282
14739
  const config = loadAppConfig();
13283
- log54.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
14740
+ log55.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
13284
14741
  void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
13285
14742
  const identityRegistry = new IdentityRegistry(config.identitiesPath);
13286
14743
  if (config.transport === "http") {
@@ -13288,7 +14745,7 @@ async function runServe() {
13288
14745
  (i) => i.role === "readonly" || i.role === "operator"
13289
14746
  );
13290
14747
  if (hasLimitedRoles && !config.confirmationSecret) {
13291
- log54.error(
14748
+ log55.error(
13292
14749
  "MIKROMCP_CONFIRMATION_SECRET is required in HTTP mode when identities with role readonly or operator are configured"
13293
14750
  );
13294
14751
  process.exit(1);
@@ -13298,7 +14755,7 @@ async function runServe() {
13298
14755
  if (config.transport === "stdio") {
13299
14756
  const stdioIdentity = getStdioIdentity(config.stdioIdentity, identityRegistry);
13300
14757
  await withIdentity(stdioIdentity, () => connectStdio(makeServer()));
13301
- log54.info("MikroMCP server running via stdio");
14758
+ log55.info("MikroMCP server running via stdio");
13302
14759
  } else if (config.transport === "http") {
13303
14760
  await connectHttp(
13304
14761
  makeServer,
@@ -13310,13 +14767,13 @@ async function runServe() {
13310
14767
  },
13311
14768
  identityRegistry
13312
14769
  );
13313
- log54.info(
14770
+ log55.info(
13314
14771
  { port: config.port, bindHost: config.bindHost },
13315
14772
  "MikroMCP server running via HTTP/SSE"
13316
14773
  );
13317
14774
  }
13318
14775
  const shutdown = () => {
13319
- log54.info("Shutting down MikroMCP server");
14776
+ log55.info("Shutting down MikroMCP server");
13320
14777
  pool.closeAll();
13321
14778
  process.exit(0);
13322
14779
  };
@@ -13329,7 +14786,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
13329
14786
  import { homedir as homedir2 } from "os";
13330
14787
  import * as net from "net";
13331
14788
  import chalk from "chalk";
13332
- var localVersion = "1.8.0";
14789
+ var localVersion = "1.9.0";
13333
14790
  var results = [];
13334
14791
  function ok(msg) {
13335
14792
  console.log(chalk.green("\u2705 ") + msg);
@@ -13634,7 +15091,7 @@ async function runDoctor() {
13634
15091
 
13635
15092
  // src/cli/init.ts
13636
15093
  import { input, confirm, select } from "@inquirer/prompts";
13637
- import { randomBytes } from "crypto";
15094
+ import { randomBytes as randomBytes2 } from "crypto";
13638
15095
  import {
13639
15096
  existsSync as existsSync4,
13640
15097
  readFileSync as readFileSync6,
@@ -13789,7 +15246,7 @@ async function collectIdentityInfo() {
13789
15246
  message: "Allowed tool patterns (comma-separated or * for all):",
13790
15247
  default: "*"
13791
15248
  });
13792
- const rawToken = randomBytes(32).toString("hex");
15249
+ const rawToken = randomBytes2(32).toString("hex");
13793
15250
  console.log(chalk2.green(`
13794
15251
  Generated token: ${chalk2.bold(rawToken)}`));
13795
15252
  console.log(chalk2.yellow(" \u26A0 Save this token \u2014 it will NOT be shown again.\n"));
@@ -13883,7 +15340,7 @@ function writeDotEnv(data) {
13883
15340
  const userKey = `${data.envPrefix}_USER`;
13884
15341
  const passKey = `${data.envPrefix}_PASS`;
13885
15342
  const auditLogPath = defaultAuditLogPath();
13886
- const confirmationSecret = data.transport === "http" ? randomBytes(32).toString("hex") : "";
15343
+ const confirmationSecret = data.transport === "http" ? randomBytes2(32).toString("hex") : "";
13887
15344
  const lines = [
13888
15345
  "# \u2500\u2500 Router credentials \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
13889
15346
  `${userKey}=${data.routerUser}`,
@@ -13952,11 +15409,11 @@ async function runInit() {
13952
15409
  const configDir = mikromcpDir2();
13953
15410
  const envPath = join4(configDir, ".env");
13954
15411
  const existingFiles = [".env", "routers.yaml", "identities.yaml"].filter(
13955
- (f) => existsSync4(join4(configDir, f))
15412
+ (f2) => existsSync4(join4(configDir, f2))
13956
15413
  );
13957
15414
  if (existingFiles.length > 0) {
13958
15415
  console.log(chalk2.yellow(` \u26A0 Found existing files in ${configDir}:`));
13959
- for (const f of existingFiles) console.log(chalk2.yellow(` ${f}`));
15416
+ for (const f2 of existingFiles) console.log(chalk2.yellow(` ${f2}`));
13960
15417
  console.log();
13961
15418
  const proceed = await confirm({
13962
15419
  message: "Continue? (existing files will be overwritten or merged)",
@@ -14041,7 +15498,7 @@ async function runInit() {
14041
15498
  }
14042
15499
 
14043
15500
  // src/cli/index.ts
14044
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.8.0");
15501
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.9.0");
14045
15502
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
14046
15503
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
14047
15504
  program.command("init").description("Interactive setup wizard").action(runInit);