@usex/mikrotik-mcp 3.18.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -782,7 +782,6 @@ import { z as z3 } from "zod";
782
782
 
783
783
  // src/ssh/safe-mode.ts
784
784
  var PROMPT_RE = /\[.+?@.+?\] (?:<SAFE> )?> ?$/m;
785
- var NORMAL_PROMPT_RE = /\[.+?@.+?\] > ?$/m;
786
785
  var ANSI_RE = /\x1B(?:\[[0-9;]*[mA-HJ-MSTfhilnprsu]|[()][0-9A-Za-z]|\[?\?\d+[hl])/g;
787
786
  function stripAnsi(text) {
788
787
  return text.replace(ANSI_RE, "");
@@ -793,13 +792,17 @@ function isSafeModeActivated(response) {
793
792
  return true;
794
793
  return /safe mode[^\n]*\b(?:success|taken|enabled|active)\b/i.test(response);
795
794
  }
796
- function isSafeModeReleased(response) {
795
+ function lastNonEmptyLine(response) {
797
796
  const lines = response.replace(/\r/g, "").split(`
798
797
  `).map((l) => l.trimEnd()).filter(Boolean);
799
- const last = lines.at(-1) ?? "";
800
- return NORMAL_PROMPT_RE.test(last) && !last.includes("<SAFE>");
798
+ return lines.at(-1) ?? "";
799
+ }
800
+ function classifyPrompt(response) {
801
+ const last = lastNonEmptyLine(response);
802
+ if (!PROMPT_RE.test(last))
803
+ return "unknown";
804
+ return last.includes("<SAFE>") ? "safe" : "released";
801
805
  }
802
-
803
806
  class SafeModeManager {
804
807
  deviceName;
805
808
  ssh = null;
@@ -874,13 +877,30 @@ class SafeModeManager {
874
877
  return this.lock(async () => {
875
878
  if (!this.active || !this.channel)
876
879
  return "Safe mode is not active. Nothing to commit.";
880
+ this.channel.write(`
881
+ `);
882
+ const probe = await this.readSettledPrompt();
883
+ const before = classifyPrompt(probe);
884
+ if (before === "released") {
885
+ this.cleanup();
886
+ return "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED.";
887
+ }
888
+ if (before === "unknown") {
889
+ return "Could not read a prompt to determine Safe Mode state. The session is left open so " + "nothing is reverted \u2014 call get_safe_mode_status, retry commit_safe_mode, or " + `rollback_safe_mode. Last output: ${probe.slice(-160)}`;
890
+ }
877
891
  this.channel.write(CTRL_X);
878
- const response = await this.readUntilPrompt(15000, (c) => isSafeModeReleased(c));
879
- if (!isSafeModeReleased(response)) {
880
- return "Commit may not have completed: the device did not return to a normal prompt " + `after exiting Safe Mode. Session left open \u2014 retry commit_safe_mode. Response: ${response.slice(-200)}`;
892
+ this.channel.write(`
893
+ `);
894
+ const after = await this.readSettledPrompt();
895
+ switch (classifyPrompt(after)) {
896
+ case "released":
897
+ this.cleanup();
898
+ return "Changes committed successfully. Safe mode DISABLED.";
899
+ case "safe":
900
+ return "Commit not completed \u2014 the device is still in Safe Mode. Your changes remain held " + "in memory (not reverted); call commit_safe_mode again to retry, or rollback_safe_mode " + "to discard them.";
901
+ default:
902
+ return "Commit status unclear \u2014 no prompt seen after exiting Safe Mode. The session is left " + "open so nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode. " + `Last output: ${after.slice(-160)}`;
881
903
  }
882
- this.cleanup();
883
- return "Changes committed successfully. Safe mode DISABLED.";
884
904
  });
885
905
  }
886
906
  rollback() {
@@ -916,6 +936,33 @@ class SafeModeManager {
916
936
  channel.on("data", onData);
917
937
  });
918
938
  }
939
+ readSettledPrompt(quietMs = 450, maxMs = 8000) {
940
+ const channel = this.channel;
941
+ if (!channel)
942
+ return Promise.resolve("");
943
+ return new Promise((resolve2) => {
944
+ let buf = "";
945
+ let quiet;
946
+ let hard;
947
+ function done() {
948
+ clearTimeout(hard);
949
+ if (quiet)
950
+ clearTimeout(quiet);
951
+ channel.removeListener("data", onData);
952
+ resolve2(stripAnsi(buf));
953
+ }
954
+ function onData(chunk) {
955
+ buf += decodeOutput(chunk);
956
+ if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
957
+ if (quiet)
958
+ clearTimeout(quiet);
959
+ quiet = setTimeout(done, quietMs);
960
+ }
961
+ }
962
+ hard = setTimeout(done, maxMs);
963
+ channel.on("data", onData);
964
+ });
965
+ }
919
966
  extractOutput(raw, command) {
920
967
  const text = raw.replace(/\r\n/g, `
921
968
  `).replace(/\r/g, `
@@ -1006,7 +1053,7 @@ function createContext(sendLog, device) {
1006
1053
  }
1007
1054
 
1008
1055
  // src/core/routeros.ts
1009
- var BARE_SAFE = /^[\w.\-:/,*@]+$/;
1056
+ var BARE_SAFE = /^[\w.\-:/,*@!]+$/;
1010
1057
  function quoteValue(value) {
1011
1058
  if (typeof value === "number" || typeof value === "boolean")
1012
1059
  return String(value);
@@ -2174,7 +2221,7 @@ ${result}`;
2174
2221
  name: "create_export",
2175
2222
  title: "Create Full Configuration Export",
2176
2223
  annotations: READ,
2177
- description: "Exports the complete device configuration to a `.rsc` plain-text script file (`/export" + " file=<name>`), re-applicable via `import_configuration`. Unlike `create_backup`, the result" + " is human-readable plain text \u2014 not a binary snapshot \u2014 and passwords are hidden by default" + " (`hide_sensitive=true`). `file_format` changes only the file extension used when looking up" + " the saved file \u2014 no `format=` flag is ever sent to RouterOS, so the content is always" + " RouterOS script text regardless of the chosen extension; selecting `json` or `xml` will also" + " cause the post-export file lookup to fail because RouterOS saves the file as `.rsc`. Use the" + " `compact` boolean to omit default values, or `verbose` to include all parameters;" + " `export_type` only controls where the `file=` argument is positioned in the command and" + " does not independently drive compactness or verbosity. For a single subsection only use" + " `export_section`. Returns file details of the created export file.",
2224
+ description: "Exports the complete device configuration to a `.rsc` plain-text script file (`/export" + " file=<name>`), re-applicable via `import_configuration`. Unlike `create_backup`, the result" + " is human-readable plain text \u2014 not a binary snapshot \u2014 and passwords are hidden by default" + " (`hide_sensitive=true`). `file_format` changes only the file extension used when looking up" + " the saved file \u2014 no `format=` flag is ever sent to RouterOS, so the content is always" + " RouterOS script text regardless of the chosen extension; selecting `json` or `xml` will also" + " cause the post-export file lookup to fail because RouterOS saves the file as `.rsc`." + " ALWAYS default to a FULL configuration export: leave `compact` and `verbose` off (the plain" + " full export) unless the user explicitly asks for less or more. Set `compact` ONLY when the" + " user wants a smaller diff that omits default values, or `verbose` ONLY when they want every" + " parameter (including defaults). `export_type` only controls where the `file=` argument is" + " positioned in the command and does not independently drive compactness or verbosity. For a" + " single subsection only use `export_section`. Returns file details of the created export file.",
2178
2225
  inputSchema: {
2179
2226
  name: z4.string().optional(),
2180
2227
  file_format: z4.enum(["rsc", "json", "xml"]).default("rsc"),
@@ -2367,6 +2414,12 @@ ${result}`;
2367
2414
  // src/tools/local-backup.ts
2368
2415
  import { z as z5 } from "zod";
2369
2416
 
2417
+ // src/core/slug.ts
2418
+ function deviceSlug(name) {
2419
+ const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
2420
+ return s || "device";
2421
+ }
2422
+
2370
2423
  // src/backups/vault.ts
2371
2424
  import {
2372
2425
  existsSync,
@@ -2487,15 +2540,17 @@ function labelSlug(label) {
2487
2540
  const s = (label ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2488
2541
  return s ? `_${s}` : "";
2489
2542
  }
2543
+ function buildExportCommand(opts = {}) {
2544
+ return new Cmd("/export").raw(opts.verbose ? "verbose" : opts.compact ? "compact" : undefined).raw(opts.terse ? "terse" : undefined).raw(opts.showSensitive ? "show-sensitive" : undefined).build();
2545
+ }
2490
2546
  async function createLocalBackup(ctx, opts = {}) {
2491
2547
  const device = resolveDeviceName(ctx.device);
2492
- const cmd = new Cmd("/export").raw(opts.showSensitive ? "show-sensitive" : undefined).build();
2493
- const body = await executeMikrotikCommand(cmd, ctx);
2548
+ const body = await executeMikrotikCommand(buildExportCommand(opts), ctx);
2494
2549
  if (isEmpty(body) || looksLikeError(body)) {
2495
2550
  return { ok: false, device, error: body.trim() || "(empty export)" };
2496
2551
  }
2497
2552
  const stamp = await deviceDateStamp(ctx);
2498
- const name = `${device}_${stamp}${labelSlug(opts.label)}.rsc`;
2553
+ const name = `${deviceSlug(device)}_${stamp}${labelSlug(opts.label)}.rsc`;
2499
2554
  return { ok: true, device, name: writeBackup(name, body), bytes: Buffer.byteLength(body) };
2500
2555
  }
2501
2556
 
@@ -2566,13 +2621,22 @@ var localBackupTools = [
2566
2621
  name: "create_local_backup",
2567
2622
  title: "Create Local Config Backup (host vault)",
2568
2623
  annotations: WRITE,
2569
- description: "Captures the device's full configuration with `/export` and saves it as a timestamped " + "plain-text `.rsc` file in the MCP server's LOCAL backup vault (default " + "`~/.mikrotik-mcp/backups/`, override with the `MIKROTIK_BACKUP_DIR` env var) \u2014 NOT on the " + "device and NOT in S3. The filename is `<device>_<date_time>.rsc` stamped in the device's " + "local clock (24-hour; Jalali for the Tehran timezone, Gregorian otherwise). This is a " + "host-side, human-readable, diffable copy you can restore later with restore_local_backup. " + "Compare: create_backup makes a binary `/system backup` file ON the device; " + "capture_config_snapshot stores an export in the local snapshot database; upload_backup_to_s3 " + "pushes to S3. show_sensitive=true includes secrets (keys/passwords) in the export. " + "Returns the saved filename, byte size and vault path.",
2624
+ description: "Captures the device's full configuration with `/export` and saves it as a timestamped " + "plain-text `.rsc` file in the MCP server's LOCAL backup vault (default " + "`~/.mikrotik-mcp/backups/`, override with the `MIKROTIK_BACKUP_DIR` env var) \u2014 NOT on the " + "device and NOT in S3. The filename is `<device-slug>_<date_time>.rsc` \u2014 the device name is " + "slugified (spaces/underscores/etc \u2192 dash) \u2014 stamped in the device's local clock (24-hour; " + "Jalali for the Tehran timezone, Gregorian otherwise). This is a host-side, human-readable, " + "diffable copy you can restore later with restore_local_backup. Compare: create_backup makes " + "a binary `/system backup` file ON the device; capture_config_snapshot stores an export in " + "the local snapshot database; upload_backup_to_s3 pushes to S3. ALWAYS default to a FULL " + "backup of the complete configuration: call this with no option flags (the bare `/export`) " + "unless the user explicitly asks for less or more. Narrow it ONLY on request \u2014 set compact to " + "drop default values, or use export_section for a single subsection. Broaden it ONLY on " + "request \u2014 set verbose to include every parameter (even defaults), show_sensitive to include " + "secrets (keys/passwords), or use create_backup for a binary full-system snapshot. terse just " + "changes the text to one machine-readable line per item. Returns the saved filename, byte size " + "and vault path.",
2570
2625
  inputSchema: {
2571
2626
  label: z5.string().optional().describe('Optional label appended to the filename, e.g. "pre-upgrade".'),
2572
- show_sensitive: z5.boolean().default(false).describe("Include secrets (keys/passwords) in the export. Default false.")
2627
+ show_sensitive: z5.boolean().default(false).describe("Include secrets (keys/passwords) in the export. Default false."),
2628
+ verbose: z5.boolean().default(false).describe("Include every parameter, even defaults (RouterOS `verbose`)."),
2629
+ compact: z5.boolean().default(false).describe("Export only non-default values (RouterOS `compact`; ignored if verbose)."),
2630
+ terse: z5.boolean().default(false).describe("One self-contained, machine-readable line per item (RouterOS `terse`).")
2573
2631
  },
2574
2632
  async handler(a, ctx) {
2575
- const r = await createLocalBackup(ctx, { label: a.label, showSensitive: a.show_sensitive });
2633
+ const r = await createLocalBackup(ctx, {
2634
+ label: a.label,
2635
+ showSensitive: a.show_sensitive,
2636
+ verbose: a.verbose,
2637
+ compact: a.compact,
2638
+ terse: a.terse
2639
+ });
2576
2640
  if (!r.ok)
2577
2641
  return `Failed to capture export for a local backup: ${r.error}`;
2578
2642
  return `Saved local backup '${r.name}' (${r.bytes} bytes) for device '${r.device}' to ` + `${backupDir()}. Restore it with restore_local_backup name=${r.name}.`;
@@ -2708,7 +2772,7 @@ function trimSlashes(s) {
2708
2772
  return s.replace(/^\/+|\/+$/g, "");
2709
2773
  }
2710
2774
  function s3DevicePrefix(device) {
2711
- const segments = [getS3Config()?.prefix ?? "", device ?? ""].map(trimSlashes).filter(Boolean);
2775
+ const segments = [getS3Config()?.prefix ?? "", device ? deviceSlug(device) : ""].map(trimSlashes).filter(Boolean);
2712
2776
  return segments.length ? `${segments.join("/")}/` : "";
2713
2777
  }
2714
2778
  function s3Key(name, device) {
@@ -6300,6 +6364,12 @@ async function updateNatRule(a, ctx) {
6300
6364
  put("protocol", a.protocol);
6301
6365
  put("in-interface", a.in_interface);
6302
6366
  put("out-interface", a.out_interface);
6367
+ put("in-interface-list", a.in_interface_list);
6368
+ put("out-interface-list", a.out_interface_list);
6369
+ put("src-address-list", a.src_address_list);
6370
+ put("dst-address-list", a.dst_address_list);
6371
+ put("connection-mark", a.connection_mark);
6372
+ put("connection-nat-state", a.connection_nat_state);
6303
6373
  put("to-addresses", a.to_addresses);
6304
6374
  put("to-ports", a.to_ports);
6305
6375
  if (a.comment !== undefined)
@@ -6336,8 +6406,14 @@ var firewallNatTools = [
6336
6406
  src_port: z23.string().optional(),
6337
6407
  dst_port: z23.string().optional(),
6338
6408
  protocol: z23.string().optional(),
6339
- in_interface: z23.string().optional(),
6409
+ in_interface: z23.string().optional().describe('Negatable, e.g. "!ether1"'),
6340
6410
  out_interface: z23.string().optional(),
6411
+ in_interface_list: z23.string().optional().describe('Interface list, e.g. "WAN" or "!LAN"'),
6412
+ out_interface_list: z23.string().optional(),
6413
+ src_address_list: z23.string().optional().describe('Match src in a named list; negate "!name"'),
6414
+ dst_address_list: z23.string().optional().describe('Match dst in a named list; negate "!name"'),
6415
+ connection_mark: z23.string().optional(),
6416
+ connection_nat_state: z23.string().optional().describe('"srcnat" / "dstnat" / "!dstnat"'),
6341
6417
  to_addresses: z23.string().optional().describe('Single IP or range e.g. "10.0.0.1" or "10.0.0.1-10.0.0.10"'),
6342
6418
  to_ports: z23.string().optional().describe('Single port or range e.g. "8080" or "8080-8090"'),
6343
6419
  comment: z23.string().optional(),
@@ -6377,7 +6453,7 @@ var firewallNatTools = [
6377
6453
  } else if (a.chain === "dstnat" && !dstnatActions.includes(a.action)) {
6378
6454
  return `Error: Invalid action '${a.action}' for dstnat. Must be one of: ${dstnatActions.join(", ")}`;
6379
6455
  }
6380
- const cmd = new Cmd("/ip firewall nat add").set("chain", a.chain).set("action", a.action).opt("src-address", a.src_address).opt("dst-address", a.dst_address).opt("src-port", a.src_port).opt("dst-port", a.dst_port).opt("protocol", a.protocol).opt("in-interface", a.in_interface).opt("out-interface", a.out_interface).opt("to-addresses", a.to_addresses).opt("to-ports", a.to_ports).opt("comment", a.comment).flag("disabled", a.disabled).flag("log", a.log).opt("log-prefix", a.log ? a.log_prefix : undefined).opt("place-before", a.place_before).build();
6456
+ const cmd = new Cmd("/ip firewall nat add").set("chain", a.chain).set("action", a.action).opt("src-address", a.src_address).opt("dst-address", a.dst_address).opt("src-port", a.src_port).opt("dst-port", a.dst_port).opt("protocol", a.protocol).opt("in-interface", a.in_interface).opt("out-interface", a.out_interface).opt("in-interface-list", a.in_interface_list).opt("out-interface-list", a.out_interface_list).opt("src-address-list", a.src_address_list).opt("dst-address-list", a.dst_address_list).opt("connection-mark", a.connection_mark).opt("connection-nat-state", a.connection_nat_state).opt("to-addresses", a.to_addresses).opt("to-ports", a.to_ports).opt("comment", a.comment).flag("disabled", a.disabled).flag("log", a.log).opt("log-prefix", a.log ? a.log_prefix : undefined).opt("place-before", a.place_before).build();
6381
6457
  const result = await executeMikrotikCommand(cmd, ctx);
6382
6458
  const trimmed = result.trim();
6383
6459
  if (looksLikeError(trimmed)) {
@@ -6475,6 +6551,12 @@ ${result}`;
6475
6551
  protocol: z23.string().optional(),
6476
6552
  in_interface: z23.string().optional(),
6477
6553
  out_interface: z23.string().optional(),
6554
+ in_interface_list: z23.string().optional(),
6555
+ out_interface_list: z23.string().optional(),
6556
+ src_address_list: z23.string().optional().describe('Negate with "!name"'),
6557
+ dst_address_list: z23.string().optional().describe('Negate with "!name"'),
6558
+ connection_mark: z23.string().optional(),
6559
+ connection_nat_state: z23.string().optional(),
6478
6560
  to_addresses: z23.string().optional(),
6479
6561
  to_ports: z23.string().optional(),
6480
6562
  comment: z23.string().optional(),
@@ -6567,10 +6649,33 @@ async function updateMangleRule(a, ctx) {
6567
6649
  put("protocol", a.protocol);
6568
6650
  put("in-interface", a.in_interface);
6569
6651
  put("out-interface", a.out_interface);
6652
+ put("in-interface-list", a.in_interface_list);
6653
+ put("out-interface-list", a.out_interface_list);
6654
+ put("src-address-list", a.src_address_list);
6655
+ put("dst-address-list", a.dst_address_list);
6656
+ put("src-address-type", a.src_address_type);
6657
+ put("dst-address-type", a.dst_address_type);
6570
6658
  put("connection-mark", a.connection_mark);
6571
6659
  put("packet-mark", a.packet_mark);
6572
6660
  put("routing-mark", a.routing_mark);
6573
6661
  put("connection-state", a.connection_state);
6662
+ put("connection-nat-state", a.connection_nat_state);
6663
+ put("connection-type", a.connection_type);
6664
+ put("connection-bytes", a.connection_bytes);
6665
+ put("connection-limit", a.connection_limit);
6666
+ put("connection-rate", a.connection_rate);
6667
+ put("per-connection-classifier", a.per_connection_classifier);
6668
+ put("tcp-flags", a.tcp_flags);
6669
+ put("dscp", a.dscp);
6670
+ put("priority", a.priority);
6671
+ put("packet-size", a.packet_size);
6672
+ put("layer7-protocol", a.layer7_protocol);
6673
+ put("ipsec-policy", a.ipsec_policy);
6674
+ put("nth", a.nth);
6675
+ put("random", a.random);
6676
+ put("time", a.time);
6677
+ put("hotspot", a.hotspot);
6678
+ put("p2p", a.p2p);
6574
6679
  put("new-connection-mark", a.new_connection_mark);
6575
6680
  put("new-packet-mark", a.new_packet_mark);
6576
6681
  put("new-routing-mark", a.new_routing_mark);
@@ -6607,7 +6712,7 @@ var firewallMangleTools = [
6607
6712
  name: "create_mangle_rule",
6608
6713
  title: "Create IPv4 Firewall Mangle Rule",
6609
6714
  annotations: WRITE,
6610
- description: "Creates an IPv4 mangle rule (`/ip firewall mangle add`) \u2014 the packet-marking and header-modification table, " + "used to mark connections/packets/routing (for policy routing, QoS and per-connection classification) or to change DSCP/TTL/MSS. " + "For accept/drop decisions use create_filter_rule; for address translation use create_nat_rule; for IPv6 mangle use create_ipv6_mangle_rule. " + "chain: prerouting/input/forward/output/postrouting. " + "action: mark-connection/mark-packet/mark-routing/change-dscp/change-ttl/change-mss/add-src-to-address-list/add-dst-to-address-list/fasttrack-connection/route/set-priority/accept/etc. " + "Set the matching new-*-mark field for mark-* actions and keep passthrough=true so later rules can also match the same packet; " + "for add-*-to-address-list set address_list (and optionally address_list_timeout). " + "place_before accepts a rule number or ID (*N) to control insertion position. " + "Returns the created rule's detail including its `.id`.",
6715
+ description: "Creates an IPv4 mangle rule (`/ip firewall mangle add`) \u2014 the packet-marking and header-modification table, " + "used to mark connections/packets/routing (for policy routing, QoS and per-connection classification) or to change DSCP/TTL/MSS. " + "For accept/drop decisions use create_filter_rule; for address translation use create_nat_rule; for IPv6 mangle use create_ipv6_mangle_rule. " + "chain: prerouting/input/forward/output/postrouting. " + "action: mark-connection/mark-packet/mark-routing/change-dscp/change-ttl/change-mss/add-src-to-address-list/add-dst-to-address-list/fasttrack-connection/route/set-priority/accept/etc. " + "Set the matching new-*-mark field for mark-* actions and keep passthrough=true so later rules can also match the same packet; " + "for add-*-to-address-list set address_list (and optionally address_list_timeout). " + "Full match surface: address-lists (src_address_list/dst_address_list \u2014 negate with a leading '!', e.g. dst_address_list='!IR' to route traffic NOT bound for a list), " + "interface-lists, per_connection_classifier (PCC load-balancing), connection_nat_state, tcp_flags, dscp, layer7_protocol, time, and more. " + "Typical policy-routing rule: chain=prerouting, src_address=<lan>, dst_address_list='!IR', action=mark-routing, new_routing_mark=<table>, then a routing rule/route uses that table. " + "place_before accepts a rule number or ID (*N) to control insertion position. " + "Returns the created rule's detail including its `.id`.",
6611
6716
  inputSchema: {
6612
6717
  chain: z24.enum(["prerouting", "input", "forward", "output", "postrouting"]),
6613
6718
  action: z24.enum([
@@ -6637,12 +6742,35 @@ var firewallMangleTools = [
6637
6742
  src_port: z24.string().optional(),
6638
6743
  dst_port: z24.string().optional(),
6639
6744
  protocol: z24.string().optional(),
6640
- in_interface: z24.string().optional(),
6745
+ in_interface: z24.string().optional().describe('Negatable, e.g. "!ether1"'),
6641
6746
  out_interface: z24.string().optional(),
6747
+ in_interface_list: z24.string().optional().describe('Interface list, negatable e.g. "!WAN"'),
6748
+ out_interface_list: z24.string().optional(),
6749
+ src_address_list: z24.string().optional().describe('Match src in a named list; negate "!name"'),
6750
+ dst_address_list: z24.string().optional().describe('Match dst in a named list; negate "!name" (e.g. "!IR" routes foreign traffic)'),
6751
+ src_address_type: z24.string().optional().describe('e.g. "local", "unicast", "!local"'),
6752
+ dst_address_type: z24.string().optional(),
6642
6753
  connection_mark: z24.string().optional(),
6643
6754
  packet_mark: z24.string().optional(),
6644
6755
  routing_mark: z24.string().optional(),
6645
- connection_state: z24.string().optional().describe('e.g. "new", "established,related", "invalid"'),
6756
+ connection_state: z24.string().optional().describe('e.g. "new", "established,related", "!invalid"'),
6757
+ connection_nat_state: z24.string().optional().describe('"srcnat" / "dstnat" / "!dstnat"'),
6758
+ connection_type: z24.string().optional().describe('Helper, e.g. "sip", "ftp"'),
6759
+ connection_bytes: z24.string().optional().describe('e.g. "1000000-0" (>1 MB connections)'),
6760
+ connection_limit: z24.string().optional().describe('e.g. "100,32"'),
6761
+ connection_rate: z24.string().optional().describe('e.g. "100k-1M"'),
6762
+ per_connection_classifier: z24.string().optional().describe('PCC for load balancing, e.g. "both-addresses:2/0"'),
6763
+ tcp_flags: z24.string().optional().describe('RouterOS flag expression e.g. "syn,!ack"'),
6764
+ dscp: z24.string().optional().describe("Match incoming DSCP (0-63)"),
6765
+ priority: z24.string().optional().describe("Match packet/queue priority (0-63)"),
6766
+ packet_size: z24.string().optional().describe('e.g. "1500" or "0-500"'),
6767
+ layer7_protocol: z24.string().optional().describe("Name of an /ip firewall layer7-protocol regex"),
6768
+ ipsec_policy: z24.string().optional().describe('e.g. "in,ipsec" or "out,none"'),
6769
+ nth: z24.string().optional().describe('e.g. "2,1" \u2014 every 2nd packet'),
6770
+ random: z24.string().optional().describe("Match a random N% of packets (1-99)"),
6771
+ time: z24.string().optional().describe('e.g. "8h-16h,mon,tue,wed,thu,fri"'),
6772
+ hotspot: z24.string().optional().describe('e.g. "auth", "!auth", "from-client"'),
6773
+ p2p: z24.string().optional(),
6646
6774
  new_connection_mark: z24.string().optional(),
6647
6775
  new_packet_mark: z24.string().optional(),
6648
6776
  new_routing_mark: z24.string().optional(),
@@ -6661,7 +6789,7 @@ var firewallMangleTools = [
6661
6789
  },
6662
6790
  async handler(a, ctx) {
6663
6791
  ctx.info(`Creating mangle rule: chain=${a.chain}, action=${a.action}`);
6664
- const cmd = new Cmd("/ip firewall mangle add").set("chain", a.chain).set("action", a.action).opt("src-address", a.src_address).opt("dst-address", a.dst_address).opt("src-port", a.src_port).opt("dst-port", a.dst_port).opt("protocol", a.protocol).opt("in-interface", a.in_interface).opt("out-interface", a.out_interface).opt("connection-mark", a.connection_mark).opt("packet-mark", a.packet_mark).opt("routing-mark", a.routing_mark).opt("connection-state", a.connection_state).opt("new-connection-mark", a.new_connection_mark).opt("new-packet-mark", a.new_packet_mark).opt("new-routing-mark", a.new_routing_mark).opt("new-dscp", a.new_dscp).opt("new-ttl", a.new_ttl).opt("new-mss", a.new_mss).opt("routing-table", a.routing_table).opt("address-list", a.address_list).opt("address-list-timeout", a.address_list_timeout).bool("passthrough", a.passthrough).opt("comment", a.comment).flag("disabled", a.disabled).flag("log", a.log).opt("log-prefix", a.log ? a.log_prefix : undefined).opt("place-before", a.place_before).build();
6792
+ const cmd = new Cmd("/ip firewall mangle add").set("chain", a.chain).set("action", a.action).opt("src-address", a.src_address).opt("dst-address", a.dst_address).opt("src-port", a.src_port).opt("dst-port", a.dst_port).opt("protocol", a.protocol).opt("in-interface", a.in_interface).opt("out-interface", a.out_interface).opt("in-interface-list", a.in_interface_list).opt("out-interface-list", a.out_interface_list).opt("src-address-list", a.src_address_list).opt("dst-address-list", a.dst_address_list).opt("src-address-type", a.src_address_type).opt("dst-address-type", a.dst_address_type).opt("connection-mark", a.connection_mark).opt("packet-mark", a.packet_mark).opt("routing-mark", a.routing_mark).opt("connection-state", a.connection_state).opt("connection-nat-state", a.connection_nat_state).opt("connection-type", a.connection_type).opt("connection-bytes", a.connection_bytes).opt("connection-limit", a.connection_limit).opt("connection-rate", a.connection_rate).opt("per-connection-classifier", a.per_connection_classifier).opt("tcp-flags", a.tcp_flags).opt("dscp", a.dscp).opt("priority", a.priority).opt("packet-size", a.packet_size).opt("layer7-protocol", a.layer7_protocol).opt("ipsec-policy", a.ipsec_policy).opt("nth", a.nth).opt("random", a.random).opt("time", a.time).opt("hotspot", a.hotspot).opt("p2p", a.p2p).opt("new-connection-mark", a.new_connection_mark).opt("new-packet-mark", a.new_packet_mark).opt("new-routing-mark", a.new_routing_mark).opt("new-dscp", a.new_dscp).opt("new-ttl", a.new_ttl).opt("new-mss", a.new_mss).opt("routing-table", a.routing_table).opt("address-list", a.address_list).opt("address-list-timeout", a.address_list_timeout).bool("passthrough", a.passthrough).opt("comment", a.comment).flag("disabled", a.disabled).flag("log", a.log).opt("log-prefix", a.log ? a.log_prefix : undefined).opt("place-before", a.place_before).build();
6665
6793
  const result = await executeMikrotikCommand(cmd, ctx);
6666
6794
  const trimmed = result.trim();
6667
6795
  if (looksLikeError(trimmed)) {
@@ -6755,10 +6883,33 @@ ${result}`;
6755
6883
  protocol: z24.string().optional(),
6756
6884
  in_interface: z24.string().optional(),
6757
6885
  out_interface: z24.string().optional(),
6886
+ in_interface_list: z24.string().optional(),
6887
+ out_interface_list: z24.string().optional(),
6888
+ src_address_list: z24.string().optional().describe('Negate with "!name" (e.g. "!IR")'),
6889
+ dst_address_list: z24.string().optional().describe('Negate with "!name" (e.g. "!IR")'),
6890
+ src_address_type: z24.string().optional(),
6891
+ dst_address_type: z24.string().optional(),
6758
6892
  connection_mark: z24.string().optional(),
6759
6893
  packet_mark: z24.string().optional(),
6760
6894
  routing_mark: z24.string().optional(),
6761
6895
  connection_state: z24.string().optional(),
6896
+ connection_nat_state: z24.string().optional(),
6897
+ connection_type: z24.string().optional(),
6898
+ connection_bytes: z24.string().optional(),
6899
+ connection_limit: z24.string().optional(),
6900
+ connection_rate: z24.string().optional(),
6901
+ per_connection_classifier: z24.string().optional(),
6902
+ tcp_flags: z24.string().optional(),
6903
+ dscp: z24.string().optional(),
6904
+ priority: z24.string().optional(),
6905
+ packet_size: z24.string().optional(),
6906
+ layer7_protocol: z24.string().optional(),
6907
+ ipsec_policy: z24.string().optional(),
6908
+ nth: z24.string().optional(),
6909
+ random: z24.string().optional(),
6910
+ time: z24.string().optional(),
6911
+ hotspot: z24.string().optional(),
6912
+ p2p: z24.string().optional(),
6762
6913
  new_connection_mark: z24.string().optional(),
6763
6914
  new_packet_mark: z24.string().optional(),
6764
6915
  new_routing_mark: z24.string().optional(),
@@ -23449,7 +23600,10 @@ async function featureRoutes(req, url) {
23449
23600
  const ctx = createContext(undefined, b?.device);
23450
23601
  const r = await createLocalBackup(ctx, {
23451
23602
  label: b?.label,
23452
- showSensitive: b?.show_sensitive === true
23603
+ showSensitive: b?.show_sensitive === true,
23604
+ verbose: b?.verbose === true,
23605
+ compact: b?.compact === true,
23606
+ terse: b?.terse === true
23453
23607
  });
23454
23608
  return r.ok ? json(r) : json({ error: r.error ?? "export failed" }, 502);
23455
23609
  }
@@ -23768,7 +23922,7 @@ function registerPrompts(server) {
23768
23922
  // package.json
23769
23923
  var package_default = {
23770
23924
  name: "@usex/mikrotik-mcp",
23771
- version: "3.18.0",
23925
+ version: "3.20.0",
23772
23926
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
23773
23927
  keywords: [
23774
23928
  "ai",
package/dist/index.d.ts CHANGED
@@ -185,6 +185,14 @@ declare class SafeModeManager {
185
185
  * (safe-mode marker present/absent), not merely the first prompt-shaped line.
186
186
  */
187
187
  private readUntilPrompt;
188
+ /**
189
+ * Read until the output SETTLES: once any prompt is visible, wait for a quiet
190
+ * gap (`quietMs` with no new bytes) before resolving, or give up at `maxMs`.
191
+ * Used by commit, where Ctrl+X + Enter can emit a transient `<SAFE>` redraw
192
+ * followed by the real post-commit prompt — settling on the LAST prompt after
193
+ * a quiet period is what makes mode detection reliable.
194
+ */
195
+ private readSettledPrompt;
188
196
  private extractOutput;
189
197
  private cleanup;
190
198
  }