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.
- package/README.md +1 -1
- package/dist/main.js +527 -182
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
|
|
14
14
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
|
|
15
|
-
[](package.json)
|
|
16
16
|
[](LICENSE)
|
|
17
17
|
[](package.json)
|
|
18
18
|
[](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
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
3542
|
-
|
|
3543
|
-
|
|
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
|
|
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
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
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: "
|
|
3582
|
-
message:
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
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
|
-
|
|
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: "
|
|
3597
|
-
code: "
|
|
3598
|
-
message: "
|
|
3599
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
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
|
|
4251
|
+
const ruleAction = checkAddParams(parsed);
|
|
4252
|
+
const existing = await findRuleByComment(context, MANGLE_PATH, comment);
|
|
3941
4253
|
if (existing) {
|
|
3942
|
-
const
|
|
3943
|
-
const
|
|
3944
|
-
|
|
3945
|
-
existing[
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
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 "${
|
|
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 "${
|
|
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
|
-
|
|
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.
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
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 "${
|
|
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
|
|
4323
|
+
log13.info({ comment, id: created[".id"] }, "Mangle rule added");
|
|
4037
4324
|
return {
|
|
4038
|
-
content: `Added mangle rule in chain "${parsed.chain}" with 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
|
|
4330
|
+
const existing = await findRuleByComment(context, MANGLE_PATH, comment);
|
|
4044
4331
|
if (!existing) {
|
|
4045
4332
|
return {
|
|
4046
|
-
content: `Mangle rule with comment "${
|
|
4047
|
-
structuredContent: { action: "already_removed", 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 "${
|
|
4054
|
-
structuredContent: { action: "dry_run", id, 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
|
|
4345
|
+
log13.info({ id, comment }, "Mangle rule removed");
|
|
4059
4346
|
return {
|
|
4060
|
-
content: `Removed mangle rule with comment "${
|
|
4061
|
-
structuredContent: { action: "removed", id, 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
|
|
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 "${
|
|
4072
|
-
details: { 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 "${
|
|
4085
|
-
structuredContent: { action: "no_change", id, 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 "${
|
|
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
|
|
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 "${
|
|
4103
|
-
structuredContent: { action: "updated", id, 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(
|
|
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(
|
|
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
|
|
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 = {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
14203
|
-
const
|
|
14204
|
-
Suggested action: ${
|
|
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([
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|