mikromcp 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/main.js +527 -182
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  [![CI](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
14
14
  [![Release](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
15
- [![Version](https://img.shields.io/badge/version-v1.12.0-2DD4BF)](package.json)
15
+ [![Version](https://img.shields.io/badge/version-v1.13.0-2DD4BF)](package.json)
16
16
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
17
17
  [![Node.js >= 22](https://img.shields.io/badge/node-%3E%3D22-339933)](package.json)
18
18
  [![RouterOS 7.x](https://img.shields.io/badge/RouterOS-7.x-293239)](https://help.mikrotik.com/docs/display/ROS/REST+API)
package/dist/main.js CHANGED
@@ -137,6 +137,7 @@ var routerId = z.string().optional().describe("Router ID; omit to use the defaul
137
137
  var dryRun = z.boolean().default(false).describe("Preview changes without applying.");
138
138
  var offset = z.number().int().min(0).default(0).describe("Pagination offset.");
139
139
  var limit = z.number().int().min(1).max(500).default(100).describe("Max results to return.");
140
+ var ruleComment = z.string().min(1);
140
141
 
141
142
  // src/domain/errors/error-enricher.ts
142
143
  import { ZodError } from "zod";
@@ -152,6 +153,7 @@ var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
152
153
  ]);
153
154
  var TIMEOUT_CODES = /* @__PURE__ */ new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
154
155
  var SSH_AUTH_LEVEL = "client-authentication";
156
+ var SSH_TIMEOUT_LEVEL = "client-timeout";
155
157
  function defaultRecoverability(category) {
156
158
  switch (category) {
157
159
  case "VALIDATION" /* VALIDATION */:
@@ -279,6 +281,19 @@ function enrichError(error, context) {
279
281
  cause: error
280
282
  });
281
283
  }
284
+ if (typeof raw === "object" && raw !== null && raw.level === SSH_TIMEOUT_LEVEL) {
285
+ return new MikroMCPError({
286
+ category: "ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
287
+ code: "SSH_HANDSHAKE_TIMEOUT",
288
+ message: `The SSH connection to the router timed out: ${rawMessage}`,
289
+ details: buildDetails({ transport: "ssh" }, context),
290
+ recoverability: {
291
+ retryable: false,
292
+ suggestedAction: "Check that the router's SSH service is enabled and reachable from this host on sshPort (default 22): /ip service ssh, its available-from list, and input firewall rules."
293
+ },
294
+ cause: error
295
+ });
296
+ }
282
297
  if (typeof raw === "object" && raw !== null && typeof raw.statusCode === "number") {
283
298
  const statusCode = raw.statusCode;
284
299
  let category2 = categoryFromStatus(statusCode);
@@ -519,8 +534,9 @@ import { z as z3 } from "zod";
519
534
  // src/adapter/response-parser.ts
520
535
  var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
521
536
  var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
537
+ var FREE_TEXT_KEYS = /* @__PURE__ */ new Set(["comment", "text"]);
522
538
  function parseRouterOSValue(key2, value) {
523
- if (key2 === ".id") {
539
+ if (key2 === ".id" || FREE_TEXT_KEYS.has(key2)) {
524
540
  return value;
525
541
  }
526
542
  if (value === "true") return true;
@@ -588,7 +604,7 @@ function compactFields(record, fields) {
588
604
  return parts.join(" ");
589
605
  }
590
606
  function listContent(label, routerId2, page, total, offset2, renderItem) {
591
- const range = total === 0 ? "none" : `${offset2 + 1}-${offset2 + page.length} of ${total}`;
607
+ const range = total === 0 ? "none" : page.length === 0 ? `none of ${total} (offset ${offset2})` : `${offset2 + 1}-${offset2 + page.length} of ${total}`;
592
608
  const header = `${label} on ${routerId2}: ${range}.`;
593
609
  if (page.length === 0) return header;
594
610
  return [header, ...page.map((item) => ` ${renderItem(item)}`)].join("\n");
@@ -1487,27 +1503,92 @@ var ADDRESS_PROPERTIES = /* @__PURE__ */ new Set([
1487
1503
  "to-addresses"
1488
1504
  ]);
1489
1505
  var SET_PROPERTIES = /* @__PURE__ */ new Set(["connection-state", "connection-nat-state"]);
1506
+ var PROTOCOL_NAMES = {
1507
+ "1": "icmp",
1508
+ "2": "igmp",
1509
+ "3": "ggp",
1510
+ "4": "ipencap",
1511
+ "5": "st",
1512
+ "6": "tcp",
1513
+ "8": "egp",
1514
+ "12": "pup",
1515
+ "17": "udp",
1516
+ "20": "hmp",
1517
+ "22": "xns-idp",
1518
+ "27": "rdp",
1519
+ "29": "iso-tp4",
1520
+ "33": "dccp",
1521
+ "36": "xtp",
1522
+ "37": "ddp",
1523
+ "38": "idpr-cmtp",
1524
+ "41": "ipv6-encap",
1525
+ "43": "ipv6-route",
1526
+ "44": "ipv6-frag",
1527
+ "46": "rsvp",
1528
+ "47": "gre",
1529
+ "50": "ipsec-esp",
1530
+ "51": "ipsec-ah",
1531
+ "58": "icmpv6",
1532
+ "59": "ipv6-nonxt",
1533
+ "60": "ipv6-opts",
1534
+ "73": "rspf",
1535
+ "81": "vmtp",
1536
+ "89": "ospf",
1537
+ "94": "ipip",
1538
+ "97": "etherip",
1539
+ "98": "encap",
1540
+ "103": "pim",
1541
+ "112": "vrrp",
1542
+ "115": "l2tp",
1543
+ "132": "sctp",
1544
+ "136": "udp-lite"
1545
+ };
1546
+ function protocolName(value) {
1547
+ return PROTOCOL_NAMES[value] ?? value;
1548
+ }
1549
+ var FLAG_PROPERTIES = /* @__PURE__ */ new Set(["tcp-flags"]);
1490
1550
  function canonical(property, value) {
1551
+ if (property === "protocol") return value === "all" ? "" : protocolName(value);
1491
1552
  if (ADDRESS_PROPERTIES.has(property)) return value.replace(/\/32$/, "");
1492
1553
  if (SET_PROPERTIES.has(property)) {
1493
1554
  const negated = value.startsWith("!");
1494
1555
  const items = new Set((negated ? value.slice(1) : value).split(",").filter(Boolean));
1495
1556
  return (negated ? "!" : "") + [...items].sort().join(",");
1496
1557
  }
1558
+ if (FLAG_PROPERTIES.has(property)) {
1559
+ return [...new Set(value.split(",").filter(Boolean))].sort().join(",");
1560
+ }
1497
1561
  return value;
1498
1562
  }
1499
1563
  function sameRuleValue(property, stored, requested) {
1500
1564
  return canonical(property, normalizeWireValue(stored)) === canonical(property, normalizeWireValue(requested));
1501
1565
  }
1502
-
1503
- // src/domain/tools/firewall-tools.ts
1504
- var log6 = createLogger("firewall-tools");
1505
- function tableToPath(table) {
1506
- return table === "filter" ? "ip/firewall/filter" : "ip/firewall/nat";
1566
+ function ruleActionAddOnly(action, ruleAction, suggestedAction) {
1567
+ return new MikroMCPError({
1568
+ category: "VALIDATION" /* VALIDATION */,
1569
+ code: "RULE_ACTION_ADD_ONLY",
1570
+ message: `ruleAction applies only to add, not to ${action}.`,
1571
+ details: { action, ruleAction },
1572
+ recoverability: { retryable: false, suggestedAction }
1573
+ });
1507
1574
  }
1508
- function sanitizeComment3(comment) {
1509
- if (comment === void 0) return void 0;
1510
- return comment.replace(/[\x00-\x1f\x7f]/g, "");
1575
+
1576
+ // src/domain/tools/rule-comment.ts
1577
+ function ruleCommentKey(comment) {
1578
+ const sanitized = comment.replace(/[\x00-\x1f\x7f]/g, "");
1579
+ if (sanitized === "") {
1580
+ throw new MikroMCPError({
1581
+ category: "VALIDATION" /* VALIDATION */,
1582
+ code: "COMMENT_EMPTY",
1583
+ message: "comment is empty after removing control characters.",
1584
+ details: { comment },
1585
+ recoverability: {
1586
+ retryable: false,
1587
+ suggestedAction: "Use a comment with printable characters."
1588
+ }
1589
+ });
1590
+ }
1591
+ return sanitized;
1511
1592
  }
1512
1593
  async function findRuleByComment(context, path, comment) {
1513
1594
  const results2 = await context.routerClient.get(path, {
@@ -1515,6 +1596,12 @@ async function findRuleByComment(context, path, comment) {
1515
1596
  });
1516
1597
  return results2.length > 0 ? results2[0] : void 0;
1517
1598
  }
1599
+
1600
+ // src/domain/tools/firewall-tools.ts
1601
+ var log6 = createLogger("firewall-tools");
1602
+ function tableToPath(table) {
1603
+ return table === "filter" ? "ip/firewall/filter" : "ip/firewall/nat";
1604
+ }
1518
1605
  var RULE_FIELDS = [
1519
1606
  ["srcAddress", "src-address"],
1520
1607
  ["dstAddress", "dst-address"],
@@ -1536,6 +1623,14 @@ var TO_PORTS_ACTIONS = /* @__PURE__ */ new Set([
1536
1623
  "masquerade"
1537
1624
  ]);
1538
1625
  var CONNECTION_STATE = "(established|related|new|invalid|untracked)";
1626
+ function isPortOrRange(value) {
1627
+ const [start, end = start] = value.split("-").map(Number);
1628
+ return start >= 1 && end <= 65535 && start <= end;
1629
+ }
1630
+ function hasNoRepeats(value) {
1631
+ const items = value.replace(/^!/, "").split(",");
1632
+ return new Set(items).size === items.length;
1633
+ }
1539
1634
  var listFirewallRulesInputSchema = z8.object({
1540
1635
  routerId,
1541
1636
  table: z8.enum(["filter", "nat"]).default("filter").describe("Firewall table to query: filter or nat"),
@@ -1575,7 +1670,7 @@ var listFirewallRulesTool = {
1575
1670
  if (parsed.chain !== void 0) {
1576
1671
  rules = rules.filter((r) => {
1577
1672
  const rec = r;
1578
- return rec.chain === parsed.chain;
1673
+ return sameRuleValue("chain", rec.chain, parsed.chain);
1579
1674
  });
1580
1675
  }
1581
1676
  if (parsed.disabled !== "all") {
@@ -1633,22 +1728,22 @@ var manageFirewallRuleInputSchema = z8.object({
1633
1728
  action: z8.enum(["add", "remove", "disable", "enable"]).describe("Action to perform: add, remove, disable, or enable a firewall rule"),
1634
1729
  chain: z8.string().describe("Firewall chain (e.g. forward, input, output, srcnat, dstnat)"),
1635
1730
  ruleAction: z8.string().describe("RouterOS rule action (e.g. accept, drop, reject, masquerade)"),
1636
- srcAddress: z8.string().optional().describe("Source address or network"),
1637
- dstAddress: z8.string().optional().describe("Destination address or network"),
1731
+ srcAddress: z8.string().min(1).optional().describe("Source address or network"),
1732
+ dstAddress: z8.string().min(1).optional().describe("Destination address or network"),
1638
1733
  protocol: z8.enum(["tcp", "udp", "icmp", "gre", "ospf", "all"]).optional().describe("Protocol to match"),
1639
- srcPort: z8.string().optional().describe("Source port or range"),
1640
- dstPort: z8.string().optional().describe("Destination port or range"),
1641
- inInterface: z8.string().optional().describe("Incoming interface"),
1642
- outInterface: z8.string().optional().describe("Outgoing interface"),
1643
- inInterfaceList: z8.string().optional().describe("Incoming interface list, e.g. WAN or !LAN"),
1644
- outInterfaceList: z8.string().optional().describe("Outgoing interface list, e.g. WAN or !LAN"),
1645
- connectionState: z8.string().regex(new RegExp(`^!?${CONNECTION_STATE}(,${CONNECTION_STATE})*$`)).optional().describe(
1734
+ srcPort: z8.string().min(1).optional().describe("Source port or range"),
1735
+ dstPort: z8.string().min(1).optional().describe("Destination port or range"),
1736
+ inInterface: z8.string().min(1).optional().describe("Incoming interface"),
1737
+ outInterface: z8.string().min(1).optional().describe("Outgoing interface"),
1738
+ inInterfaceList: z8.string().min(1).optional().describe("Incoming interface list, e.g. WAN or !LAN"),
1739
+ outInterfaceList: z8.string().min(1).optional().describe("Outgoing interface list, e.g. WAN or !LAN"),
1740
+ connectionState: z8.string().regex(new RegExp(`^!?${CONNECTION_STATE}(,${CONNECTION_STATE})*$`)).refine(hasNoRepeats, "Each connection state may appear only once").optional().describe(
1646
1741
  "Connection-tracking states, comma-separated, optionally negated: established,related or !invalid"
1647
1742
  ),
1648
1743
  connectionNatState: z8.string().regex(/^!?(srcnat|dstnat|srcnat,dstnat|dstnat,srcnat)$/).optional().describe("Connection NAT state to match, e.g. dstnat or !dstnat"),
1649
- toAddresses: z8.string().optional().describe("NAT target address or range (nat table only, e.g. dst-nat)"),
1650
- toPorts: z8.string().regex(/^\d{1,5}(-\d{1,5})?$/).optional().describe("NAT target port or range (nat table only)"),
1651
- comment: z8.string().max(255).optional().describe("Comment to identify the rule (used as idempotency key)"),
1744
+ toAddresses: z8.string().min(1).optional().describe("NAT target address or range (nat table only, e.g. dst-nat)"),
1745
+ toPorts: z8.string().regex(/^\d{1,5}(-\d{1,5})?$/).refine(isPortOrRange, "Must be a port 1-65535 or an ascending range such as 8000-8100").optional().describe("NAT target port or range (nat table only)"),
1746
+ comment: ruleComment.max(255).optional().describe("Comment to identify the rule (used as idempotency key); omit for none"),
1652
1747
  disabled: z8.boolean().default(false).describe("Whether the rule should be disabled"),
1653
1748
  placeBefore: z8.string().optional().describe("Place the new rule before this rule ID"),
1654
1749
  dryRun: z8.boolean().default(false).describe("If true, validate and return planned changes without applying")
@@ -1656,7 +1751,7 @@ var manageFirewallRuleInputSchema = z8.object({
1656
1751
  var manageFirewallRuleTool = {
1657
1752
  name: "manage_firewall_rule",
1658
1753
  title: "Manage Firewall Rule",
1659
- description: "Add, remove, disable, or enable a firewall rule on a MikroTik router. Matches addresses, ports, protocol, interfaces, interface lists, and connection/NAT state; nat rules take toAddresses/toPorts (e.g. a dst-nat port forward). Uses comment as idempotency key for deduplication and identification. Supports dry-run mode.",
1754
+ description: "Add, remove, disable, or enable a firewall rule on a MikroTik router. Matches addresses, ports, protocol, interfaces, interface lists, and connection/NAT state; nat rules take toAddresses/toPorts (e.g. a dst-nat port forward). Uses comment as idempotency key for deduplication and identification: a repeated add returns already_exists only when the match, NAT targets, and disabled state agree, otherwise CONFLICT (use enable/disable when only the disabled state differs). Supports dry-run mode.",
1660
1755
  inputSchema: manageFirewallRuleInputSchema,
1661
1756
  annotations: {
1662
1757
  readOnlyHint: false,
@@ -1667,7 +1762,6 @@ var manageFirewallRuleTool = {
1667
1762
  snapshotPaths: ["ip/firewall/filter", "ip/firewall/nat"],
1668
1763
  async handler(params, context) {
1669
1764
  const parsed = manageFirewallRuleInputSchema.parse(params);
1670
- const comment = sanitizeComment3(parsed.comment);
1671
1765
  log6.info(
1672
1766
  {
1673
1767
  routerId: context.routerId,
@@ -1679,6 +1773,7 @@ var manageFirewallRuleTool = {
1679
1773
  );
1680
1774
  const path = tableToPath(parsed.table);
1681
1775
  try {
1776
+ const comment = parsed.comment === void 0 ? void 0 : ruleCommentKey(parsed.comment);
1682
1777
  if (parsed.action === "add") {
1683
1778
  const natTargets = [
1684
1779
  ["toAddresses", parsed.toAddresses, TO_ADDRESSES_ACTIONS],
@@ -1703,10 +1798,11 @@ var manageFirewallRuleTool = {
1703
1798
  if (comment !== void 0) {
1704
1799
  const existing = await findRuleByComment(context, path, comment);
1705
1800
  if (existing) {
1706
- const matches = existing.chain === parsed.chain && existing.action === parsed.ruleAction && sameRuleValue("protocol", existing.protocol, wantProtocol) && RULE_FIELDS.every(
1801
+ const sameMatch = sameRuleValue("chain", existing.chain, parsed.chain) && existing.action === parsed.ruleAction && sameRuleValue("protocol", existing.protocol, wantProtocol) && RULE_FIELDS.every(
1707
1802
  ([key2, property]) => sameRuleValue(property, existing[property], parsed[key2])
1708
1803
  );
1709
- if (matches) {
1804
+ const existingDisabled = isTrue(existing.disabled);
1805
+ if (sameMatch && existingDisabled === parsed.disabled) {
1710
1806
  return {
1711
1807
  content: `Firewall ${parsed.table} rule with comment "${comment}" already exists. No changes made.`,
1712
1808
  structuredContent: { action: "already_exists", rule: existing }
@@ -1726,12 +1822,19 @@ var manageFirewallRuleTool = {
1726
1822
  existingDetails[property] = existing[property];
1727
1823
  requestedDetails[property] = parsed[key2];
1728
1824
  }
1825
+ existingDetails.disabled = existingDisabled;
1826
+ requestedDetails.disabled = parsed.disabled;
1827
+ const toggle = parsed.disabled ? "disable" : "enable";
1729
1828
  throw new MikroMCPError({
1730
1829
  category: "CONFLICT" /* CONFLICT */,
1731
1830
  code: "FIREWALL_RULE_CONFLICT",
1732
- message: `Firewall ${parsed.table} rule with comment "${comment}" already exists but with different configuration.`,
1831
+ message: sameMatch ? `Firewall ${parsed.table} rule with comment "${comment}" already exists but is ${existingDisabled ? "disabled" : "enabled"}.` : `Firewall ${parsed.table} rule with comment "${comment}" already exists but with different configuration.`,
1733
1832
  details: { existing: existingDetails, requested: requestedDetails },
1734
- recoverability: {
1833
+ recoverability: sameMatch ? {
1834
+ retryable: false,
1835
+ suggestedAction: `Use action=${toggle} with the same comment to ${toggle} the existing rule.`,
1836
+ alternativeTools: [`manage_firewall_rule with action=${toggle}`]
1837
+ } : {
1735
1838
  retryable: false,
1736
1839
  suggestedAction: "Remove the existing rule first, then re-add with the desired configuration.",
1737
1840
  alternativeTools: ["manage_firewall_rule with action=remove"]
@@ -3536,20 +3639,61 @@ var listDnsTool = {
3536
3639
  var manageDnsInputSchema = z14.object({
3537
3640
  routerId,
3538
3641
  action: z14.enum(["add", "remove"]).describe("Action to perform"),
3539
- name: z14.string().min(1).describe("Hostname for the DNS record (e.g. server.example.com)"),
3642
+ name: z14.string().min(1).describe(
3643
+ "Hostname for the DNS record (e.g. server.example.com); matched case-insensitively"
3644
+ ),
3540
3645
  type: z14.enum(["A", "CNAME", "TXT"]).default("A").describe("DNS record type"),
3541
- address: z14.string().optional().describe("IP address (required for A records)"),
3542
- cname: z14.string().optional().describe("Target hostname (required for CNAME records)"),
3543
- text: z14.string().optional().describe("Text value (required for TXT records)"),
3646
+ address: z14.string().optional().describe(
3647
+ "IP address (required for A records; on remove, picks the record when several share the name)"
3648
+ ),
3649
+ cname: z14.string().optional().describe(
3650
+ "Target hostname (required for CNAME records; on remove, picks the record when several share the name)"
3651
+ ),
3652
+ text: z14.string().optional().describe(
3653
+ "Text value (required for TXT records; on remove, picks the record when several share the name)"
3654
+ ),
3544
3655
  ttl: z14.string().optional().describe("TTL value (e.g. 1d, 00:05:00)"),
3545
3656
  comment: z14.string().max(255).optional().describe("Optional comment"),
3546
3657
  disabled: z14.boolean().default(false).describe("Whether the entry should be disabled"),
3547
3658
  dryRun
3548
3659
  }).strict();
3660
+ var VALUE_FIELD = { A: "address", CNAME: "cname", TXT: "text" };
3661
+ var TIME_UNIT_MS = {
3662
+ w: 6048e5,
3663
+ d: 864e5,
3664
+ h: 36e5,
3665
+ m: 6e4,
3666
+ s: 1e3,
3667
+ ms: 1
3668
+ };
3669
+ function ttlMillis(value) {
3670
+ if (typeof value === "number") return Number.isFinite(value) ? value * 1e3 : void 0;
3671
+ if (typeof value !== "string") return void 0;
3672
+ let rest = value.trim();
3673
+ if (/^\d+$/.test(rest)) return Number(rest) * 1e3;
3674
+ let total = 0;
3675
+ const clock = /(\d+):(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?$/.exec(rest);
3676
+ if (clock) {
3677
+ const [, h, m, s, frac = ""] = clock;
3678
+ total += ((Number(h) * 60 + Number(m)) * 60 + Number(s)) * 1e3 + Number(frac.padEnd(3, "0"));
3679
+ rest = rest.slice(0, clock.index).trim();
3680
+ }
3681
+ if (rest === "") return clock ? total : void 0;
3682
+ if (!/^(?:\d+(?:ms|[wdhms])\s*)+$/.test(rest)) return void 0;
3683
+ for (const [, amount, unit] of rest.matchAll(/(\d+)(ms|[wdhms])/g)) {
3684
+ total += Number(amount) * TIME_UNIT_MS[unit];
3685
+ }
3686
+ return total;
3687
+ }
3688
+ function sameTtl(stored, requested) {
3689
+ const a = ttlMillis(stored);
3690
+ const b = ttlMillis(requested);
3691
+ return a !== void 0 && b !== void 0 ? a === b : normalizeWireValue(stored) === normalizeWireValue(requested);
3692
+ }
3549
3693
  var manageDnsTool = {
3550
3694
  name: "manage_dns_entry",
3551
3695
  title: "Manage DNS Entry",
3552
- description: "Add or remove a static DNS entry. Idempotent by name+type: add returns already_exists if the same record already exists.",
3696
+ description: "Add or remove a static DNS entry, found by name (case-insensitive) and type. add returns already_exists if such a record already has the requested value and disabled state (and ttl/comment, when given), and throws CONFLICT if it differs; add never creates a second record with the same name and type. remove deletes the one matching record; when several share the name (round-robin), pass address/cname/text to pick one.",
3553
3697
  inputSchema: manageDnsInputSchema,
3554
3698
  annotations: {
3555
3699
  readOnlyHint: false,
@@ -3565,42 +3709,77 @@ var manageDnsTool = {
3565
3709
  "Managing DNS entry"
3566
3710
  );
3567
3711
  try {
3568
- const existing = await context.routerClient.get("ip/dns/static", {
3569
- filter: { name: parsed.name, type: parsed.type }
3570
- });
3571
- if (parsed.action === "add") {
3572
- if (existing.length > 0) {
3573
- return {
3574
- content: `DNS entry "${parsed.name}" (${parsed.type}) already exists. No changes made.`,
3575
- structuredContent: { action: "already_exists", entry: existing[0] }
3576
- };
3577
- }
3578
- if (parsed.type === "A" && !parsed.address) {
3712
+ const valueField = VALUE_FIELD[parsed.type];
3713
+ const value = parsed[valueField];
3714
+ const comment = parsed.comment?.replace(/[\x00-\x1f\x7f]/g, "");
3715
+ const valueFields = ["address", "cname", "text"];
3716
+ for (const field of valueFields) {
3717
+ if (field !== valueField && parsed[field] !== void 0) {
3579
3718
  throw new MikroMCPError({
3580
3719
  category: "VALIDATION" /* VALIDATION */,
3581
- code: "DNS_MISSING_ADDRESS",
3582
- message: "address is required for A records.",
3583
- recoverability: { retryable: false, suggestedAction: "Provide the address parameter." }
3584
- });
3585
- }
3586
- if (parsed.type === "CNAME" && !parsed.cname) {
3587
- throw new MikroMCPError({
3588
- category: "VALIDATION" /* VALIDATION */,
3589
- code: "DNS_MISSING_CNAME",
3590
- message: "cname is required for CNAME records.",
3591
- recoverability: { retryable: false, suggestedAction: "Provide the cname parameter." }
3720
+ code: "DNS_FIELD_NOT_APPLICABLE",
3721
+ message: `${field} does not apply to ${parsed.type} records; ${parsed.type} uses ${valueField}.`,
3722
+ details: { field, type: parsed.type },
3723
+ recoverability: {
3724
+ retryable: false,
3725
+ suggestedAction: `Drop ${field}, or set type to the record type it belongs to.`
3726
+ }
3592
3727
  });
3593
3728
  }
3594
- if (parsed.type === "TXT" && !parsed.text) {
3729
+ }
3730
+ if (parsed.action === "add" && !value) {
3731
+ throw new MikroMCPError({
3732
+ category: "VALIDATION" /* VALIDATION */,
3733
+ code: `DNS_MISSING_${valueField.toUpperCase()}`,
3734
+ message: `${valueField} is required for ${parsed.type} records.`,
3735
+ recoverability: {
3736
+ retryable: false,
3737
+ suggestedAction: `Provide the ${valueField} parameter.`
3738
+ }
3739
+ });
3740
+ }
3741
+ const wantName = parsed.name.toLowerCase();
3742
+ const existing = (await context.routerClient.get("ip/dns/static", {
3743
+ filter: { type: parsed.type }
3744
+ })).filter((entry) => String(entry.name ?? "").toLowerCase() === wantName);
3745
+ const detailsOf = (entry) => ({
3746
+ [valueField]: entry[valueField],
3747
+ disabled: entry.disabled,
3748
+ ttl: entry.ttl,
3749
+ comment: entry.comment
3750
+ });
3751
+ const describe = (fields) => compactFields(fields, [valueField, "disabled", "ttl", "comment"]);
3752
+ if (parsed.action === "add") {
3753
+ if (existing.length > 0) {
3754
+ const match = existing.find(
3755
+ (rec2) => normalizeWireValue(rec2[valueField]) === normalizeWireValue(value) && isTrue(rec2.disabled) === parsed.disabled && (parsed.ttl === void 0 || sameTtl(rec2.ttl, parsed.ttl)) && (comment === void 0 || normalizeWireValue(rec2.comment) === normalizeWireValue(comment))
3756
+ );
3757
+ if (match) {
3758
+ return {
3759
+ content: `DNS entry "${parsed.name}" (${parsed.type}) already exists. No changes made.`,
3760
+ structuredContent: { action: "already_exists", entry: match }
3761
+ };
3762
+ }
3763
+ const existingDetails = existing.map(detailsOf);
3764
+ const requestedDetails = {
3765
+ [valueField]: value,
3766
+ disabled: parsed.disabled ? "true" : "false",
3767
+ ttl: parsed.ttl,
3768
+ comment
3769
+ };
3595
3770
  throw new MikroMCPError({
3596
- category: "VALIDATION" /* VALIDATION */,
3597
- code: "DNS_MISSING_TEXT",
3598
- message: "text is required for TXT records.",
3599
- recoverability: { retryable: false, suggestedAction: "Provide the text parameter." }
3771
+ category: "CONFLICT" /* CONFLICT */,
3772
+ code: "DNS_ENTRY_CONFLICT",
3773
+ message: `DNS entry "${parsed.name}" (${parsed.type}) already exists but with different configuration. Existing: ${existingDetails.map(describe).join("; ")}. Requested: ${describe(requestedDetails)}.`,
3774
+ details: { existing: existingDetails, requested: requestedDetails },
3775
+ recoverability: {
3776
+ retryable: false,
3777
+ suggestedAction: existing.length > 1 ? `${existing.length} records share this name. Remove each one with action=remove and its ${valueField}, then re-add. add does not create a second record with the same name and type.` : "Remove the existing entry with action=remove, then re-add it with the desired values. add does not create a second record with the same name and type.",
3778
+ alternativeTools: ["manage_dns_entry with action=remove"]
3779
+ }
3600
3780
  });
3601
3781
  }
3602
3782
  if (parsed.dryRun) {
3603
- const value = parsed.address ?? parsed.cname ?? parsed.text ?? "";
3604
3783
  return {
3605
3784
  content: `Dry run: Would add DNS entry "${parsed.name}" ${parsed.type} \u2192 ${value}.`,
3606
3785
  structuredContent: {
@@ -3615,13 +3794,11 @@ var manageDnsTool = {
3615
3794
  const body = {
3616
3795
  name: parsed.name,
3617
3796
  type: parsed.type,
3618
- disabled: parsed.disabled ? "true" : "false"
3797
+ disabled: parsed.disabled ? "true" : "false",
3798
+ [valueField]: value
3619
3799
  };
3620
- if (parsed.address) body.address = parsed.address;
3621
- if (parsed.cname) body.cname = parsed.cname;
3622
- if (parsed.text) body.text = parsed.text;
3623
3800
  if (parsed.ttl) body.ttl = parsed.ttl;
3624
- if (parsed.comment) body.comment = parsed.comment.replace(/[\x00-\x1f\x7f]/g, "");
3801
+ if (comment) body.comment = comment;
3625
3802
  const created = await context.routerClient.create("ip/dns/static", body);
3626
3803
  log12.info({ name: parsed.name, id: created[".id"] }, "DNS entry created");
3627
3804
  return {
@@ -3629,19 +3806,36 @@ var manageDnsTool = {
3629
3806
  structuredContent: { action: "created", entry: created }
3630
3807
  };
3631
3808
  }
3632
- if (existing.length === 0) {
3809
+ const candidates = value === void 0 ? existing : existing.filter(
3810
+ (rec2) => normalizeWireValue(rec2[valueField]) === normalizeWireValue(value)
3811
+ );
3812
+ if (candidates.length === 0) {
3813
+ const label = value === void 0 ? "" : ` with ${valueField}=${value}`;
3633
3814
  throw new MikroMCPError({
3634
3815
  category: "NOT_FOUND" /* NOT_FOUND */,
3635
3816
  code: "DNS_ENTRY_NOT_FOUND",
3636
- message: `DNS entry "${parsed.name}" (${parsed.type}) not found.`,
3637
- details: { name: parsed.name, type: parsed.type },
3817
+ message: `DNS entry "${parsed.name}" (${parsed.type})${label} not found.` + (existing.length > 0 ? ` Existing: ${existing.map((rec2) => describe(detailsOf(rec2))).join("; ")}.` : ""),
3818
+ details: { name: parsed.name, type: parsed.type, [valueField]: value },
3638
3819
  recoverability: {
3639
3820
  retryable: false,
3640
3821
  suggestedAction: "Verify the entry with list_dns_entries."
3641
3822
  }
3642
3823
  });
3643
3824
  }
3644
- const rec = existing[0];
3825
+ if (candidates.length > 1) {
3826
+ throw new MikroMCPError({
3827
+ category: "VALIDATION" /* VALIDATION */,
3828
+ code: "DNS_ENTRY_AMBIGUOUS",
3829
+ message: `${candidates.length} DNS entries "${parsed.name}" (${parsed.type}) match: ${candidates.map((rec2) => describe(detailsOf(rec2))).join("; ")}.`,
3830
+ details: { name: parsed.name, type: parsed.type, entries: candidates.map(detailsOf) },
3831
+ recoverability: {
3832
+ retryable: false,
3833
+ suggestedAction: `Pass ${valueField} to pick the record to remove.`
3834
+ }
3835
+ });
3836
+ }
3837
+ const rec = candidates[0];
3838
+ const id = rec[".id"];
3645
3839
  if (parsed.dryRun) {
3646
3840
  return {
3647
3841
  content: `Dry run: Would remove DNS entry "${parsed.name}" (${parsed.type}).`,
@@ -3651,7 +3845,7 @@ var manageDnsTool = {
3651
3845
  }
3652
3846
  };
3653
3847
  }
3654
- await context.routerClient.remove("ip/dns/static", rec[".id"]);
3848
+ await context.routerClient.remove("ip/dns/static", id);
3655
3849
  log12.info({ name: parsed.name, type: parsed.type }, "DNS entry removed");
3656
3850
  return {
3657
3851
  content: `Removed DNS entry "${parsed.name}" (${parsed.type}).`,
@@ -3659,7 +3853,7 @@ var manageDnsTool = {
3659
3853
  action: "removed",
3660
3854
  name: parsed.name,
3661
3855
  type: parsed.type,
3662
- id: rec[".id"]
3856
+ id
3663
3857
  }
3664
3858
  };
3665
3859
  } catch (err) {
@@ -3817,11 +4011,105 @@ var dnsTools = [
3817
4011
  import { z as z15 } from "zod";
3818
4012
  var log13 = createLogger("mangle-tools");
3819
4013
  var MANGLE_PATH = "ip/firewall/mangle";
3820
- async function findMangleRuleByComment(context, comment) {
3821
- const results2 = await context.routerClient.get(MANGLE_PATH, {
3822
- filter: { comment }
4014
+ var MANGLE_MATCH_FIELDS = [
4015
+ ["srcAddress", "src-address"],
4016
+ ["dstAddress", "dst-address"],
4017
+ ["srcAddressList", "src-address-list"],
4018
+ ["dstAddressList", "dst-address-list"],
4019
+ ["protocol", "protocol"],
4020
+ ["srcPort", "src-port"],
4021
+ ["dstPort", "dst-port"],
4022
+ ["inInterface", "in-interface"],
4023
+ ["outInterface", "out-interface"],
4024
+ ["tcpFlags", "tcp-flags"]
4025
+ ];
4026
+ var MANGLE_ACTIONS = [
4027
+ "accept",
4028
+ "passthrough",
4029
+ "mark-routing",
4030
+ "mark-connection",
4031
+ "mark-packet",
4032
+ "change-dscp",
4033
+ "change-mss"
4034
+ ];
4035
+ var ACTION_VALUE_PARAMS = [
4036
+ ["mark-routing", "newRoutingMark", "new-routing-mark"],
4037
+ ["mark-connection", "newConnectionMark", "new-connection-mark"],
4038
+ ["mark-packet", "newPacketMark", "new-packet-mark"],
4039
+ ["change-dscp", "newDscpValue", "new-dscp"],
4040
+ ["change-mss", "newMss", "new-mss"]
4041
+ ];
4042
+ function sameActionField(property, stored, requested, fallback) {
4043
+ if (stored === void 0) return true;
4044
+ return sameRuleValue(property, stored, requested ?? fallback);
4045
+ }
4046
+ var NO_ROUTING_MARK_CHAINS = /* @__PURE__ */ new Set(["input", "forward", "postrouting"]);
4047
+ function validationError(code, message, details, suggestedAction) {
4048
+ return new MikroMCPError({
4049
+ category: "VALIDATION" /* VALIDATION */,
4050
+ code,
4051
+ message,
4052
+ details,
4053
+ recoverability: { retryable: false, suggestedAction }
3823
4054
  });
3824
- return results2.length > 0 ? results2[0] : void 0;
4055
+ }
4056
+ function checkAddParams(parsed) {
4057
+ const values = ACTION_VALUE_PARAMS.filter(([, param]) => parsed[param] !== void 0);
4058
+ if (parsed.ruleAction === void 0 && values.length > 1) {
4059
+ throw validationError(
4060
+ "MANGLE_FIELD_NOT_APPLICABLE",
4061
+ `${values.map(([, param]) => param).join(" and ")} belong to different actions (${values.map(([action]) => action).join(", ")}); a mangle rule has one action.`,
4062
+ { params: values.map(([, param]) => param), actions: values.map(([action]) => action) },
4063
+ "Add one rule per action, each with its own value."
4064
+ );
4065
+ }
4066
+ const ruleAction = parsed.ruleAction ?? values[0]?.[0] ?? "accept";
4067
+ for (const [action, param, property] of ACTION_VALUE_PARAMS) {
4068
+ const given = parsed[param] !== void 0;
4069
+ if (given && action !== ruleAction) {
4070
+ throw validationError(
4071
+ "MANGLE_FIELD_NOT_APPLICABLE",
4072
+ `${param} (${property}) applies only to ruleAction ${action}, not to ${ruleAction}.`,
4073
+ { ruleAction, param, requiredRuleAction: action },
4074
+ `Drop ${param}, or set ruleAction to ${action}.`
4075
+ );
4076
+ }
4077
+ if (!given && action === ruleAction) {
4078
+ throw validationError(
4079
+ "MANGLE_ACTION_VALUE_REQUIRED",
4080
+ `ruleAction ${action} needs ${param} (${property}).`,
4081
+ { ruleAction, param },
4082
+ `Provide ${param}.`
4083
+ );
4084
+ }
4085
+ }
4086
+ if (ruleAction === "mark-routing" && NO_ROUTING_MARK_CHAINS.has(parsed.chain)) {
4087
+ throw validationError(
4088
+ "MANGLE_CHAIN_NOT_APPLICABLE",
4089
+ `A routing mark set in chain ${parsed.chain} takes no effect; mark-routing works only in prerouting and output.`,
4090
+ { ruleAction, chain: parsed.chain },
4091
+ "Use chain prerouting for forwarded traffic or output for the router's own traffic."
4092
+ );
4093
+ }
4094
+ const tcp = parsed.protocol !== void 0 && protocolName(parsed.protocol) === "tcp";
4095
+ const syn = (parsed.tcpFlags ?? "").split(",").includes("syn");
4096
+ if (ruleAction === "change-mss" && !(tcp && syn)) {
4097
+ throw validationError(
4098
+ "MANGLE_TCP_REQUIRED",
4099
+ "change-mss works only on TCP SYN packets; RouterOS rejects it without protocol tcp and tcpFlags syn.",
4100
+ { protocol: parsed.protocol, tcpFlags: parsed.tcpFlags },
4101
+ 'Set protocol to "tcp" and tcpFlags to "syn".'
4102
+ );
4103
+ }
4104
+ if (parsed.tcpFlags !== void 0 && !tcp) {
4105
+ throw validationError(
4106
+ "MANGLE_TCP_REQUIRED",
4107
+ `tcpFlags works only with TCP, but protocol is ${parsed.protocol ?? "not set"}.`,
4108
+ { protocol: parsed.protocol, tcpFlags: parsed.tcpFlags },
4109
+ 'Set protocol to "tcp".'
4110
+ );
4111
+ }
4112
+ return ruleAction;
3825
4113
  }
3826
4114
  var listMangleRulesInputSchema = z15.object({
3827
4115
  routerId,
@@ -3849,7 +4137,9 @@ var listMangleRulesTool = {
3849
4137
  offset: void 0
3850
4138
  });
3851
4139
  if (parsed.chain !== void 0) {
3852
- rules = rules.filter((r) => r.chain === parsed.chain);
4140
+ rules = rules.filter(
4141
+ (r) => sameRuleValue("chain", r.chain, parsed.chain)
4142
+ );
3853
4143
  }
3854
4144
  if (parsed.action !== void 0) {
3855
4145
  rules = rules.filter((r) => r.action === parsed.action);
@@ -3889,7 +4179,7 @@ var listMangleRulesTool = {
3889
4179
  var manageMangleRuleInputSchema = z15.object({
3890
4180
  routerId,
3891
4181
  action: z15.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
3892
- comment: z15.string().describe("Idempotency key \u2014 uniquely identifies this mangle rule"),
4182
+ comment: ruleComment.describe("Idempotency key \u2014 uniquely identifies this mangle rule"),
3893
4183
  chain: z15.string().optional().describe("Mangle chain (required on add): prerouting, input, forward, output, postrouting"),
3894
4184
  dryRun,
3895
4185
  srcAddress: z15.string().optional().describe("Source IP/CIDR to match"),
@@ -3901,15 +4191,28 @@ var manageMangleRuleInputSchema = z15.object({
3901
4191
  dstPort: z15.string().optional().describe("Destination port or range"),
3902
4192
  inInterface: z15.string().optional().describe("Incoming interface to match"),
3903
4193
  outInterface: z15.string().optional().describe("Outgoing interface to match"),
3904
- newRoutingMark: z15.string().optional().describe("Routing mark to set"),
3905
- newConnectionMark: z15.string().optional().describe("Connection mark to set"),
3906
- newDscpValue: z15.number().int().min(0).max(63).optional().describe("DSCP value to set (0\u201363)"),
4194
+ tcpFlags: z15.string().min(1).optional().describe(
4195
+ 'TCP flags to match, comma-separated, ! negates one (e.g. "syn"); needs protocol tcp'
4196
+ ),
4197
+ ruleAction: z15.enum(MANGLE_ACTIONS).optional().describe(
4198
+ "Mangle action, add only. Omitted: the action of the new* value given (newRoutingMark \u2192 mark-routing, newConnectionMark \u2192 mark-connection, newPacketMark \u2192 mark-packet, newDscpValue \u2192 change-dscp, newMss \u2192 change-mss), else accept, the RouterOS default"
4199
+ ),
4200
+ newRoutingMark: z15.string().min(1).optional().describe("Routing mark to set (ruleAction mark-routing, chain prerouting or output)"),
4201
+ newConnectionMark: z15.string().min(1).optional().describe("Connection mark to set (ruleAction mark-connection)"),
4202
+ newPacketMark: z15.string().min(1).optional().describe("Packet mark to set (ruleAction mark-packet)"),
4203
+ newDscpValue: z15.number().int().min(0).max(63).optional().describe("DSCP value to set, 0\u201363 (ruleAction change-dscp)"),
4204
+ // RouterOS documents new-mss as an integer with no range; 0–65535 is the size of
4205
+ // the TCP MSS option, and how RouterOS bounds it was not checked. clamp-to-pmtu is
4206
+ // named in the docs and stored as such (RB5009, RouterOS 7.24.2).
4207
+ newMss: z15.union([z15.number().int().min(0).max(65535), z15.literal("clamp-to-pmtu")]).optional().describe(
4208
+ "MSS to set: a number or clamp-to-pmtu (ruleAction change-mss, with protocol tcp and tcpFlags syn)"
4209
+ ),
3907
4210
  passthrough: z15.boolean().optional().describe("Whether to continue matching subsequent rules")
3908
4211
  }).strict();
3909
4212
  var manageMangleRuleTool = {
3910
4213
  name: "manage_mangle_rule",
3911
4214
  title: "Manage Mangle Rule",
3912
- description: "Add, remove, enable, or disable a firewall mangle rule. Uses comment as idempotency key. Supports dry-run mode.",
4215
+ description: "Add, remove, enable, or disable a firewall mangle rule. ruleAction sets the action; when it is omitted, a new* value implies its own (newRoutingMark \u2192 mark-routing), otherwise accept. Each new* value is accepted only under its action. Uses comment as idempotency key: a repeated add returns already_exists only when the chain, match fields, marks, DSCP, MSS, passthrough, and the given or implied action agree, otherwise CONFLICT. Supports dry-run mode.",
3913
4216
  inputSchema: manageMangleRuleInputSchema,
3914
4217
  annotations: {
3915
4218
  readOnlyHint: false,
@@ -3925,6 +4228,14 @@ var manageMangleRuleTool = {
3925
4228
  "Managing mangle rule"
3926
4229
  );
3927
4230
  try {
4231
+ const comment = ruleCommentKey(parsed.comment);
4232
+ if (parsed.action !== "add" && parsed.ruleAction !== void 0) {
4233
+ throw ruleActionAddOnly(
4234
+ parsed.action,
4235
+ parsed.ruleAction,
4236
+ "Drop ruleAction; the rule is identified by its comment."
4237
+ );
4238
+ }
3928
4239
  if (parsed.action === "add") {
3929
4240
  if (parsed.chain === void 0) {
3930
4241
  throw new MikroMCPError({
@@ -3937,65 +4248,39 @@ var manageMangleRuleTool = {
3937
4248
  }
3938
4249
  });
3939
4250
  }
3940
- const existing = await findMangleRuleByComment(context, parsed.comment);
4251
+ const ruleAction = checkAddParams(parsed);
4252
+ const existing = await findRuleByComment(context, MANGLE_PATH, comment);
3941
4253
  if (existing) {
3942
- const sameChain = existing.chain === parsed.chain;
3943
- const sameSrcAddress = sameRuleValue(
3944
- "src-address",
3945
- existing["src-address"],
3946
- parsed.srcAddress
3947
- );
3948
- const sameDstAddress = sameRuleValue(
3949
- "dst-address",
3950
- existing["dst-address"],
3951
- parsed.dstAddress
3952
- );
3953
- const sameSrcAddressList = sameRuleValue(
3954
- "src-address-list",
3955
- existing["src-address-list"],
3956
- parsed.srcAddressList
3957
- );
3958
- const sameDstAddressList = sameRuleValue(
3959
- "dst-address-list",
3960
- existing["dst-address-list"],
3961
- parsed.dstAddressList
3962
- );
3963
- const sameNewRoutingMark = sameRuleValue(
3964
- "new-routing-mark",
3965
- existing["new-routing-mark"],
3966
- parsed.newRoutingMark
3967
- );
3968
- const sameNewConnectionMark = sameRuleValue(
3969
- "new-connection-mark",
3970
- existing["new-connection-mark"],
3971
- parsed.newConnectionMark
3972
- );
3973
- if (sameChain && sameSrcAddress && sameDstAddress && sameSrcAddressList && sameDstAddressList && sameNewRoutingMark && sameNewConnectionMark) {
4254
+ const compareAction = parsed.ruleAction !== void 0 || ruleAction !== "accept";
4255
+ const existingAction = existing.action ?? "accept";
4256
+ const matches = (!compareAction || sameRuleValue("action", existingAction, ruleAction)) && sameRuleValue("chain", existing.chain, parsed.chain) && MANGLE_MATCH_FIELDS.every(
4257
+ ([key2, property]) => sameRuleValue(property, existing[property], parsed[key2])
4258
+ ) && ACTION_VALUE_PARAMS.every(
4259
+ ([, param, property]) => sameActionField(property, existing[property], parsed[param])
4260
+ ) && sameActionField("passthrough", existing.passthrough, parsed.passthrough, true);
4261
+ if (matches) {
3974
4262
  return {
3975
- content: `Mangle rule with comment "${parsed.comment}" already exists. No changes made.`,
4263
+ content: `Mangle rule with comment "${comment}" already exists. No changes made.`,
3976
4264
  structuredContent: { action: "already_exists", rule: existing }
3977
4265
  };
3978
4266
  }
4267
+ const existingDetails = compareAction ? { action: existingAction, chain: existing.chain } : { chain: existing.chain };
4268
+ const requestedDetails = compareAction ? { action: ruleAction, chain: parsed.chain } : { chain: parsed.chain };
4269
+ for (const [key2, property] of MANGLE_MATCH_FIELDS) {
4270
+ existingDetails[property] = existing[property];
4271
+ requestedDetails[property] = parsed[key2];
4272
+ }
4273
+ for (const [, param, property] of ACTION_VALUE_PARAMS) {
4274
+ existingDetails[property] = existing[property];
4275
+ requestedDetails[property] = parsed[param];
4276
+ }
4277
+ existingDetails.passthrough = existing.passthrough;
4278
+ requestedDetails.passthrough = parsed.passthrough ?? true;
3979
4279
  throw new MikroMCPError({
3980
4280
  category: "CONFLICT" /* CONFLICT */,
3981
4281
  code: "MANGLE_RULE_CONFLICT",
3982
- message: `Mangle rule with comment "${parsed.comment}" already exists but with different configuration.`,
3983
- details: {
3984
- existing: {
3985
- chain: existing.chain,
3986
- "src-address": existing["src-address"],
3987
- "dst-address": existing["dst-address"],
3988
- "new-routing-mark": existing["new-routing-mark"],
3989
- "new-connection-mark": existing["new-connection-mark"]
3990
- },
3991
- requested: {
3992
- chain: parsed.chain,
3993
- "src-address": parsed.srcAddress,
3994
- "dst-address": parsed.dstAddress,
3995
- "new-routing-mark": parsed.newRoutingMark,
3996
- "new-connection-mark": parsed.newConnectionMark
3997
- }
3998
- },
4282
+ message: `Mangle rule with comment "${comment}" already exists but with different configuration.`,
4283
+ details: { existing: existingDetails, requested: requestedDetails },
3999
4284
  recoverability: {
4000
4285
  retryable: false,
4001
4286
  suggestedAction: "Remove the existing mangle rule first, then re-add with the desired configuration.",
@@ -4005,7 +4290,8 @@ var manageMangleRuleTool = {
4005
4290
  }
4006
4291
  const body = {
4007
4292
  chain: parsed.chain,
4008
- comment: parsed.comment
4293
+ action: ruleAction,
4294
+ comment
4009
4295
  };
4010
4296
  if (parsed.srcAddress !== void 0) body["src-address"] = parsed.srcAddress;
4011
4297
  if (parsed.dstAddress !== void 0) body["dst-address"] = parsed.dstAddress;
@@ -4016,10 +4302,11 @@ var manageMangleRuleTool = {
4016
4302
  if (parsed.dstPort !== void 0) body["dst-port"] = parsed.dstPort;
4017
4303
  if (parsed.inInterface !== void 0) body["in-interface"] = parsed.inInterface;
4018
4304
  if (parsed.outInterface !== void 0) body["out-interface"] = parsed.outInterface;
4019
- if (parsed.newRoutingMark !== void 0) body["new-routing-mark"] = parsed.newRoutingMark;
4020
- if (parsed.newConnectionMark !== void 0)
4021
- body["new-connection-mark"] = parsed.newConnectionMark;
4022
- if (parsed.newDscpValue !== void 0) body["new-dscp"] = String(parsed.newDscpValue);
4305
+ if (parsed.tcpFlags !== void 0) body["tcp-flags"] = parsed.tcpFlags;
4306
+ for (const [, param, property] of ACTION_VALUE_PARAMS) {
4307
+ const value = parsed[param];
4308
+ if (value !== void 0) body[property] = String(value);
4309
+ }
4023
4310
  if (parsed.passthrough !== void 0) body.passthrough = parsed.passthrough ? "yes" : "no";
4024
4311
  if (parsed.dryRun) {
4025
4312
  const diff = Object.entries(body).map(([property, after]) => ({
@@ -4028,48 +4315,48 @@ var manageMangleRuleTool = {
4028
4315
  after
4029
4316
  }));
4030
4317
  return {
4031
- content: `Dry run: Would add mangle rule in chain "${parsed.chain}" with comment "${parsed.comment}".`,
4318
+ content: `Dry run: Would add mangle rule in chain "${parsed.chain}" with comment "${comment}".`,
4032
4319
  structuredContent: { action: "dry_run", diff }
4033
4320
  };
4034
4321
  }
4035
4322
  const created = await context.routerClient.create(MANGLE_PATH, body);
4036
- log13.info({ comment: parsed.comment, id: created[".id"] }, "Mangle rule added");
4323
+ log13.info({ comment, id: created[".id"] }, "Mangle rule added");
4037
4324
  return {
4038
- content: `Added mangle rule in chain "${parsed.chain}" with comment "${parsed.comment}".`,
4325
+ content: `Added mangle rule in chain "${parsed.chain}" with comment "${comment}".`,
4039
4326
  structuredContent: { action: "created", rule: created }
4040
4327
  };
4041
4328
  }
4042
4329
  if (parsed.action === "remove") {
4043
- const existing = await findMangleRuleByComment(context, parsed.comment);
4330
+ const existing = await findRuleByComment(context, MANGLE_PATH, comment);
4044
4331
  if (!existing) {
4045
4332
  return {
4046
- content: `Mangle rule with comment "${parsed.comment}" does not exist. No changes made.`,
4047
- structuredContent: { action: "already_removed", comment: parsed.comment }
4333
+ content: `Mangle rule with comment "${comment}" does not exist. No changes made.`,
4334
+ structuredContent: { action: "already_removed", comment }
4048
4335
  };
4049
4336
  }
4050
4337
  const id = existing[".id"];
4051
4338
  if (parsed.dryRun) {
4052
4339
  return {
4053
- content: `Dry run: Would remove mangle rule with comment "${parsed.comment}".`,
4054
- structuredContent: { action: "dry_run", id, comment: parsed.comment }
4340
+ content: `Dry run: Would remove mangle rule with comment "${comment}".`,
4341
+ structuredContent: { action: "dry_run", id, comment }
4055
4342
  };
4056
4343
  }
4057
4344
  await context.routerClient.remove(MANGLE_PATH, id);
4058
- log13.info({ id, comment: parsed.comment }, "Mangle rule removed");
4345
+ log13.info({ id, comment }, "Mangle rule removed");
4059
4346
  return {
4060
- content: `Removed mangle rule with comment "${parsed.comment}".`,
4061
- structuredContent: { action: "removed", id, comment: parsed.comment }
4347
+ content: `Removed mangle rule with comment "${comment}".`,
4348
+ structuredContent: { action: "removed", id, comment }
4062
4349
  };
4063
4350
  }
4064
4351
  if (parsed.action === "enable" || parsed.action === "disable") {
4065
4352
  const wantDisabled = parsed.action === "disable";
4066
- const existing = await findMangleRuleByComment(context, parsed.comment);
4353
+ const existing = await findRuleByComment(context, MANGLE_PATH, comment);
4067
4354
  if (!existing) {
4068
4355
  throw new MikroMCPError({
4069
4356
  category: "NOT_FOUND" /* NOT_FOUND */,
4070
4357
  code: "MANGLE_RULE_NOT_FOUND",
4071
- message: `No mangle rule found with comment "${parsed.comment}".`,
4072
- details: { comment: parsed.comment },
4358
+ message: `No mangle rule found with comment "${comment}".`,
4359
+ details: { comment },
4073
4360
  recoverability: {
4074
4361
  retryable: false,
4075
4362
  suggestedAction: "Verify the comment using list_mangle_rules.",
@@ -4081,8 +4368,8 @@ var manageMangleRuleTool = {
4081
4368
  const isDisabled = isTrue(existing.disabled);
4082
4369
  if (isDisabled === wantDisabled) {
4083
4370
  return {
4084
- content: `Mangle rule with comment "${parsed.comment}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
4085
- structuredContent: { action: "no_change", id, comment: parsed.comment }
4371
+ content: `Mangle rule with comment "${comment}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
4372
+ structuredContent: { action: "no_change", id, comment }
4086
4373
  };
4087
4374
  }
4088
4375
  if (parsed.dryRun) {
@@ -4090,17 +4377,17 @@ var manageMangleRuleTool = {
4090
4377
  { property: "disabled", before: String(isDisabled), after: String(wantDisabled) }
4091
4378
  ];
4092
4379
  return {
4093
- content: `Dry run: Would ${parsed.action} mangle rule with comment "${parsed.comment}".`,
4380
+ content: `Dry run: Would ${parsed.action} mangle rule with comment "${comment}".`,
4094
4381
  structuredContent: { action: "dry_run", diff }
4095
4382
  };
4096
4383
  }
4097
4384
  await context.routerClient.update(MANGLE_PATH, id, {
4098
4385
  disabled: wantDisabled ? "true" : "false"
4099
4386
  });
4100
- log13.info({ id, comment: parsed.comment, action: parsed.action }, "Mangle rule toggled");
4387
+ log13.info({ id, comment, action: parsed.action }, "Mangle rule toggled");
4101
4388
  return {
4102
- content: `${parsed.action === "disable" ? "Disabled" : "Enabled"} mangle rule with comment "${parsed.comment}".`,
4103
- structuredContent: { action: "updated", id, comment: parsed.comment }
4389
+ content: `${parsed.action === "disable" ? "Disabled" : "Enabled"} mangle rule with comment "${comment}".`,
4390
+ structuredContent: { action: "updated", id, comment }
4104
4391
  };
4105
4392
  }
4106
4393
  throw new MikroMCPError({
@@ -4303,7 +4590,9 @@ var listRoutingRulesTool = {
4303
4590
  offset: void 0
4304
4591
  });
4305
4592
  if (parsed.table !== void 0) {
4306
- rules = rules.filter((r) => r.table === parsed.table);
4593
+ rules = rules.filter(
4594
+ (r) => sameRuleValue("table", r.table, parsed.table)
4595
+ );
4307
4596
  }
4308
4597
  if (parsed.disabled !== void 0) {
4309
4598
  rules = rules.filter((r) => {
@@ -4325,7 +4614,8 @@ var listRoutingRulesTool = {
4325
4614
  "action",
4326
4615
  "table",
4327
4616
  "interface",
4328
- "disabled"
4617
+ "disabled",
4618
+ "inactive"
4329
4619
  ])
4330
4620
  ),
4331
4621
  structuredContent: { routerId: context.routerId, rules, total: rules.length }
@@ -4341,22 +4631,27 @@ var manageRoutingRuleInputSchema = z17.object({
4341
4631
  table: z17.string().describe(
4342
4632
  "Routing table name \u2014 part of the composite idempotency key; required for all actions"
4343
4633
  ),
4634
+ ruleAction: z17.enum(["lookup", "lookup-only-in-table"]).optional().describe(
4635
+ "Rule action, add only: lookup (default; when the table has no route, later rules and main still apply) or lookup-only-in-table (no fallback)"
4636
+ ),
4344
4637
  srcAddress: z17.string().optional().describe("Source CIDR to match"),
4345
4638
  dstAddress: z17.string().optional().describe("Destination CIDR to match"),
4346
4639
  interface: z17.string().optional().describe("Incoming interface to match"),
4347
- priority: z17.number().int().min(0).max(4294967295).optional().describe("Rule priority (0\u20134294967295)"),
4640
+ priority: z17.number().int().min(0).max(4294967295).optional().describe(
4641
+ "Unsupported: RouterOS 7 /routing/rule has no priority (rules apply in list order); add rejects it"
4642
+ ),
4348
4643
  dryRun
4349
4644
  }).strict();
4350
4645
  function findRoutingRule(rules, srcAddress, dstAddress, iface, table) {
4351
4646
  return rules.find((r) => {
4352
4647
  const rec = r;
4353
- return sameRuleValue("src-address", rec["src-address"], srcAddress) && sameRuleValue("dst-address", rec["dst-address"], dstAddress) && sameRuleValue("interface", rec["interface"], iface) && rec.table === table;
4648
+ return sameRuleValue("src-address", rec["src-address"], srcAddress) && sameRuleValue("dst-address", rec["dst-address"], dstAddress) && sameRuleValue("interface", rec["interface"], iface) && sameRuleValue("table", rec.table, table);
4354
4649
  });
4355
4650
  }
4356
4651
  var manageRoutingRuleTool = {
4357
4652
  name: "manage_routing_rule",
4358
4653
  title: "Manage Routing Rule",
4359
- description: "Add, remove, enable, or disable a policy routing rule. Idempotent by srcAddress+dstAddress+interface+table composite key. Supports dry-run mode.",
4654
+ description: "Add, remove, enable, or disable a policy routing rule. Idempotent by srcAddress+dstAddress+interface+table composite key; a repeated add with an explicit, different ruleAction is a CONFLICT. Supports dry-run mode.",
4360
4655
  inputSchema: manageRoutingRuleInputSchema,
4361
4656
  annotations: {
4362
4657
  readOnlyHint: false,
@@ -4372,6 +4667,25 @@ var manageRoutingRuleTool = {
4372
4667
  "Managing routing rule"
4373
4668
  );
4374
4669
  try {
4670
+ if (parsed.action === "add" && parsed.priority !== void 0) {
4671
+ throw new MikroMCPError({
4672
+ category: "VALIDATION" /* VALIDATION */,
4673
+ code: "PRIORITY_UNSUPPORTED",
4674
+ message: "RouterOS 7 routing rules have no priority; they apply in list order.",
4675
+ details: { priority: parsed.priority },
4676
+ recoverability: {
4677
+ retryable: false,
4678
+ suggestedAction: "Drop priority. Rule order is the position in /routing/rule."
4679
+ }
4680
+ });
4681
+ }
4682
+ if (parsed.action !== "add" && parsed.ruleAction !== void 0) {
4683
+ throw ruleActionAddOnly(
4684
+ parsed.action,
4685
+ parsed.ruleAction,
4686
+ "Drop ruleAction; the rule is identified by srcAddress, dstAddress, interface, and table."
4687
+ );
4688
+ }
4375
4689
  const allRules = await context.routerClient.get(ROUTING_RULE_PATH, {
4376
4690
  limit: void 0,
4377
4691
  offset: void 0
@@ -4396,16 +4710,34 @@ var manageRoutingRuleTool = {
4396
4710
  });
4397
4711
  }
4398
4712
  if (existing) {
4713
+ if (parsed.ruleAction !== void 0 && existing.action !== parsed.ruleAction) {
4714
+ throw new MikroMCPError({
4715
+ category: "CONFLICT" /* CONFLICT */,
4716
+ code: "ROUTING_RULE_CONFLICT",
4717
+ message: `Routing rule for table "${parsed.table}" already exists with action "${existing.action ?? "(none)"}".`,
4718
+ details: {
4719
+ existing: { action: existing.action },
4720
+ requested: { action: parsed.ruleAction }
4721
+ },
4722
+ recoverability: {
4723
+ retryable: false,
4724
+ suggestedAction: "Remove the existing routing rule first, then re-add it with the desired ruleAction.",
4725
+ alternativeTools: ["manage_routing_rule with action=remove"]
4726
+ }
4727
+ });
4728
+ }
4399
4729
  return {
4400
4730
  content: `Routing rule for table "${parsed.table}" already exists. No changes made.`,
4401
4731
  structuredContent: { action: "already_exists", rule: existing }
4402
4732
  };
4403
4733
  }
4404
- const body = { table: parsed.table };
4734
+ const body = {
4735
+ action: parsed.ruleAction ?? "lookup",
4736
+ table: parsed.table
4737
+ };
4405
4738
  if (parsed.srcAddress !== void 0) body["src-address"] = parsed.srcAddress;
4406
4739
  if (parsed.dstAddress !== void 0) body["dst-address"] = parsed.dstAddress;
4407
4740
  if (parsed.interface !== void 0) body["interface"] = parsed.interface;
4408
- if (parsed.priority !== void 0) body.priority = String(parsed.priority);
4409
4741
  if (parsed.dryRun) {
4410
4742
  const diff = Object.entries(body).map(([property, after]) => ({
4411
4743
  property,
@@ -10068,7 +10400,7 @@ var networkServicesTools = [
10068
10400
  // src/domain/tools/vlan-tools.ts
10069
10401
  import { z as z34 } from "zod";
10070
10402
  var log34 = createLogger("vlan-tools");
10071
- function sanitizeComment4(comment) {
10403
+ function sanitizeComment3(comment) {
10072
10404
  if (comment === void 0) return void 0;
10073
10405
  return comment.replace(/[\x00-\x1f\x7f]/g, "");
10074
10406
  }
@@ -10099,7 +10431,7 @@ var manageVlanTool = {
10099
10431
  snapshotPaths: ["interface/vlan"],
10100
10432
  async handler(params, context) {
10101
10433
  const parsed = manageVlanInputSchema.parse(params);
10102
- const comment = sanitizeComment4(parsed.comment);
10434
+ const comment = sanitizeComment3(parsed.comment);
10103
10435
  log34.info(
10104
10436
  { routerId: context.routerId, action: parsed.action, name: parsed.name },
10105
10437
  "Managing VLAN"
@@ -13443,7 +13775,7 @@ function resolveField(epKey, key2) {
13443
13775
  function resolveWireKey(field, wire) {
13444
13776
  return Object.values(field.keys).find((key2) => key2 in wire);
13445
13777
  }
13446
- function validationError(code, message, suggestedAction) {
13778
+ function validationError2(code, message, suggestedAction) {
13447
13779
  return new MikroMCPError({
13448
13780
  category: "VALIDATION" /* VALIDATION */,
13449
13781
  code,
@@ -13461,7 +13793,7 @@ function coerceValue(field, value, existing) {
13461
13793
  function coerceScalar(field, value, existing) {
13462
13794
  if (typeof value === "boolean") {
13463
13795
  if (field.kind === Kind.BOOL) {
13464
- throw validationError(
13796
+ throw validationError2(
13465
13797
  "SWOS_FIELD_SHAPE_MISMATCH",
13466
13798
  `Field "${field.name}" is a per-port bitmask, not a single flag.`,
13467
13799
  "Pass the mask as a number \u2014 e.g. 1023 for all 10 ports, 0 for none."
@@ -13473,7 +13805,7 @@ function coerceScalar(field, value, existing) {
13473
13805
  if (field.kind === Kind.OPTION) {
13474
13806
  const idx = (field.options ?? []).indexOf(String(value));
13475
13807
  if (idx < 0) {
13476
- throw validationError(
13808
+ throw validationError2(
13477
13809
  "SWOS_INVALID_OPTION",
13478
13810
  `Invalid option "${String(value)}" for field "${field.name}".`,
13479
13811
  `Use one of: ${(field.options ?? []).join(", ")}.`
@@ -13488,7 +13820,7 @@ function assertShapeMatches(perPort, key2, next, existing) {
13488
13820
  if (existing === void 0) return;
13489
13821
  if (Array.isArray(next) && Array.isArray(existing)) {
13490
13822
  if (next.length === existing.length) return;
13491
- throw validationError(
13823
+ throw validationError2(
13492
13824
  "SWOS_FIELD_SHAPE_MISMATCH",
13493
13825
  `Field "${key2}" holds ${existing.length} value(s) on the wire, but ${next.length} were given.`,
13494
13826
  `Pass exactly ${existing.length} value(s), or a single value to apply to all of them.`
@@ -13496,7 +13828,7 @@ function assertShapeMatches(perPort, key2, next, existing) {
13496
13828
  }
13497
13829
  if (!Array.isArray(next) && !Array.isArray(existing)) return;
13498
13830
  if (!Array.isArray(next) && perPort) return;
13499
- throw validationError(
13831
+ throw validationError2(
13500
13832
  "SWOS_FIELD_SHAPE_MISMATCH",
13501
13833
  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.`,
13502
13834
  Array.isArray(next) ? "Pass the bitmask as one number (e.g. 1023 for all 10 ports)." : "Pass one value per port."
@@ -13527,7 +13859,7 @@ var writeSwosBlobTool = {
13527
13859
  const field = resolveField(parsed.endpoint, key2);
13528
13860
  const targetKey = field ? resolveWireKey(field, wire) : key2;
13529
13861
  if (targetKey === void 0 || !(targetKey in wire)) {
13530
- throw validationError(
13862
+ throw validationError2(
13531
13863
  "SWOS_UNKNOWN_FIELD",
13532
13864
  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}.`,
13533
13865
  `Read the endpoint first; only keys the device actually sent can be written: ${Object.keys(wire).join(", ")}.`
@@ -14199,11 +14531,19 @@ function formatToolResult(result) {
14199
14531
  };
14200
14532
  }
14201
14533
  function formatError(error) {
14202
- const hint = error.recoverability.suggestedAction;
14203
- const text = `Error [${error.category}]: ${error.message}${hint ? `
14204
- Suggested action: ${hint}` : ""}`;
14534
+ const { suggestedAction, alternativeTools, retryAfterMs } = error.recoverability;
14535
+ const lines = [`Error [${error.category}]: ${error.message}`];
14536
+ if (suggestedAction) lines.push(`Suggested action: ${suggestedAction}`);
14537
+ lines.push(`Code: ${error.code}`);
14538
+ if (error.details && Object.keys(error.details).length > 0) {
14539
+ lines.push(`Details: ${JSON.stringify(error.details)}`);
14540
+ }
14541
+ if (alternativeTools && alternativeTools.length > 0) {
14542
+ lines.push(`Alternative tools: ${alternativeTools.join(", ")}`);
14543
+ }
14544
+ if (retryAfterMs !== void 0) lines.push(`Retry after: ${retryAfterMs} ms`);
14205
14545
  return {
14206
- content: [{ type: "text", text }],
14546
+ content: [{ type: "text", text: lines.join("\n") }],
14207
14547
  structuredContent: error.toJSON(),
14208
14548
  isError: true
14209
14549
  };
@@ -14763,7 +15103,12 @@ var listRoutersTool = {
14763
15103
  }
14764
15104
  };
14765
15105
  var FLEET_TOOL_NAMES = /* @__PURE__ */ new Set(["bulk_execute", "bulk_read", "check_router_health"]);
14766
- var BULK_READ_EXCLUDED_TOOLS = /* @__PURE__ */ new Set(["ping", "traceroute", "torch", "bandwidth_test"]);
15106
+ var BULK_READ_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
15107
+ "ping",
15108
+ "traceroute",
15109
+ "torch",
15110
+ "bandwidth_test"
15111
+ ]);
14767
15112
  function hasItems(list) {
14768
15113
  return Array.isArray(list) && list.length > 0;
14769
15114
  }
@@ -15459,7 +15804,7 @@ var ConnectionPool = class {
15459
15804
  };
15460
15805
 
15461
15806
  // src/version.ts
15462
- var VERSION = "1.12.0";
15807
+ var VERSION = "1.13.0";
15463
15808
 
15464
15809
  // src/mcp/server.ts
15465
15810
  var SERVER_INSTRUCTIONS = [
@@ -15937,7 +16282,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
15937
16282
  import { homedir as homedir2 } from "os";
15938
16283
  import * as net from "net";
15939
16284
  import chalk from "chalk";
15940
- var localVersion = "1.12.0";
16285
+ var localVersion = "1.13.0";
15941
16286
  var results = [];
15942
16287
  function ok(msg) {
15943
16288
  console.log(chalk.green("\u2705 ") + msg);
@@ -16682,7 +17027,7 @@ async function runInit() {
16682
17027
  }
16683
17028
 
16684
17029
  // src/cli/index.ts
16685
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.12.0");
17030
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.13.0");
16686
17031
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
16687
17032
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
16688
17033
  program.command("init").description("Interactive setup wizard").action(runInit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikromcp",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "mcpName": "io.github.AliKarami/mikromcp",
5
5
  "description": "Production-grade MCP server for MikroTik RouterOS",
6
6
  "type": "module",