@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 +317 -77
- package/dist/index.js +24 -5
- package/dist/ui/observability.html +67 -14
- package/package.json +8 -2
- package/schemas/tool-catalog.json +2 -2
- package/schemas/tools/create_filter_rule.json +1 -1
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
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19687
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
19614
19688
|
import { homedir as homedir2, networkInterfaces } from "os";
|
|
19615
|
-
import { join as
|
|
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
|
|
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
|
|
19944
|
-
|
|
19945
|
-
|
|
19946
|
-
|
|
19947
|
-
|
|
19948
|
-
|
|
19949
|
-
|
|
19950
|
-
|
|
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:
|
|
19957
|
-
boardName:
|
|
19958
|
-
architecture:
|
|
19959
|
-
cpuCount:
|
|
19960
|
-
cpuLoad,
|
|
19961
|
-
freeMemory,
|
|
19962
|
-
totalMemory,
|
|
19963
|
-
memUsedPct,
|
|
19964
|
-
freeHdd,
|
|
19965
|
-
totalHdd,
|
|
19966
|
-
hddUsedPct,
|
|
19967
|
-
uptime:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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("~/") ?
|
|
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
|
|
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
|
|
20960
|
-
import { join as
|
|
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 =
|
|
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(
|
|
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.
|
|
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.
|
|
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 =
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
},
|