@usex/mikrotik-mcp 3.21.0 → 3.23.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
@@ -239,13 +239,38 @@ function getConfig() {
239
239
  function listDevices() {
240
240
  return { names: Object.keys(active.devices), default: active.defaultDevice };
241
241
  }
242
+ function deviceKeyForLabel(name) {
243
+ const target = name.trim().toLowerCase();
244
+ for (const [key, dc] of Object.entries(active.devices)) {
245
+ if (dc.description && dc.description.trim().toLowerCase() === target)
246
+ return key;
247
+ }
248
+ return;
249
+ }
250
+ function deviceLabels() {
251
+ const seen = new Set;
252
+ const out = [];
253
+ for (const [key, dc] of Object.entries(active.devices)) {
254
+ const label = dc.description?.trim();
255
+ if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
256
+ seen.add(label);
257
+ out.push(label);
258
+ }
259
+ }
260
+ return out;
261
+ }
242
262
  function resolveDeviceName(name) {
243
- if (name && name in active.devices)
244
- return name;
263
+ if (name) {
264
+ if (name in active.devices)
265
+ return name;
266
+ const byLabel = deviceKeyForLabel(name);
267
+ if (byLabel)
268
+ return byLabel;
269
+ }
245
270
  return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
246
271
  }
247
272
  function getDevice(name) {
248
- if (name && !(name in active.devices)) {
273
+ if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
249
274
  throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
250
275
  }
251
276
  const key = resolveDeviceName(name);
@@ -894,31 +919,39 @@ class SafeModeManager {
894
919
  }
895
920
  commit() {
896
921
  return this.lock(async () => {
897
- if (!this.active || !this.channel)
898
- return "Safe mode is not active. Nothing to commit.";
899
- this.channel.write(`
900
- `);
901
- const probe = await this.readSettledPrompt();
902
- const before = classifyPrompt(probe);
922
+ if (!this.active || !this.channel) {
923
+ return { ok: true, message: "Safe mode is not active. Nothing to commit." };
924
+ }
925
+ const before = await this.probeMode();
903
926
  if (before === "released") {
904
927
  this.cleanup();
905
- return "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED.";
928
+ return {
929
+ ok: true,
930
+ message: "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED."
931
+ };
906
932
  }
907
933
  if (before === "unknown") {
908
- 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)}`;
934
+ return {
935
+ ok: false,
936
+ message: "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."
937
+ };
909
938
  }
910
939
  this.channel.write(CTRL_X);
911
- this.channel.write(`
912
- `);
913
- const after = await this.readSettledPrompt();
914
- switch (classifyPrompt(after)) {
940
+ const after = await this.probeMode();
941
+ switch (after) {
915
942
  case "released":
916
943
  this.cleanup();
917
- return "Changes committed successfully. Safe mode DISABLED.";
944
+ return { ok: true, message: "Changes committed successfully. Safe mode DISABLED." };
918
945
  case "safe":
919
- 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.";
946
+ return {
947
+ ok: false,
948
+ message: "Commit NOT completed \u2014 the device is still in Safe Mode (the Ctrl+X commit did not " + "take). Your changes remain held in memory and are NOT yet saved; call commit_safe_mode " + "again to retry, or rollback_safe_mode to discard them. If retries keep failing, this " + "RouterOS build may not accept an interactive Safe Mode commit over SSH \u2014 apply the " + "change without Safe Mode instead."
949
+ };
920
950
  default:
921
- 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)}`;
951
+ return {
952
+ ok: false,
953
+ message: "Commit status unclear \u2014 no prompt seen after the commit. The session is left open so " + "nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode."
954
+ };
922
955
  }
923
956
  });
924
957
  }
@@ -955,32 +988,17 @@ class SafeModeManager {
955
988
  channel.on("data", onData);
956
989
  });
957
990
  }
958
- readSettledPrompt(quietMs = 450, maxMs = 8000) {
959
- const channel = this.channel;
960
- if (!channel)
961
- return Promise.resolve("");
962
- return new Promise((resolve2) => {
963
- let buf = "";
964
- let quiet;
965
- let hard;
966
- function done() {
967
- clearTimeout(hard);
968
- if (quiet)
969
- clearTimeout(quiet);
970
- channel.removeListener("data", onData);
971
- resolve2(stripAnsi(buf));
972
- }
973
- function onData(chunk) {
974
- buf += decodeOutput(chunk);
975
- if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
976
- if (quiet)
977
- clearTimeout(quiet);
978
- quiet = setTimeout(done, quietMs);
979
- }
980
- }
981
- hard = setTimeout(done, maxMs);
982
- channel.on("data", onData);
991
+ async probeMode() {
992
+ if (!this.channel)
993
+ return "unknown";
994
+ const token = "__MCP_SAFEMODE_PROBE__";
995
+ this.channel.write(`:put "${token}"
996
+ `);
997
+ const out = await this.readUntilPrompt(8000, (cleaned) => {
998
+ const i = cleaned.lastIndexOf(token);
999
+ return i >= 0 && PROMPT_RE.test(cleaned.slice(i));
983
1000
  });
1001
+ return classifyPrompt(out);
984
1002
  }
985
1003
  extractOutput(raw, command) {
986
1004
  const text = raw.replace(/\r\n/g, `
@@ -1654,6 +1672,7 @@ function recordToolCall(raw) {
1654
1672
 
1655
1673
  // src/core/registry.ts
1656
1674
  var AUTO_RECORDS_VERB = /^(list|get|show|print)_/;
1675
+ var UI_OUTPUT_SCHEMA = z2.object({}).passthrough();
1657
1676
  function effectiveUi(def) {
1658
1677
  if (def.ui)
1659
1678
  return { ui: def.ui, auto: false };
@@ -1694,12 +1713,13 @@ function defineTool(def) {
1694
1713
  inputSchema: def.inputSchema,
1695
1714
  ui: def.ui,
1696
1715
  register(server, opts = {}) {
1697
- const { sendLog, deviceNames } = opts;
1716
+ const { sendLog, deviceNames, deviceAliases } = opts;
1698
1717
  const multiDevice = !!deviceNames && deviceNames.length > 1;
1699
1718
  const { ui, auto } = effectiveUi(def);
1719
+ const selectorNames = multiDevice && deviceNames ? [...new Set([...deviceNames, ...deviceAliases ?? []])] : [];
1700
1720
  const inputSchema = multiDevice ? {
1701
1721
  ...def.inputSchema,
1702
- device: z2.enum(deviceNames).optional().describe(`Which configured MikroTik device to run this on. One of: ${deviceNames.join(", ")}. Omit to use the default device.`)
1722
+ device: z2.enum(selectorNames).optional().describe(`Which configured MikroTik device to run this on. One of: ${selectorNames.join(", ")} (a config key or its label). Omit to use the default device.`)
1703
1723
  } : def.inputSchema;
1704
1724
  const risk = riskOf(def.annotations);
1705
1725
  const callback = async (args) => {
@@ -1722,7 +1742,10 @@ function defineTool(def) {
1722
1742
  return { content: [{ type: "text", text: out.text }], isError: true };
1723
1743
  }
1724
1744
  if (auto && !out.structuredContent) {
1725
- out.structuredContent = buildRecordsView(def.name, def.title, out.text, new Date().toISOString());
1745
+ const view = buildRecordsView(def.name, def.title, out.text, new Date().toISOString());
1746
+ if (view.rows.length > 0) {
1747
+ out.structuredContent = view;
1748
+ }
1726
1749
  }
1727
1750
  const result = {
1728
1751
  content: [{ type: "text", text: out.text }]
@@ -1765,7 +1788,8 @@ function defineTool(def) {
1765
1788
  description: def.description,
1766
1789
  inputSchema,
1767
1790
  annotations: { ...def.annotations, title: def.title },
1768
- ...ui ? { _meta: toolUiMeta(ui) } : {}
1791
+ ...ui ? { _meta: toolUiMeta(ui) } : {},
1792
+ ...ui && !auto ? { outputSchema: UI_OUTPUT_SCHEMA } : {}
1769
1793
  }, callback);
1770
1794
  }
1771
1795
  };
@@ -2632,11 +2656,19 @@ async function restoreLocalBackup(device, name, confirm) {
2632
2656
  return fail("device stopped responding after applying \u2014 rolled back to avoid a lock-out", applied);
2633
2657
  }
2634
2658
  const committed = await safe.commit();
2659
+ if (!committed.ok) {
2660
+ return {
2661
+ ok: false,
2662
+ applied,
2663
+ committed: false,
2664
+ message: `applied ${applied} command(s) but COMMIT FAILED \u2014 not saved: ${committed.message}`
2665
+ };
2666
+ }
2635
2667
  return {
2636
2668
  ok: true,
2637
2669
  applied,
2638
2670
  committed: true,
2639
- message: `committed ${applied} command(s). ${committed}`
2671
+ message: `committed ${applied} command(s). ${committed.message}`
2640
2672
  };
2641
2673
  } catch (e) {
2642
2674
  await safe.rollback();
@@ -3870,9 +3902,14 @@ DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
3870
3902
  ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
3871
3903
  }
3872
3904
  const committed = await safe.commit();
3905
+ if (!committed.ok) {
3906
+ return `${header}
3907
+
3908
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
3909
+ }
3873
3910
  return `${header}
3874
3911
 
3875
- COMMITTED: changes are now permanent. ${committed}`;
3912
+ COMMITTED: changes are now permanent. ${committed.message}`;
3876
3913
  } catch (e) {
3877
3914
  await safe.rollback();
3878
3915
  const msg = e instanceof Error ? e.message : String(e);
@@ -5935,8 +5972,12 @@ ${useSafe ? "Safe Mode rolled back \u2014 no changes kept." : "Partial rules rem
5935
5972
  }
5936
5973
  done.push(cmd);
5937
5974
  }
5938
- if (useSafe)
5939
- await mgr.commit();
5975
+ if (useSafe) {
5976
+ const c = await mgr.commit();
5977
+ if (!c.ok) {
5978
+ return `Applied ${done.length} rule(s) but Safe Mode COMMIT FAILED \u2014 changes are NOT saved ` + `and will revert: ${c.message}`;
5979
+ }
5980
+ }
5940
5981
  return `Security Shield applied \u2014 ${done.length} rule(s) across ${groups.length} protection(s)${useSafe ? " (committed via Safe Mode)" : ""}. Audit with audit_firewall_hardening; undo with remove_firewall_hardening.`;
5941
5982
  }
5942
5983
  }),
@@ -13512,7 +13553,7 @@ ${redactSecrets(result)}`;
13512
13553
 
13513
13554
  // src/tools/poe.ts
13514
13555
  import { z as z69 } from "zod";
13515
- var NO_POE = "This device does not have PoE-out hardware \u2014 the /interface ethernet poe menu is not available.";
13556
+ var NO_POE = "This device has no PoE-OUT hardware, so the `/interface ethernet poe` menu " + "(used to SUPPLY power to other devices) is not available \u2014 this is normal, not an error. " + "Note: PoE-OUT (this router powering other devices) is different from PoE-IN (this router being " + "powered over Ethernet). If your device is PoE-powered, read its PoE-IN voltage/state with " + "get_system_health (`/system health`), not the PoE tools.";
13516
13557
  var poeTools = [
13517
13558
  defineTool({
13518
13559
  name: "get_poe_monitor",
@@ -17583,7 +17624,7 @@ var safeModeTools = [
17583
17624
  async handler(_a, ctx) {
17584
17625
  const device = resolveDeviceName(ctx.device);
17585
17626
  ctx.info(`[${device}] Committing safe mode changes`);
17586
- return getSafeModeManager(device).commit();
17627
+ return (await getSafeModeManager(device).commit()).message;
17587
17628
  }
17588
17629
  }),
17589
17630
  defineTool({
@@ -23951,7 +23992,7 @@ function registerPrompts(server) {
23951
23992
  // package.json
23952
23993
  var package_default = {
23953
23994
  name: "@usex/mikrotik-mcp",
23954
- version: "3.21.0",
23995
+ version: "3.23.0",
23955
23996
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
23956
23997
  keywords: [
23957
23998
  "ai",
@@ -24155,6 +24196,7 @@ function createServer(opts = {}) {
24155
24196
  const toolCount = registerTools(server, toolModules, {
24156
24197
  sendLog,
24157
24198
  deviceNames: names,
24199
+ deviceAliases: deviceLabels(),
24158
24200
  readOnly
24159
24201
  });
24160
24202
  const promptCount = registerPrompts(server);
package/dist/index.d.ts CHANGED
@@ -49,9 +49,16 @@ interface UiLink {
49
49
  /** Options threaded into every tool registration. */
50
50
  interface RegisterOptions {
51
51
  sendLog?: SendLog;
52
- /** Configured device names; when more than one, a `device` selector is injected. */
52
+ /** Configured device KEYS; when more than one, a `device` selector is injected. */
53
53
  deviceNames?: string[];
54
54
  /**
55
+ * Extra accepted values for the `device` selector — a device's free-text
56
+ * label (`description`, e.g. "Ali Home") so the AI can target it by the
57
+ * friendly name as well as its config key. Resolved back to the key by
58
+ * {@link resolveDeviceName}. Does NOT affect the single-vs-multi decision.
59
+ */
60
+ deviceAliases?: string[];
61
+ /**
55
62
  * Read-only mode: register only tools annotated `readOnlyHint`. Used to
56
63
  * withhold every write/destructive tool from a publicly-exposed surface (e.g.
57
64
  * a ChatGPT Apps connector) until authentication is in place.
@@ -110,11 +117,15 @@ declare function listDevices(): {
110
117
  names: string[];
111
118
  default: string;
112
119
  };
113
- /** Resolve a (possibly undefined) device name to a concrete, existing name. */
120
+ /**
121
+ * Resolve a (possibly undefined) device name to a concrete, existing config key.
122
+ * Accepts a config key OR a device's free-text label; falls back to the default.
123
+ */
114
124
  declare function resolveDeviceName(name?: string): string;
115
125
  /**
116
- * Return the connection config for a device by name (or the default when
117
- * `name` is undefined). Throws if an explicit, unknown name is given.
126
+ * Return the connection config for a device by key or label (or the default when
127
+ * `name` is undefined). Throws if an explicit name matches neither a key nor a
128
+ * label.
118
129
  */
119
130
  declare function getDevice(name?: string): DeviceConfig;
120
131
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -163,6 +174,16 @@ declare class MikroTikSSHClient {
163
174
  /** Close the SSH connection. Safe to call multiple times. */
164
175
  disconnect(): void;
165
176
  }
177
+ /**
178
+ * Outcome of {@link SafeModeManager.commit}. `ok` is true ONLY when Safe Mode is
179
+ * confirmed exited (changes persisted) — callers must check it before telling
180
+ * the user anything was committed, so a failed commit can never masquerade as a
181
+ * success that then silently reverts.
182
+ */
183
+ interface CommitResult {
184
+ ok: boolean;
185
+ message: string;
186
+ }
166
187
  declare class SafeModeManager {
167
188
  private readonly deviceName;
168
189
  private ssh;
@@ -179,8 +200,12 @@ declare class SafeModeManager {
179
200
  enable(): Promise<string>;
180
201
  /** Execute a command through the safe-mode persistent shell session. */
181
202
  execute(command: string): Promise<string>;
182
- /** Send Ctrl+X again to exit Safe Mode and persist all changes. */
183
- commit(): Promise<string>;
203
+ /**
204
+ * Send Ctrl+X again to exit Safe Mode and persist all changes. Returns a
205
+ * structured result so callers NEVER report "committed" on a failed commit —
206
+ * `ok` is true only when Safe Mode is confirmed exited (changes saved).
207
+ */
208
+ commit(): Promise<CommitResult>;
184
209
  /** Close the session to trigger MikroTik's automatic safe-mode revert. */
185
210
  rollback(): Promise<string>;
186
211
  status(): string;
@@ -192,13 +217,14 @@ declare class SafeModeManager {
192
217
  */
193
218
  private readUntilPrompt;
194
219
  /**
195
- * Read until the output SETTLES: once any prompt is visible, wait for a quiet
196
- * gap (`quietMs` with no new bytes) before resolving, or give up at `maxMs`.
197
- * Used by commit, where Ctrl+X + Enter can emit a transient `<SAFE>` redraw
198
- * followed by the real post-commit prompt settling on the LAST prompt after
199
- * a quiet period is what makes mode detection reliable.
220
+ * Determine the shell's CURRENT mode definitively by round-tripping a sentinel
221
+ * command (`:put "<token>"`) and classifying the prompt that follows ITS
222
+ * output. A real command not a bare Enter forces RouterOS to fully process
223
+ * any pending Ctrl+X and render a prompt that reflects the true mode, so we
224
+ * never settle on a transient `<SAFE>`→normal redraw and report a commit that
225
+ * did not actually take. Returns `safe`, `released`, or `unknown` (no prompt).
200
226
  */
201
- private readSettledPrompt;
227
+ private probeMode;
202
228
  private extractOutput;
203
229
  private cleanup;
204
230
  }
package/dist/index.js CHANGED
@@ -231,13 +231,38 @@ function getConfig() {
231
231
  function listDevices() {
232
232
  return { names: Object.keys(active.devices), default: active.defaultDevice };
233
233
  }
234
+ function deviceKeyForLabel(name) {
235
+ const target = name.trim().toLowerCase();
236
+ for (const [key, dc] of Object.entries(active.devices)) {
237
+ if (dc.description && dc.description.trim().toLowerCase() === target)
238
+ return key;
239
+ }
240
+ return;
241
+ }
242
+ function deviceLabels() {
243
+ const seen = new Set;
244
+ const out = [];
245
+ for (const [key, dc] of Object.entries(active.devices)) {
246
+ const label = dc.description?.trim();
247
+ if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
248
+ seen.add(label);
249
+ out.push(label);
250
+ }
251
+ }
252
+ return out;
253
+ }
234
254
  function resolveDeviceName(name) {
235
- if (name && name in active.devices)
236
- return name;
255
+ if (name) {
256
+ if (name in active.devices)
257
+ return name;
258
+ const byLabel = deviceKeyForLabel(name);
259
+ if (byLabel)
260
+ return byLabel;
261
+ }
237
262
  return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
238
263
  }
239
264
  function getDevice(name) {
240
- if (name && !(name in active.devices)) {
265
+ if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
241
266
  throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
242
267
  }
243
268
  const key = resolveDeviceName(name);
@@ -880,31 +905,39 @@ class SafeModeManager {
880
905
  }
881
906
  commit() {
882
907
  return this.lock(async () => {
883
- if (!this.active || !this.channel)
884
- return "Safe mode is not active. Nothing to commit.";
885
- this.channel.write(`
886
- `);
887
- const probe = await this.readSettledPrompt();
888
- const before = classifyPrompt(probe);
908
+ if (!this.active || !this.channel) {
909
+ return { ok: true, message: "Safe mode is not active. Nothing to commit." };
910
+ }
911
+ const before = await this.probeMode();
889
912
  if (before === "released") {
890
913
  this.cleanup();
891
- return "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED.";
914
+ return {
915
+ ok: true,
916
+ message: "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED."
917
+ };
892
918
  }
893
919
  if (before === "unknown") {
894
- 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)}`;
920
+ return {
921
+ ok: false,
922
+ message: "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."
923
+ };
895
924
  }
896
925
  this.channel.write(CTRL_X);
897
- this.channel.write(`
898
- `);
899
- const after = await this.readSettledPrompt();
900
- switch (classifyPrompt(after)) {
926
+ const after = await this.probeMode();
927
+ switch (after) {
901
928
  case "released":
902
929
  this.cleanup();
903
- return "Changes committed successfully. Safe mode DISABLED.";
930
+ return { ok: true, message: "Changes committed successfully. Safe mode DISABLED." };
904
931
  case "safe":
905
- 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.";
932
+ return {
933
+ ok: false,
934
+ message: "Commit NOT completed \u2014 the device is still in Safe Mode (the Ctrl+X commit did not " + "take). Your changes remain held in memory and are NOT yet saved; call commit_safe_mode " + "again to retry, or rollback_safe_mode to discard them. If retries keep failing, this " + "RouterOS build may not accept an interactive Safe Mode commit over SSH \u2014 apply the " + "change without Safe Mode instead."
935
+ };
906
936
  default:
907
- 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)}`;
937
+ return {
938
+ ok: false,
939
+ message: "Commit status unclear \u2014 no prompt seen after the commit. The session is left open so " + "nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode."
940
+ };
908
941
  }
909
942
  });
910
943
  }
@@ -941,32 +974,17 @@ class SafeModeManager {
941
974
  channel.on("data", onData);
942
975
  });
943
976
  }
944
- readSettledPrompt(quietMs = 450, maxMs = 8000) {
945
- const channel = this.channel;
946
- if (!channel)
947
- return Promise.resolve("");
948
- return new Promise((resolve2) => {
949
- let buf = "";
950
- let quiet;
951
- let hard;
952
- function done() {
953
- clearTimeout(hard);
954
- if (quiet)
955
- clearTimeout(quiet);
956
- channel.removeListener("data", onData);
957
- resolve2(stripAnsi(buf));
958
- }
959
- function onData(chunk) {
960
- buf += decodeOutput(chunk);
961
- if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
962
- if (quiet)
963
- clearTimeout(quiet);
964
- quiet = setTimeout(done, quietMs);
965
- }
966
- }
967
- hard = setTimeout(done, maxMs);
968
- channel.on("data", onData);
977
+ async probeMode() {
978
+ if (!this.channel)
979
+ return "unknown";
980
+ const token = "__MCP_SAFEMODE_PROBE__";
981
+ this.channel.write(`:put "${token}"
982
+ `);
983
+ const out = await this.readUntilPrompt(8000, (cleaned) => {
984
+ const i = cleaned.lastIndexOf(token);
985
+ return i >= 0 && PROMPT_RE.test(cleaned.slice(i));
969
986
  });
987
+ return classifyPrompt(out);
970
988
  }
971
989
  extractOutput(raw, command) {
972
990
  const text = raw.replace(/\r\n/g, `
@@ -1560,6 +1578,7 @@ function recordToolCall(raw) {
1560
1578
 
1561
1579
  // src/core/registry.ts
1562
1580
  var AUTO_RECORDS_VERB = /^(list|get|show|print)_/;
1581
+ var UI_OUTPUT_SCHEMA = z2.object({}).passthrough();
1563
1582
  function effectiveUi(def) {
1564
1583
  if (def.ui)
1565
1584
  return { ui: def.ui, auto: false };
@@ -1600,12 +1619,13 @@ function defineTool(def) {
1600
1619
  inputSchema: def.inputSchema,
1601
1620
  ui: def.ui,
1602
1621
  register(server, opts = {}) {
1603
- const { sendLog, deviceNames } = opts;
1622
+ const { sendLog, deviceNames, deviceAliases } = opts;
1604
1623
  const multiDevice = !!deviceNames && deviceNames.length > 1;
1605
1624
  const { ui, auto } = effectiveUi(def);
1625
+ const selectorNames = multiDevice && deviceNames ? [...new Set([...deviceNames, ...deviceAliases ?? []])] : [];
1606
1626
  const inputSchema = multiDevice ? {
1607
1627
  ...def.inputSchema,
1608
- device: z2.enum(deviceNames).optional().describe(`Which configured MikroTik device to run this on. One of: ${deviceNames.join(", ")}. Omit to use the default device.`)
1628
+ device: z2.enum(selectorNames).optional().describe(`Which configured MikroTik device to run this on. One of: ${selectorNames.join(", ")} (a config key or its label). Omit to use the default device.`)
1609
1629
  } : def.inputSchema;
1610
1630
  const risk = riskOf(def.annotations);
1611
1631
  const callback = async (args) => {
@@ -1628,7 +1648,10 @@ function defineTool(def) {
1628
1648
  return { content: [{ type: "text", text: out.text }], isError: true };
1629
1649
  }
1630
1650
  if (auto && !out.structuredContent) {
1631
- out.structuredContent = buildRecordsView(def.name, def.title, out.text, new Date().toISOString());
1651
+ const view = buildRecordsView(def.name, def.title, out.text, new Date().toISOString());
1652
+ if (view.rows.length > 0) {
1653
+ out.structuredContent = view;
1654
+ }
1632
1655
  }
1633
1656
  const result = {
1634
1657
  content: [{ type: "text", text: out.text }]
@@ -1671,7 +1694,8 @@ function defineTool(def) {
1671
1694
  description: def.description,
1672
1695
  inputSchema,
1673
1696
  annotations: { ...def.annotations, title: def.title },
1674
- ...ui ? { _meta: toolUiMeta(ui) } : {}
1697
+ ...ui ? { _meta: toolUiMeta(ui) } : {},
1698
+ ...ui && !auto ? { outputSchema: UI_OUTPUT_SCHEMA } : {}
1675
1699
  }, callback);
1676
1700
  }
1677
1701
  };
@@ -2644,11 +2668,19 @@ async function restoreLocalBackup(device, name, confirm) {
2644
2668
  return fail("device stopped responding after applying \u2014 rolled back to avoid a lock-out", applied);
2645
2669
  }
2646
2670
  const committed = await safe.commit();
2671
+ if (!committed.ok) {
2672
+ return {
2673
+ ok: false,
2674
+ applied,
2675
+ committed: false,
2676
+ message: `applied ${applied} command(s) but COMMIT FAILED \u2014 not saved: ${committed.message}`
2677
+ };
2678
+ }
2647
2679
  return {
2648
2680
  ok: true,
2649
2681
  applied,
2650
2682
  committed: true,
2651
- message: `committed ${applied} command(s). ${committed}`
2683
+ message: `committed ${applied} command(s). ${committed.message}`
2652
2684
  };
2653
2685
  } catch (e) {
2654
2686
  await safe.rollback();
@@ -3882,9 +3914,14 @@ DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
3882
3914
  ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
3883
3915
  }
3884
3916
  const committed = await safe.commit();
3917
+ if (!committed.ok) {
3918
+ return `${header}
3919
+
3920
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
3921
+ }
3885
3922
  return `${header}
3886
3923
 
3887
- COMMITTED: changes are now permanent. ${committed}`;
3924
+ COMMITTED: changes are now permanent. ${committed.message}`;
3888
3925
  } catch (e) {
3889
3926
  await safe.rollback();
3890
3927
  const msg = e instanceof Error ? e.message : String(e);
@@ -5947,8 +5984,12 @@ ${useSafe ? "Safe Mode rolled back \u2014 no changes kept." : "Partial rules rem
5947
5984
  }
5948
5985
  done.push(cmd);
5949
5986
  }
5950
- if (useSafe)
5951
- await mgr.commit();
5987
+ if (useSafe) {
5988
+ const c = await mgr.commit();
5989
+ if (!c.ok) {
5990
+ return `Applied ${done.length} rule(s) but Safe Mode COMMIT FAILED \u2014 changes are NOT saved ` + `and will revert: ${c.message}`;
5991
+ }
5992
+ }
5952
5993
  return `Security Shield applied \u2014 ${done.length} rule(s) across ${groups.length} protection(s)${useSafe ? " (committed via Safe Mode)" : ""}. Audit with audit_firewall_hardening; undo with remove_firewall_hardening.`;
5953
5994
  }
5954
5995
  }),
@@ -13524,7 +13565,7 @@ ${redactSecrets(result)}`;
13524
13565
 
13525
13566
  // src/tools/poe.ts
13526
13567
  import { z as z70 } from "zod";
13527
- var NO_POE = "This device does not have PoE-out hardware \u2014 the /interface ethernet poe menu is not available.";
13568
+ var NO_POE = "This device has no PoE-OUT hardware, so the `/interface ethernet poe` menu " + "(used to SUPPLY power to other devices) is not available \u2014 this is normal, not an error. " + "Note: PoE-OUT (this router powering other devices) is different from PoE-IN (this router being " + "powered over Ethernet). If your device is PoE-powered, read its PoE-IN voltage/state with " + "get_system_health (`/system health`), not the PoE tools.";
13528
13569
  var poeTools = [
13529
13570
  defineTool({
13530
13571
  name: "get_poe_monitor",
@@ -17595,7 +17636,7 @@ var safeModeTools = [
17595
17636
  async handler(_a, ctx) {
17596
17637
  const device = resolveDeviceName(ctx.device);
17597
17638
  ctx.info(`[${device}] Committing safe mode changes`);
17598
- return getSafeModeManager(device).commit();
17639
+ return (await getSafeModeManager(device).commit()).message;
17599
17640
  }
17600
17641
  }),
17601
17642
  defineTool({
@@ -22351,7 +22392,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
22351
22392
  // package.json
22352
22393
  var package_default = {
22353
22394
  name: "@usex/mikrotik-mcp",
22354
- version: "3.21.0",
22395
+ version: "3.23.0",
22355
22396
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
22356
22397
  keywords: [
22357
22398
  "ai",
@@ -22555,6 +22596,7 @@ function createServer(opts = {}) {
22555
22596
  const toolCount = registerTools(server, toolModules, {
22556
22597
  sendLog,
22557
22598
  deviceNames: names,
22599
+ deviceAliases: deviceLabels(),
22558
22600
  readOnly
22559
22601
  });
22560
22602
  const promptCount = registerPrompts(server);
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
6
7
  <title>MikroTik Device Dashboard</title>
7
8
  <script type="module">
8
9
  import{a as e,i as t,n,r,t as i}from"./app-DU6NBjf_.js";var a=`show_system_dashboard`,o=document.getElementById(`app`);function s(e,t={},...n){let r=document.createElement(e);for(let[e,n]of Object.entries(t))e===`class`?r.className=n:r.setAttribute(e,n);for(let e of n.flat())e===!1||e==null||r.append(e instanceof Node?e:document.createTextNode(String(e)));return r}function c(e){if(e==null)return`—`;let t=[`B`,`KiB`,`MiB`,`GiB`,`TiB`],n=0,r=e;for(;r>=1024&&n<t.length-1;)r/=1024,n++;return`${r.toFixed(+(r<10&&n>0))} ${t[n]}`}function l(e){return e==null?``:e>=90?`is-bad`:e>=70?`is-warn`:`is-good`}var u=`http://www.w3.org/2000/svg`;function d(e,t){let n=document.createElementNS(u,e);for(let[e,r]of Object.entries(t))n.setAttribute(e,String(r));return n}function f(e,t,n){let r=2*Math.PI*26,i=t==null?0:Math.max(0,Math.min(100,t))/100,a=d(`svg`,{width:64,height:64,viewBox:`0 0 64 64`}),o=d(`circle`,{cx:32,cy:32,r:26,fill:`none`,"stroke-width":7});o.setAttribute(`class`,`gauge__track`);let c=d(`circle`,{cx:32,cy:32,r:26,fill:`none`,"stroke-width":7,"stroke-dasharray":r,"stroke-dashoffset":r*(1-i)});c.setAttribute(`class`,`gauge__bar ${l(t)}`);let u=d(`text`,{x:32,y:37,"text-anchor":`middle`});return u.setAttribute(`class`,`gauge__pct`),u.textContent=t==null?`—`:`${Math.round(t)}%`,a.replaceChildren(o,c,u),s(`div`,{class:`card gauge`},a,s(`div`,{class:`gauge__meta`},s(`p`,{class:`card__label`},e),s(`small`,{},n)))}function p(e,t){return s(`div`,{class:`card`},s(`p`,{class:`card__label`},e),s(`div`,{class:`card__value`},t))}function m(e,t){let n=Object.entries(t);if(n.length===0)return null;let r=s(`div`,{class:`kv__body`},...n.flatMap(([e,t])=>[s(`div`,{class:`kv__k`},e),s(`div`,{class:`kv__v`},t||`—`)]));return s(`details`,{class:`kv`},s(`summary`,{},`${e} (${n.length})`),r)}var h=null,g=!1;function _(){if(!h){o.replaceChildren(s(`div`,{class:`skeleton`},`Waiting for device data…`));return}let e=h,t=e.derived,n=e.resource.version??`?`,r=e.routerboard.model??e.resource[`board-name`]??`?`,i=[f(`CPU load`,t.cpuLoadPct,`${e.resource[`cpu-count`]??`?`} cores`),f(`Memory`,t.memUsedPct,`${c(t.memUsedBytes)} / ${c(t.memTotalBytes)}`),t.hddUsedPct!=null&&f(`Disk`,t.hddUsedPct,`${c(t.hddUsedBytes)} / ${c(t.hddTotalBytes)}`)].filter(Boolean),a=[p(`Uptime`,e.resource.uptime??`—`),t.temperatureC!=null&&p(`Temperature`,`${t.temperatureC} °C`),t.voltageV!=null&&p(`Voltage`,`${t.voltageV} V`),p(`Architecture`,e.resource[`architecture-name`]??`—`)].filter(Boolean),l=s(`button`,{class:`btn`},g?`Refreshing…`:`↻ Refresh`);g&&l.setAttribute(`disabled`,`true`),l.addEventListener(`click`,b);let u=s(`span`,{class:`pill`},`device `,s(`b`,{},e.device)),d=s(`header`,{class:`hd`},s(`span`,{class:`hd__dot`}),s(`div`,{},s(`h1`,{class:`hd__title`},e.identity),s(`p`,{class:`hd__sub`},`${r} · RouterOS ${n}`)),s(`span`,{class:`hd__spacer`}),u),_=s(`footer`,{class:`foot`},l,s(`span`,{},`updated ${new Date(e.generatedAt).toLocaleTimeString()}`)),v=[d,s(`section`,{class:`grid`},...i),s(`section`,{class:`grid`},...a),m(`System resource`,e.resource),m(`RouterBOARD`,e.routerboard),_].filter(e=>e!=null);o.replaceChildren(...v)}var v=new t({name:`mikrotik-dashboard`,version:`1.0.0`});function y(e){e&&typeof e==`object`&&`device`in e&&(h=e,_())}async function b(){if(!g){g=!0,_();try{y((await v.callServerTool({name:a,arguments:{}})).structuredContent)}catch(e){console.error(`[dashboard] refresh failed`,e)}finally{g=!1,_()}}}v.ontoolresult=e=>y(e.structuredContent),v.ontoolinput=()=>{h||_()},v.onhostcontextchanged=t=>{if(t.theme&&e(t.theme),t.styles?.variables&&r(t.styles.variables),t.styles?.css?.fonts&&n(t.styles.css.fonts),t.safeAreaInsets){let{top:e,right:n,bottom:r,left:i}=t.safeAreaInsets;document.body.style.padding=`${e+16}px ${n+16}px ${r+16}px ${i+16}px`}},v.onteardown=async()=>({}),e(i()),_(),v.connect().catch(e=>console.error(`[dashboard] connect failed`,e));
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
6
7
  <title>MikroTik Firewall Audit</title>
7
8
  <script type="module">
8
9
  import{i as e}from"./app-DU6NBjf_.js";import{i as t,o as n,t as r}from"./kit-DAVkZl3Q.js";var i=`firewall_audit`,a=document.getElementById(`app`),o=null,s=!1,c=null,l=new e({name:`mikrotik-firewall-audit`,version:`1.0.0`});function u(e){return e===`clean`||e===`good`?`is-good`:e===`fair`?`is-warn`:`is-bad`}function d(e){return e===`high`?`is-bad`:e===`medium`?`is-warn`:`is-low`}function f(e,t){let n=2*Math.PI*34,r=Math.max(0,Math.min(100,e))/100,i=document.createElementNS(`http://www.w3.org/2000/svg`,`svg`);i.setAttribute(`viewBox`,`0 0 88 88`),i.setAttribute(`class`,`gauge ${u(t)}`);let a=(e,t)=>{let n=document.createElementNS(`http://www.w3.org/2000/svg`,`circle`);n.setAttribute(`class`,e),n.setAttribute(`cx`,`44`),n.setAttribute(`cy`,`44`),n.setAttribute(`r`,`34`);for(let[e,r]of Object.entries(t))n.setAttribute(e,r);return n};i.append(a(`gauge__bg`,{})),i.append(a(`gauge__fg`,{"stroke-dasharray":`${n*r} ${n}`,transform:`rotate(-90 44 44)`}));let o=document.createElementNS(`http://www.w3.org/2000/svg`,`text`);return o.setAttribute(`x`,`44`),o.setAttribute(`y`,`48`),o.setAttribute(`text-anchor`,`middle`),o.setAttribute(`class`,`gauge__num`),o.textContent=String(e),i.append(o),i}async function p(e,t){if(!(!e.action||s)){c=t,h();try{await l.callServerTool({name:e.action.tool,arguments:e.action.args}),await _()}catch(e){console.error(`[firewall-audit] action failed`,e)}finally{c=null,h()}}}function m(e,n){let i=`${e.kind}-${e.table}-${e.ruleIndex??e.chain}-${n}`,a=t(`div`,{class:`f-head`},t(`span`,{class:`chip ${d(e.severity)}`},e.severity),t(`span`,{class:`f-title`},e.title),t(`span`,{class:`f-where`},`${e.table}/${e.chain}`)),o=t(`div`,{class:`f-body`},t(`p`,{class:`f-detail`},e.detail),t(`p`,{class:`f-fix`},t(`b`,{},`Fix: `),e.suggestion)),l=t(`div`,{class:`f-card ${d(e.severity)}`},a,o);if(e.action){let n=c===i?`Working…`:e.action.label;l.append(t(`div`,{class:`f-actions`},r(n,()=>void p(e,i),{class:`btn-danger`,disabled:s||c===i,title:`Calls ${e.action.tool}`})))}return l}function h(){if(!o){a.replaceChildren(t(`div`,{class:`skeleton`},`Running firewall audit…`));return}let e=o,n=t(`header`,{class:`hd`},f(e.riskScore,e.grade),t(`div`,{class:`hd__meta`},t(`h1`,{class:`hd__title`},`Firewall audit`),t(`p`,{class:`hd__sub`},`device `,t(`b`,{},e.device),` · ${e.ruleCount} rules · grade `,t(`b`,{class:u(e.grade)},e.grade)),t(`div`,{class:`counts`},t(`span`,{class:`chip is-bad`},`${e.counts.high} high`),t(`span`,{class:`chip is-warn`},`${e.counts.medium} medium`),t(`span`,{class:`chip is-low`},`${e.counts.low} low`))),t(`span`,{class:`hd__spacer`}),r(s?`Auditing…`:`↻ Re-audit`,_,{disabled:s})),i;i=e.findings.length===0?t(`div`,{class:`empty`},`No issues found — the ruleset looks clean. ✓`):t(`div`,{class:`f-list`},...e.findings.map(m));let c=t(`footer`,{class:`foot`},t(`span`,{class:`grow`}),t(`span`,{},`updated ${new Date(e.generatedAt).toLocaleTimeString()}`));a.replaceChildren(n,i,c)}function g(e){e&&typeof e==`object`&&e.__mikrotikView===`firewall-audit`&&(o=e,h())}async function _(){if(!s){s=!0,h();try{g((await l.callServerTool({name:i,arguments:{}})).structuredContent)}catch(e){console.error(`[firewall-audit] refresh failed`,e)}finally{s=!1,h()}}}l.ontoolresult=e=>g(e.structuredContent),l.ontoolinput=()=>{o||h()},n(l),l.onteardown=async()=>({}),h(),l.connect().catch(e=>console.error(`[firewall-audit] connect failed`,e));
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
6
7
  <title>MikroTik Firewall Rules</title>
7
8
  <script type="module">
8
9
  import{i as e}from"./app-DU6NBjf_.js";import{a as t,i as n,o as r,r as i,t as a}from"./kit-DAVkZl3Q.js";var o=`show_firewall_filter`,s=document.getElementById(`app`),c=null,l=``,u=!1,d=[`protocol`,`src-address`,`dst-address`,`src-port`,`dst-port`,`in-interface`,`out-interface`,`in-interface-list`,`out-interface-list`,`connection-state`,`src-address-list`,`dst-address-list`];function f(e){return e===`accept`?`act is-accept`:e===`drop`||e===`reject`?`act is-drop`:e===`jump`||e===`return`?`act is-jump`:`act`}function p(e){let t=Number(e);if(!e||Number.isNaN(t))return e??`—`;if(t<1e3)return String(t);let n=[`k`,`M`,`G`,`T`],r=-1,i=t;for(;i>=1e3&&r<n.length-1;)i/=1e3,r++;return`${i.toFixed(1)}${n[r]}`}function m(e){let t=[];for(let r of d)e[r]&&t.push(n(`span`,{},`${r}=`,n(`b`,{},e[r])));let r=n(`td`,{class:`matchers`});return t.forEach((e,t)=>{t&&r.append(document.createTextNode(` `)),r.append(e)}),t.length||r.append(document.createTextNode(e.comment??`—`)),r}function h(e){let t=l.trim().toLowerCase();return t?e.rows.filter(e=>Object.values(e).some(e=>e.toLowerCase().includes(t))):e.rows}function g(e){let t=(e.flags??``).includes(`X`),r=e.action??`—`,i=n(`tr`,t?{class:`is-disabled`}:{});return i.append(n(`td`,{class:`col-num`},e[`#`]??`—`),n(`td`,{},e.chain??`—`),n(`td`,{},n(`span`,{class:f(r)},r)),m(e),n(`td`,{class:`num`},p(e.packets)),n(`td`,{class:`num`},p(e.bytes))),i}function _(){if(!c){s.replaceChildren(n(`div`,{class:`skeleton`},`Waiting for firewall rules…`));return}let e=c,r=h(e),o=e.rows.filter(e=>(e.flags??``).includes(`X`)).length,f=n(`input`,{class:`search`,type:`search`,placeholder:`Search ${e.rows.length} rule(s)…`,value:l});f.addEventListener(`input`,()=>{l=f.value,_()});let p=n(`header`,{class:`hd`},n(`span`,{class:`hd__dot`}),n(`div`,{},n(`h1`,{class:`hd__title`},`Firewall — Filter`),n(`p`,{class:`hd__sub`},`${e.rows.length} rules · ${o} disabled`)),n(`span`,{class:`hd__spacer`}),n(`span`,{class:`pill`},`device `,n(`b`,{},e.device))),m=n(`div`,{class:`toolbar`},n(`div`,{class:`grow`},f),a(u?`Refreshing…`:`↻ Refresh`,b,{disabled:u}),a(`CSV`,()=>{let n=[`#`,`chain`,`action`,...d,`packets`,`bytes`];i(`firewall-${e.chain}.csv`,t(n,h(e)),`text/csv`)},{title:`Export visible rules as CSV`})),v;v=e.rows.length?r.length?n(`div`,{class:`tablewrap`},n(`table`,{class:`tbl`},n(`thead`,{},n(`tr`,{},n(`th`,{class:`col-num`},`#`),n(`th`,{},`chain`),n(`th`,{},`action`),n(`th`,{},`matchers`),n(`th`,{class:`num`},`packets`),n(`th`,{class:`num`},`bytes`))),n(`tbody`,{},...r.map(g)))):n(`div`,{class:`empty`},`No rules match the search.`):n(`div`,{class:`empty`},`No filter rules configured.`);let y=n(`footer`,{class:`foot`},n(`span`,{class:`grow`}),n(`span`,{},`updated ${new Date(e.generatedAt).toLocaleTimeString()}`));s.replaceChildren(p,m,v,y)}var v=new e({name:`mikrotik-firewall`,version:`1.0.0`});function y(e){e&&typeof e==`object`&&e.__mikrotikView===`firewall`&&(c=e,_())}async function b(){if(!u){u=!0,_();try{y((await v.callServerTool({name:o,arguments:{}})).structuredContent)}catch(e){console.error(`[firewall] refresh failed`,e)}finally{u=!1,_()}}}v.ontoolresult=e=>y(e.structuredContent),v.ontoolinput=()=>{c||_()},r(v),v.onteardown=async()=>({}),_(),v.connect().catch(e=>console.error(`[firewall] connect failed`,e));
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
6
7
  <title>MikroTik Interfaces</title>
7
8
  <script type="module">
8
9
  import{i as e}from"./app-DU6NBjf_.js";import{i as t,o as n,t as r}from"./kit-DAVkZl3Q.js";var i=`show_interfaces`,a=document.getElementById(`app`),o=null,s=``,c=!1;function l(e){return(e.flags??``).includes(`R`)||e.running===`true`}function u(e){return(e.flags??``).includes(`X`)||e.disabled===`true`}function d(e,n){return!!n&&t(`div`,{class:`contents`},t(`span`,{},e),t(`b`,{},n))}function f(e){let n=u(e),r=n?`dot is-down`:l(e)?`dot is-up`:`dot`,i=t(`div`,{class:`if-card__meta`},d(`MTU`,e.mtu??e[`actual-mtu`]),d(`MAC`,e[`mac-address`]),d(`comment`,e.comment));return t(`div`,{class:`card if-card${n?` is-disabled`:``}`},t(`div`,{class:`if-card__top`},t(`span`,{class:r}),t(`span`,{class:`if-card__name`},e.name??`?`),t(`span`,{class:`badge`},e.type??`—`)),i)}function p(e){let t=s.trim().toLowerCase();return t?e.rows.filter(e=>Object.values(e).some(e=>e.toLowerCase().includes(t))):e.rows}function m(){if(!o){a.replaceChildren(t(`div`,{class:`skeleton`},`Waiting for interfaces…`));return}let e=o,n=p(e),i=e.rows.filter(l).length,d=e.rows.filter(u).length,h=t(`input`,{class:`search`,type:`search`,placeholder:`Search ${e.rows.length} interface(s)…`,value:s});h.addEventListener(`input`,()=>{s=h.value,m()});let g=t(`header`,{class:`hd`},t(`span`,{class:`hd__dot`}),t(`div`,{},t(`h1`,{class:`hd__title`},`Interfaces`),t(`p`,{class:`hd__sub`},`${i} running · ${d} disabled · ${e.rows.length} total`)),t(`span`,{class:`hd__spacer`}),t(`span`,{class:`pill`},`device `,t(`b`,{},e.device))),v=t(`div`,{class:`toolbar`},t(`div`,{class:`grow`},h),r(c?`Refreshing…`:`↻ Refresh`,_,{disabled:c})),y=n.length?t(`section`,{class:`if-grid`},...n.map(f)):t(`div`,{class:`empty`},`No interfaces match the search.`),b=t(`footer`,{class:`foot`},t(`span`,{class:`grow`}),t(`span`,{},`updated ${new Date(e.generatedAt).toLocaleTimeString()}`));a.replaceChildren(g,v,y,b)}var h=new e({name:`mikrotik-interfaces`,version:`1.0.0`});function g(e){e&&typeof e==`object`&&e.__mikrotikView===`interfaces`&&(o=e,m())}async function _(){if(!c){c=!0,m();try{g((await h.callServerTool({name:i,arguments:{}})).structuredContent)}catch(e){console.error(`[interfaces] refresh failed`,e)}finally{c=!1,m()}}}h.ontoolresult=e=>g(e.structuredContent),h.ontoolinput=()=>{o||m()},n(h),h.onteardown=async()=>({}),m(),h.connect().catch(e=>console.error(`[interfaces] connect failed`,e));
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
6
7
  <title>MikroTik Records Viewer</title>
7
8
  <script type="module">
8
9
  import{i as e}from"./app-DU6NBjf_.js";import{a as t,i as n,n as r,o as i,r as a,t as o}from"./kit-DAVkZl3Q.js";var s=document.getElementById(`app`),c=null,l=``,u=null,d=1,f=new Set,p=null,m=!1,h=0,g=null,_={};function v(e){let t=[`#`,`flags`].filter(t=>e.columns.includes(t));return[...t,...e.columns.filter(e=>!t.includes(e))]}function y(e){let t=l.trim().toLowerCase(),n=e.rows;if(t&&(n=n.filter(e=>Object.values(e).some(e=>e.toLowerCase().includes(t)))),f.size&&(n=n.filter(e=>{let t=e.flags??``;return[...f].every(e=>t.includes(e))})),u){let e=u;n=[...n].sort((t,n)=>{let r=t[e]??``,i=n[e]??``,a=Number(r),o=Number(i);return(r!==``&&i!==``&&!Number.isNaN(a)&&!Number.isNaN(o)?a-o:r.localeCompare(i))*d})}return n}function b(e){let t=new Set;for(let n of e.rows)for(let e of n.flags??``)t.add(e);return[...t].sort()}function x(e){return Object.entries(e).filter(([e])=>e!==`#`&&e!==`flags`).map(([e,t])=>/\s/.test(t)?`${e}="${t}"`:`${e}=${t}`).join(` `)}function S(e){let t=b(e);return t.length?n(`div`,{class:`toolbar`},...t.map(t=>{let n=e.flags[t]??t,r=f.has(t);return o(`${t} · ${n}`,()=>{r?f.delete(t):f.add(t),j()},{class:r?`is-active`:``,title:`Filter rows flagged "${n}"`})})):!1}function C(e){let t=n(`th`,e===`#`?{class:`col-num`}:{},e);return u===e&&t.append(n(`span`,{class:`arrow`},d===1?`▲`:`▼`)),t.addEventListener(`click`,()=>{u===e?d=d===1?-1:1:(u=e,d=1),j()}),t}function w(e,t){let r=v(e),i=n(`thead`,{},n(`tr`,{},...r.map(C))),a=n(`tbody`);return t.forEach(t=>{let i=n(`tr`,(t.flags??``).includes(`X`)?{class:`is-disabled`}:{});for(let e of r)i.append(n(`td`,e===`#`?{class:`col-num`}:{},t[e]??`—`));i.addEventListener(`click`,()=>{p=e.rows.indexOf(t),j()}),a.append(i)}),n(`div`,{class:`tablewrap`},n(`table`,{class:`tbl`},i,a))}function T(e){let t=n(`div`,{class:`kv__body`});for(let[r,i]of Object.entries(e))t.append(n(`div`,{class:`kv__k`},r),n(`div`,{class:`kv__v`},i||`—`));return t}function E(e){if(p==null||!e.rows[p])return!1;let t=e.rows[p],i=t.name??t[`#`]??`record`;return n(`div`,{class:`drawer`},n(`div`,{class:`drawer__hd`},n(`span`,{},`Row `),n(`b`,{},String(i)),n(`span`,{class:`hd__spacer`}),o(`Copy as CLI`,()=>void r(x(t)),{title:`Copy key=value pairs`}),o(`Close`,()=>{p=null,j()})),T(t))}function D(e,i){let s=n(`input`,{class:`search`,type:`search`,placeholder:`Search ${e.count} row${e.count===1?``:`s`}…`,value:l});s.addEventListener(`input`,()=>{l=s.value,p=null,k()});let c=n(`select`,{class:`btn`,title:`Auto-refresh interval`});for(let[e,t]of[[`Auto: off`,`0`],[`Auto: 5s`,`5000`],[`Auto: 15s`,`15000`],[`Auto: 60s`,`60000`]]){let r=n(`option`,{value:t},e);Number(t)===h&&(r.selected=!0),c.append(r)}c.addEventListener(`change`,()=>F(Number(c.value)));let u=()=>y(e),d=v(e);return n(`div`,{class:`toolbar`},n(`div`,{class:`grow`},s),o(m?`Refreshing…`:`↻ Refresh`,P,{disabled:m}),c,o(`CSV`,()=>a(`${e.tool}.csv`,t(d,u()),`text/csv`),{title:`Export visible rows as CSV`}),o(`JSON`,()=>a(`${e.tool}.json`,JSON.stringify(u(),null,2),`application/json`),{title:`Export visible rows as JSON`}),o(`Copy`,()=>void r(JSON.stringify(u(),null,2)),{title:`Copy visible rows as JSON`}),n(`span`,{class:`pill count-pill`},`showing `,n(`b`,{},`${i}/${e.count}`)))}function O(e){return n(`header`,{class:`hd`},n(`span`,{class:`hd__dot`}),n(`div`,{},n(`h1`,{class:`hd__title`},e.title),n(`p`,{class:`hd__sub`},e.tool)),n(`span`,{class:`hd__spacer`}))}function k(){if(!c)return;let e=document.getElementById(`results`);if(!e){j();return}e.replaceChildren(A(c));let t=document.querySelector(`.count-pill b`);t&&(t.textContent=`${y(c).length}/${c.count}`)}function A(e){let t=y(e);return e.rows.length===0?e.raw?n(`pre`,{class:`raw`},e.raw):n(`div`,{class:`empty`},`No records returned.`):e.kind===`record`&&e.rows.length===1?T(e.rows[0]):t.length===0?n(`div`,{class:`empty`},`No rows match the current filter.`):w(e,t)}function j(){if(!c){s.replaceChildren(n(`div`,{class:`skeleton`},`Waiting for data…`));return}let e=c,t=y(e).length,r=e.kind===`list`&&e.rows.length>0,i=[O(e),r&&D(e,t),r&&S(e),n(`div`,{id:`results`},A(e)),E(e),n(`footer`,{class:`foot`},n(`span`,{},`format: ${e.format}`),n(`span`,{class:`grow`}),n(`span`,{},`updated ${new Date(e.generatedAt).toLocaleTimeString()}`))];s.replaceChildren(...i.filter(Boolean))}var M=new e({name:`mikrotik-records`,version:`1.0.0`});function N(e){e&&typeof e==`object`&&e.__mikrotikView===`records`&&(c=e,p=null,j())}async function P(){if(!(m||!c)){m=!0,j();try{N((await M.callServerTool({name:c.tool,arguments:_})).structuredContent)}catch(e){console.error(`[records] refresh failed`,e)}finally{m=!1,j()}}}function F(e){h=e,g&&=(clearInterval(g),null),e>0&&(g=setInterval(()=>void P(),e)),j()}M.ontoolresult=e=>N(e.structuredContent),M.ontoolinput=e=>{e&&typeof e==`object`&&`arguments`in e&&(_=e.arguments??{}),c||j()},i(M),M.onteardown=async()=>(g&&clearInterval(g),{}),j(),M.connect().catch(e=>console.error(`[records] connect failed`,e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usex/mikrotik-mcp",
3
- "version": "3.21.0",
3
+ "version": "3.23.0",
4
4
  "description": "MCP server for MikroTik RouterOS — 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
5
5
  "keywords": [
6
6
  "ai",