@usex/mikrotik-mcp 3.4.0 → 3.5.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
@@ -3,7 +3,7 @@
3
3
  var __require = import.meta.require;
4
4
 
5
5
  // src/cli.ts
6
- import { existsSync as existsSync2 } from "fs";
6
+ import { existsSync as existsSync3 } from "fs";
7
7
 
8
8
  // src/config.ts
9
9
  import { readFileSync } from "fs";
@@ -13,6 +13,7 @@ import { z } from "zod";
13
13
  var DEFAULT_DASHBOARD_DB = join(homedir(), ".mikrotik-mcp", "events.db");
14
14
  var DEFAULT_SNAPSHOT_DB = join(homedir(), ".mikrotik-mcp", "snapshots.db");
15
15
  var DEFAULT_BACKUP_DIR = join(homedir(), ".mikrotik-mcp", "backups");
16
+ var DEFAULT_CONFIG_HISTORY_DIR = join(homedir(), ".mikrotik-mcp", "config-history");
16
17
  var DEFAULT_CONFIG_FILE = join(homedir(), ".mikrotik-mcp", "config.json");
17
18
  var TransportSchema = z.enum(["stdio", "sse", "streamable-http"]);
18
19
  var McpServerSettingsSchema = z.object({
@@ -1051,6 +1052,16 @@ function looksLikeError(result) {
1051
1052
  const t = result.toLowerCase();
1052
1053
  return t.includes("failure:") || t.includes("syntax error") || t.includes("bad command") || t.includes("bad parameter") || t.includes("expected end of command") || t.includes("invalid value") || t.includes("input does not match") || t.includes("ambiguous value") || t.startsWith("error");
1053
1054
  }
1055
+ function placeBeforeError(result, placeBefore) {
1056
+ if (!placeBefore)
1057
+ return;
1058
+ const t = result.toLowerCase();
1059
+ if (!t.includes("place-before") || !(t.includes("does not exist") || t.includes("not found"))) {
1060
+ return;
1061
+ }
1062
+ const detail = placeBefore.startsWith("*") ? `A '*N' value is an internal .id (hexadecimal, reassigned over time), NOT the row number \u2014 list the rules first and use a CURRENT .id, or pass a bare ordinal position instead (e.g. '${placeBefore.slice(1)}').` : "Pass a bare ordinal position (e.g. '0') or a current internal .id ('*N') from a list call.";
1063
+ return `place_before '${placeBefore}' does not exist on the device. ${detail}`;
1064
+ }
1054
1065
  function commandUnsupported(result) {
1055
1066
  const t = result.toLowerCase();
1056
1067
  return t.includes("bad command name") || t.includes("no such command") || t.includes("no such command prefix") || t.includes("invalid command name");
@@ -1086,6 +1097,67 @@ function parseKeyValues(text) {
1086
1097
  }
1087
1098
  return out;
1088
1099
  }
1100
+ function parseSize(s) {
1101
+ if (!s)
1102
+ return;
1103
+ const m = s.trim().match(/^([\d.]+)\s*([KMGT]i?B|B)?$/i);
1104
+ if (!m)
1105
+ return;
1106
+ const val = Number.parseFloat(m[1]);
1107
+ if (!Number.isFinite(val))
1108
+ return;
1109
+ const mult = {
1110
+ B: 1,
1111
+ KIB: 1024,
1112
+ MIB: 1024 ** 2,
1113
+ GIB: 1024 ** 3,
1114
+ TIB: 1024 ** 4,
1115
+ KB: 1000,
1116
+ MB: 1e6,
1117
+ GB: 1e9,
1118
+ TB: 1000000000000
1119
+ };
1120
+ return val * (mult[(m[2] ?? "B").toUpperCase()] ?? 1);
1121
+ }
1122
+ function parsePercent(s) {
1123
+ if (!s)
1124
+ return;
1125
+ const m = s.trim().match(/^([\d.]+)\s*%?$/);
1126
+ if (!m)
1127
+ return;
1128
+ const v = Number.parseFloat(m[1]);
1129
+ return Number.isFinite(v) ? v : undefined;
1130
+ }
1131
+ function usedPct(total, free) {
1132
+ if (total == null || free == null || total <= 0)
1133
+ return;
1134
+ return Math.max(0, Math.min(100, (total - free) / total * 100));
1135
+ }
1136
+ function parseSystemResource(text) {
1137
+ const r = parseKeyValues(text);
1138
+ const totalMemory = parseSize(r["total-memory"]);
1139
+ const freeMemory = parseSize(r["free-memory"]);
1140
+ const totalHdd = parseSize(r["total-hdd-space"]);
1141
+ const freeHdd = parseSize(r["free-hdd-space"]);
1142
+ const cpuLoad = parsePercent(r["cpu-load"]);
1143
+ const cpuCount = Number.parseInt(r["cpu-count"] ?? "", 10);
1144
+ const out = {
1145
+ version: r.version || undefined,
1146
+ boardName: r["board-name"] || undefined,
1147
+ architecture: r["architecture-name"] || undefined,
1148
+ cpuCount: Number.isFinite(cpuCount) ? cpuCount : undefined,
1149
+ cpuLoad,
1150
+ freeMemory,
1151
+ totalMemory,
1152
+ memUsedPct: usedPct(totalMemory, freeMemory),
1153
+ freeHdd,
1154
+ totalHdd,
1155
+ hddUsedPct: usedPct(totalHdd, freeHdd),
1156
+ uptime: r.uptime || undefined
1157
+ };
1158
+ const gotMetric = out.cpuLoad != null || out.totalMemory != null || out.totalHdd != null || out.version != null || out.uptime != null;
1159
+ return gotMetric ? out : null;
1160
+ }
1089
1161
  function parseFlagLegend(text) {
1090
1162
  const out = {};
1091
1163
  const line = text.split(`
@@ -5176,7 +5248,7 @@ var firewallFilterTools = [
5176
5248
  disabled: z17.boolean().default(false),
5177
5249
  log: z17.boolean().default(false),
5178
5250
  log_prefix: z17.string().optional(),
5179
- place_before: z17.string().optional().describe('Rule number or ID (*N) to insert before e.g. "0" or "*3"')
5251
+ place_before: z17.string().optional().describe('Insert before this position. Either a bare ordinal/row number (e.g. "0", "13") OR a ' + 'CURRENT internal .id ("*N") from list_filter_rules. Note: a "*N" .id is hexadecimal ' + 'and reassigned over time \u2014 it is NOT the row number, so "*13" \u2260 the 13th rule.')
5180
5252
  },
5181
5253
  async handler(a, ctx) {
5182
5254
  ctx.info(`Creating firewall filter rule: chain=${a.chain}, action=${a.action}`);
@@ -5190,7 +5262,8 @@ var firewallFilterTools = [
5190
5262
 
5191
5263
  ${details}` : `Firewall filter rule created with ID: ${result}`;
5192
5264
  }
5193
- return `Failed to create firewall filter rule: ${result}`;
5265
+ const hint = placeBeforeError(result, a.place_before);
5266
+ return `Failed to create firewall filter rule: ${hint ?? result}`;
5194
5267
  }
5195
5268
  const count = await executeMikrotikCommand("/ip firewall filter print detail count-only", ctx);
5196
5269
  const c = count.trim();
@@ -5486,7 +5559,8 @@ var firewallNatTools = [
5486
5559
 
5487
5560
  ${details}` : `NAT rule created with ID: ${result}`;
5488
5561
  }
5489
- return `Failed to create NAT rule: ${result}`;
5562
+ const hint = placeBeforeError(result, a.place_before);
5563
+ return `Failed to create NAT rule: ${hint ?? result}`;
5490
5564
  }
5491
5565
  const count = await executeMikrotikCommand("/ip firewall nat print detail count-only", ctx);
5492
5566
  const c = count.trim();
@@ -19610,9 +19684,9 @@ var moduleCatalog = [
19610
19684
  var allToolModules = moduleCatalog.map((m) => m.tools);
19611
19685
 
19612
19686
  // src/observability/dashboard.ts
19613
- import { readFileSync as readFileSync5 } from "fs";
19687
+ import { readFileSync as readFileSync6 } from "fs";
19614
19688
  import { homedir as homedir2, networkInterfaces } from "os";
19615
- import { join as join5 } from "path";
19689
+ import { join as join6 } from "path";
19616
19690
  var {serve } = globalThis.Bun;
19617
19691
  import { z as z93 } from "zod";
19618
19692
 
@@ -19709,6 +19783,108 @@ function createConfigAdmin(deps) {
19709
19783
  return { applyConfig, keepConfig, rollback, pendingId: () => pending?.id ?? null };
19710
19784
  }
19711
19785
 
19786
+ // src/observability/config-history.ts
19787
+ import {
19788
+ existsSync as existsSync2,
19789
+ mkdirSync as mkdirSync4,
19790
+ readFileSync as readFileSync5,
19791
+ readdirSync as readdirSync2,
19792
+ rmSync as rmSync2,
19793
+ statSync as statSync2,
19794
+ writeFileSync as writeFileSync3
19795
+ } from "fs";
19796
+ import { join as join5 } from "path";
19797
+ var AUTO_RETENTION = 50;
19798
+ function historyDir() {
19799
+ return process.env.MIKROTIK_CONFIG_HISTORY_DIR || DEFAULT_CONFIG_HISTORY_DIR;
19800
+ }
19801
+ function idToFile(id) {
19802
+ if (!/^v\d+$/.test(id))
19803
+ throw new Error(`invalid version id: ${id}`);
19804
+ return `${id}.json`;
19805
+ }
19806
+ function listVersions() {
19807
+ const dir = historyDir();
19808
+ if (!existsSync2(dir))
19809
+ return [];
19810
+ const out = [];
19811
+ for (const f of readdirSync2(dir)) {
19812
+ if (!/^v\d+\.json$/.test(f))
19813
+ continue;
19814
+ try {
19815
+ const raw = readFileSync5(join5(dir, f), "utf8");
19816
+ const parsed = JSON.parse(raw);
19817
+ out.push({
19818
+ id: f.replace(/\.json$/, ""),
19819
+ ts: parsed.ts,
19820
+ kind: parsed.kind === "checkpoint" ? "checkpoint" : "auto",
19821
+ label: parsed.label,
19822
+ bytes: Buffer.byteLength(JSON.stringify(parsed.config ?? {}, null, 2), "utf8")
19823
+ });
19824
+ } catch {}
19825
+ }
19826
+ return out.sort((a, b) => b.ts - a.ts);
19827
+ }
19828
+ function readVersion(id) {
19829
+ const raw = readFileSync5(join5(historyDir(), idToFile(id)), "utf8");
19830
+ return JSON.parse(raw);
19831
+ }
19832
+ function deleteVersion(id) {
19833
+ const p = join5(historyDir(), idToFile(id));
19834
+ if (!existsSync2(p))
19835
+ return false;
19836
+ rmSync2(p);
19837
+ return true;
19838
+ }
19839
+ function recordVersion(config, kind, now, label) {
19840
+ const dir = historyDir();
19841
+ mkdirSync4(dir, { recursive: true });
19842
+ let ts = now;
19843
+ while (existsSync2(join5(dir, `v${ts}.json`)))
19844
+ ts++;
19845
+ const body = { ts, kind, label, config };
19846
+ writeFileSync3(join5(dir, `v${ts}.json`), `${JSON.stringify(body, null, 2)}
19847
+ `, "utf8");
19848
+ pruneAuto(dir);
19849
+ return {
19850
+ id: `v${ts}`,
19851
+ ts,
19852
+ kind,
19853
+ label,
19854
+ bytes: Buffer.byteLength(JSON.stringify(config ?? {}, null, 2), "utf8")
19855
+ };
19856
+ }
19857
+ function pruneAuto(dir) {
19858
+ const autos = listVersions().filter((v) => v.kind === "auto");
19859
+ if (autos.length <= AUTO_RETENTION)
19860
+ return;
19861
+ for (const v of autos.slice(AUTO_RETENTION)) {
19862
+ try {
19863
+ rmSync2(join5(dir, `${v.id}.json`));
19864
+ } catch {}
19865
+ }
19866
+ }
19867
+ function isEmpty2() {
19868
+ const dir = historyDir();
19869
+ if (!existsSync2(dir))
19870
+ return true;
19871
+ return !readdirSync2(dir).some((f) => /^v\d+\.json$/.test(f));
19872
+ }
19873
+ function historyBytes() {
19874
+ const dir = historyDir();
19875
+ if (!existsSync2(dir))
19876
+ return 0;
19877
+ let total = 0;
19878
+ for (const f of readdirSync2(dir)) {
19879
+ if (!/^v\d+\.json$/.test(f))
19880
+ continue;
19881
+ try {
19882
+ total += statSync2(join5(dir, f)).size;
19883
+ } catch {}
19884
+ }
19885
+ return total;
19886
+ }
19887
+
19712
19888
  // src/observability/topology.ts
19713
19889
  function normMac(mac2) {
19714
19890
  if (!mac2)
@@ -19863,6 +20039,7 @@ function buildTopology(input) {
19863
20039
  }
19864
20040
 
19865
20041
  // src/observability/health.ts
20042
+ var LOG_TAG = "mikrotik-mcp";
19866
20043
  var HISTORY_CAP = 60;
19867
20044
  var statuses = new Map;
19868
20045
  var histories = new Map;
@@ -19886,39 +20063,6 @@ function pushHistory(name, sample) {
19886
20063
  arr.splice(0, arr.length - HISTORY_CAP);
19887
20064
  histories.set(name, arr);
19888
20065
  }
19889
- function parseSize(s) {
19890
- if (!s)
19891
- return;
19892
- const m = s.trim().match(/^([\d.]+)\s*([KMGT]i?B|B)?$/i);
19893
- if (!m)
19894
- return;
19895
- const val = Number.parseFloat(m[1]);
19896
- if (!Number.isFinite(val))
19897
- return;
19898
- const mult = {
19899
- B: 1,
19900
- KIB: 1024,
19901
- MIB: 1024 ** 2,
19902
- GIB: 1024 ** 3,
19903
- TIB: 1024 ** 4,
19904
- KB: 1000,
19905
- MB: 1e6,
19906
- GB: 1e9,
19907
- TB: 1000000000000
19908
- };
19909
- return val * (mult[(m[2] ?? "B").toUpperCase()] ?? 1);
19910
- }
19911
- function parsePercent(s) {
19912
- if (!s)
19913
- return;
19914
- const m = s.trim().match(/^([\d.]+)\s*%?$/);
19915
- return m ? Number.parseFloat(m[1]) : undefined;
19916
- }
19917
- function usedPct(total, free) {
19918
- if (total == null || free == null || total <= 0)
19919
- return;
19920
- return Math.max(0, Math.min(100, (total - free) / total * 100));
19921
- }
19922
20066
  async function probeDevice(name, dc) {
19923
20067
  const client = createDeviceClient({
19924
20068
  ...dc,
@@ -19936,41 +20080,41 @@ async function probeDevice(name, dc) {
19936
20080
  error: client.lastError ?? "connection failed"
19937
20081
  };
19938
20082
  } else {
19939
- const identity = parseKeyValues(await client.run("/system identity print")).name;
19940
- const r = parseKeyValues(await client.run("/system resource print"));
20083
+ const rawResource = await client.run("/system resource print");
19941
20084
  const checkedAt = Date.now();
19942
20085
  const latencyMs = checkedAt - t0;
19943
- const totalMemory = parseSize(r["total-memory"]);
19944
- const freeMemory = parseSize(r["free-memory"]);
19945
- const totalHdd = parseSize(r["total-hdd-space"]);
19946
- const freeHdd = parseSize(r["free-hdd-space"]);
19947
- const cpuLoad = parsePercent(r["cpu-load"]);
19948
- const memUsedPct = usedPct(totalMemory, freeMemory);
19949
- const hddUsedPct = usedPct(totalHdd, freeHdd);
19950
- const cpuCount = Number.parseInt(r["cpu-count"] ?? "", 10);
20086
+ const sys = parseSystemResource(rawResource);
20087
+ if (!sys) {
20088
+ logger.warn(`[${LOG_TAG}] '${name}' is reachable but '/system resource print' returned no ` + `parseable metrics. Raw output: ${JSON.stringify(rawResource.slice(0, 200))}`);
20089
+ }
20090
+ let identity;
20091
+ try {
20092
+ identity = parseKeyValues(await client.run("/system identity print")).name || undefined;
20093
+ } catch {}
19951
20094
  status = {
19952
20095
  reachable: true,
19953
20096
  checkedAt,
19954
20097
  latencyMs,
19955
20098
  identity,
19956
- version: r.version,
19957
- boardName: r["board-name"] || undefined,
19958
- architecture: r["architecture-name"] || undefined,
19959
- cpuCount: Number.isFinite(cpuCount) ? cpuCount : undefined,
19960
- cpuLoad,
19961
- freeMemory,
19962
- totalMemory,
19963
- memUsedPct,
19964
- freeHdd,
19965
- totalHdd,
19966
- hddUsedPct,
19967
- uptime: r.uptime || undefined
20099
+ version: sys?.version,
20100
+ boardName: sys?.boardName,
20101
+ architecture: sys?.architecture,
20102
+ cpuCount: sys?.cpuCount,
20103
+ cpuLoad: sys?.cpuLoad,
20104
+ freeMemory: sys?.freeMemory,
20105
+ totalMemory: sys?.totalMemory,
20106
+ memUsedPct: sys?.memUsedPct,
20107
+ freeHdd: sys?.freeHdd,
20108
+ totalHdd: sys?.totalHdd,
20109
+ hddUsedPct: sys?.hddUsedPct,
20110
+ uptime: sys?.uptime,
20111
+ error: sys ? undefined : "reachable, but no system metrics returned"
19968
20112
  };
19969
20113
  pushHistory(name, {
19970
20114
  ts: checkedAt,
19971
- cpuLoad: cpuLoad ?? null,
19972
- memUsedPct: memUsedPct ?? null,
19973
- hddUsedPct: hddUsedPct ?? null,
20115
+ cpuLoad: sys?.cpuLoad ?? null,
20116
+ memUsedPct: sys?.memUsedPct ?? null,
20117
+ hddUsedPct: sys?.hddUsedPct ?? null,
19974
20118
  latencyMs
19975
20119
  });
19976
20120
  try {
@@ -20023,7 +20167,7 @@ function stopHealthChecks() {
20023
20167
  }
20024
20168
 
20025
20169
  // src/observability/store.ts
20026
- import { mkdirSync as mkdirSync4 } from "fs";
20170
+ import { mkdirSync as mkdirSync5 } from "fs";
20027
20171
  import { dirname as dirname4 } from "path";
20028
20172
  function rowToEvent(r) {
20029
20173
  return {
@@ -20181,7 +20325,7 @@ class SqliteEventStore {
20181
20325
  async function openSqliteStore(path) {
20182
20326
  if (path !== ":memory:") {
20183
20327
  try {
20184
- mkdirSync4(dirname4(path), { recursive: true });
20328
+ mkdirSync5(dirname4(path), { recursive: true });
20185
20329
  } catch {}
20186
20330
  }
20187
20331
  const { Database } = await import("bun:sqlite");
@@ -20290,7 +20434,7 @@ function json(body, status = 200) {
20290
20434
  }
20291
20435
  function dashboardHtml() {
20292
20436
  try {
20293
- return readFileSync5(join5(UI_DIST_DIR, "observability.html"), "utf8");
20437
+ return readFileSync6(join6(UI_DIST_DIR, "observability.html"), "utf8");
20294
20438
  } catch {
20295
20439
  return `<!doctype html><meta charset=utf-8><body style="font:14px system-ui;padding:24px;background:#0b0d10;color:#e8eaed">
20296
20440
  <h2>MikroTik MCP \u2014 Observability Dashboard</h2>
@@ -20516,13 +20660,96 @@ async function configRoutes(req, url, admin) {
20516
20660
  }
20517
20661
  if (p === "/api/config/keep" && req.method === "POST") {
20518
20662
  const body = await readJson(req);
20519
- return json({ kept: admin.keepConfig(String(body?.pendingId ?? "")) });
20663
+ const kept = admin.keepConfig(String(body?.pendingId ?? ""));
20664
+ if (kept)
20665
+ recordVersion(getConfig(), "auto", Date.now());
20666
+ return json({ kept });
20520
20667
  }
20521
20668
  if (p === "/api/config/rollback" && req.method === "POST") {
20522
20669
  const body = await readJson(req);
20523
20670
  const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
20524
20671
  return json({ rolledBack, config: redact(getConfig()) });
20525
20672
  }
20673
+ if (p === "/api/config/history" && req.method === "GET") {
20674
+ const current = JSON.stringify(redact(getConfig()), null, 2);
20675
+ const versions = listVersions().map((v) => {
20676
+ let added = 0;
20677
+ let removed = 0;
20678
+ try {
20679
+ const vc = JSON.stringify(redact(readVersion(v.id).config), null, 2);
20680
+ const d = diffLines(vc, current);
20681
+ added = d.summary.added;
20682
+ removed = d.summary.removed;
20683
+ } catch {}
20684
+ return { ...v, drift: { added, removed } };
20685
+ });
20686
+ return json({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
20687
+ }
20688
+ if (p === "/api/config/history/get" && req.method === "GET") {
20689
+ const id = url.searchParams.get("id");
20690
+ if (!id)
20691
+ return json({ error: "id required" }, 400);
20692
+ try {
20693
+ const v = readVersion(id);
20694
+ return json({
20695
+ ts: v.ts,
20696
+ kind: v.kind,
20697
+ label: v.label,
20698
+ config: JSON.stringify(redact(v.config), null, 2)
20699
+ });
20700
+ } catch {
20701
+ return json({ error: "not found" }, 404);
20702
+ }
20703
+ }
20704
+ if (p === "/api/config/history/diff" && req.method === "GET") {
20705
+ const id = url.searchParams.get("id");
20706
+ if (!id)
20707
+ return json({ error: "id required" }, 400);
20708
+ try {
20709
+ const before = JSON.stringify(redact(readVersion(id).config), null, 2);
20710
+ const after = JSON.stringify(redact(getConfig()), null, 2);
20711
+ const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
20712
+ return json({ summary: d.summary, unified: d.unified });
20713
+ } catch {
20714
+ return json({ error: "not found" }, 404);
20715
+ }
20716
+ }
20717
+ if (p === "/api/config/history/checkpoint" && req.method === "POST") {
20718
+ const b = await readJson(req);
20719
+ const label = b?.label?.trim() || "checkpoint";
20720
+ return json({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
20721
+ }
20722
+ if (p === "/api/config/history/restore" && req.method === "POST") {
20723
+ const b = await readJson(req);
20724
+ if (!b?.id)
20725
+ return json({ error: "id required" }, 400);
20726
+ let target;
20727
+ try {
20728
+ target = readVersion(b.id);
20729
+ } catch {
20730
+ return json({ error: "not found" }, 404);
20731
+ }
20732
+ const v = validateConfig(target.config);
20733
+ if (!v.ok || !v.value)
20734
+ return json({ ok: false, errors: v.errors }, 400);
20735
+ recordVersion(getConfig(), "auto", Date.now(), "before restore");
20736
+ setConfig(v.value);
20737
+ let persisted = true;
20738
+ try {
20739
+ atomicWrite(getConfigSource().path, serializeConfig(v.value));
20740
+ } catch {
20741
+ persisted = false;
20742
+ }
20743
+ const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
20744
+ recordVersion(getConfig(), "auto", Date.now(), label);
20745
+ return json({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
20746
+ }
20747
+ if (p === "/api/config/history/delete" && req.method === "POST") {
20748
+ const b = await readJson(req);
20749
+ if (!b?.id)
20750
+ return json({ error: "id required" }, 400);
20751
+ return deleteVersion(b.id) ? json({ ok: true }) : json({ error: "not found" }, 404);
20752
+ }
20526
20753
  return null;
20527
20754
  }
20528
20755
  async function captureRoutes(req, url) {
@@ -20663,7 +20890,7 @@ async function featureRoutes(req, url) {
20663
20890
  const raw = b?.dir?.trim();
20664
20891
  if (!raw)
20665
20892
  return json({ error: "dir required" }, 400);
20666
- const dir = raw === "~" || raw.startsWith("~/") ? join5(homedir2(), raw.slice(1)) : raw;
20893
+ const dir = raw === "~" || raw.startsWith("~/") ? join6(homedir2(), raw.slice(1)) : raw;
20667
20894
  const next = { ...getConfig(), backupDir: dir };
20668
20895
  setConfig(next);
20669
20896
  try {
@@ -20676,6 +20903,7 @@ async function featureRoutes(req, url) {
20676
20903
  warning: `applied live but not saved: ${e instanceof Error ? e.message : String(e)}`
20677
20904
  });
20678
20905
  }
20906
+ recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
20679
20907
  return json({ ok: true, dir: backupDir(), persisted: true });
20680
20908
  }
20681
20909
  if (p === "/api/backups/get" && req.method === "GET") {
@@ -20766,13 +20994,19 @@ async function runDashboard(cfg, transportLabel) {
20766
20994
  transport: transportLabel
20767
20995
  });
20768
20996
  startHealthChecks(30000);
20997
+ try {
20998
+ if (isEmpty2())
20999
+ recordVersion(getConfig(), "auto", Date.now(), "baseline");
21000
+ } catch (e) {
21001
+ logger.warn(`[${SERVER_TAG}] could not seed config history baseline: ${String(e)}`);
21002
+ }
20769
21003
  const configAdmin = createConfigAdmin({
20770
21004
  getConfig,
20771
21005
  setConfig,
20772
21006
  source: getConfigSource,
20773
21007
  readFile: (pth) => {
20774
21008
  try {
20775
- return readFileSync5(pth, "utf8");
21009
+ return readFileSync6(pth, "utf8");
20776
21010
  } catch {
20777
21011
  return null;
20778
21012
  }
@@ -20956,8 +21190,8 @@ function corsHeaders(origin, configured) {
20956
21190
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20957
21191
 
20958
21192
  // src/prompts/index.ts
20959
- import { readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
20960
- import { join as join6 } from "path";
21193
+ import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
21194
+ import { join as join7 } from "path";
20961
21195
  import { z as z94 } from "zod";
20962
21196
  function parseFrontmatter(raw) {
20963
21197
  const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
@@ -21020,7 +21254,7 @@ function substitute(body, vars) {
21020
21254
  function registerPrompts(server) {
21021
21255
  let files;
21022
21256
  try {
21023
- files = readdirSync2(PROMPTS_DIR).filter((f) => f.endsWith(".md"));
21257
+ files = readdirSync3(PROMPTS_DIR).filter((f) => f.endsWith(".md"));
21024
21258
  } catch {
21025
21259
  return 0;
21026
21260
  }
@@ -21028,7 +21262,7 @@ function registerPrompts(server) {
21028
21262
  for (const file of files) {
21029
21263
  let parsed;
21030
21264
  try {
21031
- parsed = parseFrontmatter(readFileSync6(join6(PROMPTS_DIR, file), "utf8"));
21265
+ parsed = parseFrontmatter(readFileSync7(join7(PROMPTS_DIR, file), "utf8"));
21032
21266
  } catch (e) {
21033
21267
  logger.warn(`Skipping prompt ${file}: ${String(e)}`);
21034
21268
  continue;
@@ -21060,7 +21294,7 @@ function registerPrompts(server) {
21060
21294
  // package.json
21061
21295
  var package_default = {
21062
21296
  name: "@usex/mikrotik-mcp",
21063
- version: "3.4.0",
21297
+ version: "3.5.0",
21064
21298
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
21065
21299
  keywords: [
21066
21300
  "ai",
@@ -21142,18 +21376,24 @@ var package_default = {
21142
21376
  },
21143
21377
  devDependencies: {
21144
21378
  "@resvg/resvg-js": "^2.6.2",
21379
+ "@tailwindcss/vite": "^4.3.1",
21145
21380
  "@types/bun": "latest",
21146
- "@types/node": "^26.0.0",
21381
+ "@types/node": "^26.0.1",
21147
21382
  "@types/react": "^19.2.17",
21148
21383
  "@types/react-dom": "^19.2.3",
21149
21384
  "@types/ssh2": "^1.15.5",
21150
21385
  "@types/update-notifier": "^6.0.8",
21151
21386
  bunup: "^0.16.32",
21387
+ "class-variance-authority": "^0.7.1",
21388
+ clsx: "^2.1.1",
21152
21389
  gsap: "^3.15.0",
21153
21390
  prettier: "^3.8.4",
21154
21391
  react: "^19.2.7",
21155
21392
  "react-dom": "^19.2.7",
21393
+ recharts: "^3.9.0",
21156
21394
  "release-it": "^20.2.0",
21395
+ "tailwind-merge": "^3.6.0",
21396
+ tailwindcss: "^4.3.1",
21157
21397
  vite: "npm:@voidzero-dev/vite-plus-core@latest",
21158
21398
  "vite-plus": "latest"
21159
21399
  },
@@ -21402,7 +21642,7 @@ OBSERVABILITY DASHBOARD (optional; real-time feed + analytics of every tool cal
21402
21642
  (MIKROTIK_DASHBOARD__TOKEN)
21403
21643
  `;
21404
21644
  function warnIfPlaintextPasswordInContainer(anyPassword) {
21405
- const inContainer = existsSync2("/.dockerenv") || process.env.container === "docker";
21645
+ const inContainer = existsSync3("/.dockerenv") || process.env.container === "docker";
21406
21646
  if (inContainer && anyPassword) {
21407
21647
  logger.warn("Security notice: running inside a container with a plaintext password in the environment. " + "Environment variables are visible via 'docker inspect'. Prefer Docker secrets / a key file. See SECURITY.md.");
21408
21648
  }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { z } from "zod";
9
9
  var DEFAULT_DASHBOARD_DB = join(homedir(), ".mikrotik-mcp", "events.db");
10
10
  var DEFAULT_SNAPSHOT_DB = join(homedir(), ".mikrotik-mcp", "snapshots.db");
11
11
  var DEFAULT_BACKUP_DIR = join(homedir(), ".mikrotik-mcp", "backups");
12
+ var DEFAULT_CONFIG_HISTORY_DIR = join(homedir(), ".mikrotik-mcp", "config-history");
12
13
  var DEFAULT_CONFIG_FILE = join(homedir(), ".mikrotik-mcp", "config.json");
13
14
  var TransportSchema = z.enum(["stdio", "sse", "streamable-http"]);
14
15
  var McpServerSettingsSchema = z.object({
@@ -1036,6 +1037,16 @@ function looksLikeError(result) {
1036
1037
  const t = result.toLowerCase();
1037
1038
  return t.includes("failure:") || t.includes("syntax error") || t.includes("bad command") || t.includes("bad parameter") || t.includes("expected end of command") || t.includes("invalid value") || t.includes("input does not match") || t.includes("ambiguous value") || t.startsWith("error");
1038
1039
  }
1040
+ function placeBeforeError(result, placeBefore) {
1041
+ if (!placeBefore)
1042
+ return;
1043
+ const t = result.toLowerCase();
1044
+ if (!t.includes("place-before") || !(t.includes("does not exist") || t.includes("not found"))) {
1045
+ return;
1046
+ }
1047
+ const detail = placeBefore.startsWith("*") ? `A '*N' value is an internal .id (hexadecimal, reassigned over time), NOT the row number \u2014 list the rules first and use a CURRENT .id, or pass a bare ordinal position instead (e.g. '${placeBefore.slice(1)}').` : "Pass a bare ordinal position (e.g. '0') or a current internal .id ('*N') from a list call.";
1048
+ return `place_before '${placeBefore}' does not exist on the device. ${detail}`;
1049
+ }
1039
1050
  function commandUnsupported(result) {
1040
1051
  const t = result.toLowerCase();
1041
1052
  return t.includes("bad command name") || t.includes("no such command") || t.includes("no such command prefix") || t.includes("invalid command name");
@@ -5249,7 +5260,7 @@ var firewallFilterTools = [
5249
5260
  disabled: z18.boolean().default(false),
5250
5261
  log: z18.boolean().default(false),
5251
5262
  log_prefix: z18.string().optional(),
5252
- place_before: z18.string().optional().describe('Rule number or ID (*N) to insert before e.g. "0" or "*3"')
5263
+ place_before: z18.string().optional().describe('Insert before this position. Either a bare ordinal/row number (e.g. "0", "13") OR a ' + 'CURRENT internal .id ("*N") from list_filter_rules. Note: a "*N" .id is hexadecimal ' + 'and reassigned over time \u2014 it is NOT the row number, so "*13" \u2260 the 13th rule.')
5253
5264
  },
5254
5265
  async handler(a, ctx) {
5255
5266
  ctx.info(`Creating firewall filter rule: chain=${a.chain}, action=${a.action}`);
@@ -5263,7 +5274,8 @@ var firewallFilterTools = [
5263
5274
 
5264
5275
  ${details}` : `Firewall filter rule created with ID: ${result}`;
5265
5276
  }
5266
- return `Failed to create firewall filter rule: ${result}`;
5277
+ const hint = placeBeforeError(result, a.place_before);
5278
+ return `Failed to create firewall filter rule: ${hint ?? result}`;
5267
5279
  }
5268
5280
  const count = await executeMikrotikCommand("/ip firewall filter print detail count-only", ctx);
5269
5281
  const c = count.trim();
@@ -5559,7 +5571,8 @@ var firewallNatTools = [
5559
5571
 
5560
5572
  ${details}` : `NAT rule created with ID: ${result}`;
5561
5573
  }
5562
- return `Failed to create NAT rule: ${result}`;
5574
+ const hint = placeBeforeError(result, a.place_before);
5575
+ return `Failed to create NAT rule: ${hint ?? result}`;
5563
5576
  }
5564
5577
  const count = await executeMikrotikCommand("/ip firewall nat print detail count-only", ctx);
5565
5578
  const c = count.trim();
@@ -19684,7 +19697,7 @@ var allToolModules = moduleCatalog.map((m) => m.tools);
19684
19697
  // package.json
19685
19698
  var package_default = {
19686
19699
  name: "@usex/mikrotik-mcp",
19687
- version: "3.4.0",
19700
+ version: "3.5.0",
19688
19701
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
19689
19702
  keywords: [
19690
19703
  "ai",
@@ -19766,18 +19779,24 @@ var package_default = {
19766
19779
  },
19767
19780
  devDependencies: {
19768
19781
  "@resvg/resvg-js": "^2.6.2",
19782
+ "@tailwindcss/vite": "^4.3.1",
19769
19783
  "@types/bun": "latest",
19770
- "@types/node": "^26.0.0",
19784
+ "@types/node": "^26.0.1",
19771
19785
  "@types/react": "^19.2.17",
19772
19786
  "@types/react-dom": "^19.2.3",
19773
19787
  "@types/ssh2": "^1.15.5",
19774
19788
  "@types/update-notifier": "^6.0.8",
19775
19789
  bunup: "^0.16.32",
19790
+ "class-variance-authority": "^0.7.1",
19791
+ clsx: "^2.1.1",
19776
19792
  gsap: "^3.15.0",
19777
19793
  prettier: "^3.8.4",
19778
19794
  react: "^19.2.7",
19779
19795
  "react-dom": "^19.2.7",
19796
+ recharts: "^3.9.0",
19780
19797
  "release-it": "^20.2.0",
19798
+ "tailwind-merge": "^3.6.0",
19799
+ tailwindcss: "^4.3.1",
19781
19800
  vite: "npm:@voidzero-dev/vite-plus-core@latest",
19782
19801
  "vite-plus": "latest"
19783
19802
  },