mikromcp 1.14.1 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/config/identities.example.yaml +2 -0
- package/dist/main.js +420 -74
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
|
|
14
14
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
|
|
15
|
-
[](package.json)
|
|
16
16
|
[](LICENSE)
|
|
17
17
|
[](package.json)
|
|
18
18
|
[](https://help.mikrotik.com/docs/display/ROS/REST+API)
|
|
19
19
|
[](https://modelcontextprotocol.io)
|
|
20
|
-
[](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools)
|
|
21
21
|
[](https://glama.ai/mcp/servers/AliKarami/MikroMCP)
|
|
22
22
|
|
|
23
23
|
MikroMCP exists because raw router CLI access is the wrong abstraction for AI agents. RouterOS is powerful, but asking an LLM to improvise shell commands against production network gear is risky. MikroMCP gives agents a controlled tool surface: strict schemas, idempotent writes, dry-run previews, per-router circuit breakers, retry policies, RBAC, audit logs, snapshots, and rollback-aware change workflows.
|
|
@@ -54,7 +54,7 @@ That's the whole setup for a single-router stdio deployment. For standalone bina
|
|
|
54
54
|
| 🤖 **AI-agent fit** | Human-readable responses plus structured JSON content for reasoning, chaining, and automation; server advertises an `instructions` string on MCP initialize so clients self-configure; optional [`routerId` resolved via `MIKROMCP_DEFAULT_ROUTER`](https://github.com/AliKarami/MikroMCP/wiki/Configuration) for single-router setups; [usage skill](docs/wiki/Using-the-Skill.md) for safe, guided tool use in Claude Code |
|
|
55
55
|
| 🧩 **MCP compatibility** | stdio for desktop clients, Streamable HTTP and legacy SSE for remote or service-style clients |
|
|
56
56
|
|
|
57
|
-
**
|
|
57
|
+
**133 typed tools** in total — browse the full catalog with parameters, defaults, and copy-paste example prompts in **[Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools)**.
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
@@ -140,7 +140,7 @@ AI assistants can use MikroMCP to:
|
|
|
140
140
|
|
|
141
141
|
### What is MikroMCP?
|
|
142
142
|
|
|
143
|
-
MikroMCP is an open-source [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that exposes MikroTik RouterOS as
|
|
143
|
+
MikroMCP is an open-source [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that exposes MikroTik RouterOS as 133 typed, auditable tools — letting AI assistants inspect, diagnose, and safely operate routers in natural language instead of improvising CLI commands.
|
|
144
144
|
|
|
145
145
|
### MikroMCP vs RouterOS API
|
|
146
146
|
|
|
@@ -189,7 +189,7 @@ The README stays intentionally short. Everything below is documented in depth in
|
|
|
189
189
|
| [Connecting to Claude Desktop](https://github.com/AliKarami/MikroMCP/wiki/Connecting-to-Claude-Desktop) | Register MikroMCP in Claude Desktop |
|
|
190
190
|
| [Connecting to AI Assistants](https://github.com/AliKarami/MikroMCP/wiki/Connecting-to-AI-Assistants) | Claude Code, Cursor, Codex, HTTP/Docker/systemd |
|
|
191
191
|
| [Using the Skill](docs/wiki/Using-the-Skill.md) | Install the MikroMCP usage skill so your assistant drives the tools safely |
|
|
192
|
-
| [Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools) | All
|
|
192
|
+
| [Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools) | All 133 tools — parameters and example prompts |
|
|
193
193
|
| [Architecture](https://github.com/AliKarami/MikroMCP/wiki/Architecture) | System layers, request pipeline, auth model |
|
|
194
194
|
| [Error Handling](https://github.com/AliKarami/MikroMCP/wiki/Error-Handling) | Error categories, retry engine, circuit breaker |
|
|
195
195
|
| [Security](https://github.com/AliKarami/MikroMCP/wiki/Security) | Threat model, hardening checklist, vulnerability reporting |
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
#
|
|
20
20
|
# allowedRouters: [] means all routers are accessible.
|
|
21
21
|
# allowedToolPatterns: [] means all tools are accessible. Supports * wildcard.
|
|
22
|
+
# Fleet tools (bulk_read, bulk_execute) must match by their own name too;
|
|
23
|
+
# they also check each tool they fan out.
|
|
22
24
|
|
|
23
25
|
identities:
|
|
24
26
|
# CI pipeline: operator access to specific routers, limited tool set
|
package/dist/main.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/main.ts
|
|
4
4
|
import { config as loadDotenv } from "dotenv";
|
|
5
|
-
import { join as
|
|
5
|
+
import { join as join7 } from "path";
|
|
6
6
|
import { homedir as homedir4 } from "os";
|
|
7
7
|
|
|
8
8
|
// src/cli/index.ts
|
|
@@ -398,6 +398,13 @@ function loggableParams(tool, params) {
|
|
|
398
398
|
return { routerId: params.routerId, params: "<withheld: redaction failed>" };
|
|
399
399
|
}
|
|
400
400
|
}
|
|
401
|
+
function isEffectiveDryRun(tool, params) {
|
|
402
|
+
try {
|
|
403
|
+
return tool.inputSchema.parse(params).dryRun === true;
|
|
404
|
+
} catch {
|
|
405
|
+
return params.dryRun === true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
401
408
|
function snapshotPathsFor(tool, params) {
|
|
402
409
|
return typeof tool.snapshotPaths === "function" ? tool.snapshotPaths(params) : tool.snapshotPaths ?? [];
|
|
403
410
|
}
|
|
@@ -2666,7 +2673,7 @@ var runCommandInputSchema = z10.object({
|
|
|
2666
2673
|
var runCommandTool = {
|
|
2667
2674
|
name: "run_command",
|
|
2668
2675
|
title: "Run Command",
|
|
2669
|
-
description: "Execute an arbitrary RouterOS console command via SSH. Guarded by an allow/deny policy (built-in deny list blocks destructive commands; tighten via cmdAllow in routers.yaml or MIKROMCP_CMD_ALLOW). Prefer dedicated tools (reboot, etc.) where available. Output capped at 4000 characters.",
|
|
2676
|
+
description: "Execute an arbitrary RouterOS console command via SSH. Guarded by an allow/deny policy (built-in deny list blocks destructive commands; tighten via cmdAllow in routers.yaml or MIKROMCP_CMD_ALLOW). Prefer dedicated tools (reboot, etc.) where available. Output capped at 4000 characters. Journaled for audit like every write (see list_journal), but never snapshotted: arbitrary commands touch unknown config sections, so it cannot be rolled back via rollback_change \u2014 prefer a dedicated tool when one exists, and note the manual undo command in your own records.",
|
|
2670
2677
|
inputSchema: runCommandInputSchema,
|
|
2671
2678
|
annotations: {
|
|
2672
2679
|
readOnlyHint: false,
|
|
@@ -2725,6 +2732,26 @@ var systemOpsTools = [
|
|
|
2725
2732
|
|
|
2726
2733
|
// src/domain/tools/bridge-tools.ts
|
|
2727
2734
|
import { z as z11 } from "zod";
|
|
2735
|
+
|
|
2736
|
+
// src/domain/tools/interface-name.ts
|
|
2737
|
+
var NEW_INTERFACE_NAME_MAX = 64;
|
|
2738
|
+
var NEW_INTERFACE_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
|
2739
|
+
function assertNewInterfaceName(name, opts) {
|
|
2740
|
+
const tooLong = name.length > NEW_INTERFACE_NAME_MAX;
|
|
2741
|
+
if (!tooLong && NEW_INTERFACE_NAME_PATTERN.test(name)) return;
|
|
2742
|
+
throw new MikroMCPError({
|
|
2743
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
2744
|
+
code: opts.code,
|
|
2745
|
+
message: tooLong ? `${opts.kind} name is ${name.length} chars; ${opts.action} allows at most ${NEW_INTERFACE_NAME_MAX}.` : `${opts.kind} name "${name}" uses characters ${opts.action} does not allow (permitted: letters, digits, dot, hyphen, underscore).`,
|
|
2746
|
+
details: { name },
|
|
2747
|
+
recoverability: {
|
|
2748
|
+
retryable: false,
|
|
2749
|
+
suggestedAction: `Use a name of letters, digits, dot, hyphen or underscore, at most ${NEW_INTERFACE_NAME_MAX} chars.`
|
|
2750
|
+
}
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// src/domain/tools/bridge-tools.ts
|
|
2728
2755
|
var log9 = createLogger("bridge-tools");
|
|
2729
2756
|
var listBridgesInputSchema = z11.object({
|
|
2730
2757
|
routerId,
|
|
@@ -2865,7 +2892,9 @@ var listBridgePortsTool = {
|
|
|
2865
2892
|
var manageBridgeInputSchema = z11.object({
|
|
2866
2893
|
routerId,
|
|
2867
2894
|
action: z11.enum(["create", "remove"]).describe("Action to perform"),
|
|
2868
|
-
name: z11.string().
|
|
2895
|
+
name: z11.string().min(1).describe(
|
|
2896
|
+
"Bridge interface name, taken verbatim for remove; create accepts letters, digits, dot, hyphen, underscore, up to 64 chars"
|
|
2897
|
+
),
|
|
2869
2898
|
comment: z11.string().max(255).optional().describe("Optional comment"),
|
|
2870
2899
|
disabled: z11.boolean().default(false).describe("Whether the bridge should be disabled"),
|
|
2871
2900
|
dryRun
|
|
@@ -2893,6 +2922,11 @@ var manageBridgeTool = {
|
|
|
2893
2922
|
filter: { name: parsed.name }
|
|
2894
2923
|
});
|
|
2895
2924
|
if (parsed.action === "create") {
|
|
2925
|
+
assertNewInterfaceName(parsed.name, {
|
|
2926
|
+
kind: "Bridge",
|
|
2927
|
+
action: "create",
|
|
2928
|
+
code: "BRIDGE_NAME_INVALID"
|
|
2929
|
+
});
|
|
2896
2930
|
if (existing.length > 0) {
|
|
2897
2931
|
return {
|
|
2898
2932
|
content: `Bridge "${parsed.name}" already exists. No changes made.`,
|
|
@@ -6326,7 +6360,6 @@ var containerTools = [listContainersTool, manageContainerTool];
|
|
|
6326
6360
|
|
|
6327
6361
|
// src/domain/tools/change-management-tools.ts
|
|
6328
6362
|
import { z as z25 } from "zod";
|
|
6329
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
6330
6363
|
|
|
6331
6364
|
// src/domain/snapshot/snapshot-engine.ts
|
|
6332
6365
|
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
@@ -8047,8 +8080,10 @@ async function loadSnapshot(filePath) {
|
|
|
8047
8080
|
}
|
|
8048
8081
|
|
|
8049
8082
|
// src/domain/snapshot/write-journal.ts
|
|
8083
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
8084
|
+
import { readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
8050
8085
|
import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
|
|
8051
|
-
import { dirname } from "path";
|
|
8086
|
+
import { basename, dirname, extname, join as join3 } from "path";
|
|
8052
8087
|
import { nanoid as nanoid2 } from "nanoid";
|
|
8053
8088
|
|
|
8054
8089
|
// src/observability/audit-log.ts
|
|
@@ -8117,10 +8152,77 @@ function recordAttempt(args) {
|
|
|
8117
8152
|
routerId: args.routerId,
|
|
8118
8153
|
params: redactParams(args.params),
|
|
8119
8154
|
snapshotIds: args.snapshotIds,
|
|
8155
|
+
...args.dryRun ? { dryRun: true } : {},
|
|
8120
8156
|
phase: "attempt"
|
|
8121
8157
|
});
|
|
8122
8158
|
return id;
|
|
8123
8159
|
}
|
|
8160
|
+
function parseJournal(raw) {
|
|
8161
|
+
const entries = [];
|
|
8162
|
+
for (const line of raw.split("\n")) {
|
|
8163
|
+
if (!line.trim()) continue;
|
|
8164
|
+
try {
|
|
8165
|
+
const entry = JSON.parse(line);
|
|
8166
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
8167
|
+
entries.push(entry);
|
|
8168
|
+
}
|
|
8169
|
+
} catch {
|
|
8170
|
+
continue;
|
|
8171
|
+
}
|
|
8172
|
+
}
|
|
8173
|
+
return entries;
|
|
8174
|
+
}
|
|
8175
|
+
var SEGMENT_STAMP = /^\d{8}T\d{6}Z-[0-9a-z]+$/;
|
|
8176
|
+
function journalSegmentName(journalPath, now, suffix = randomBytes2(6).toString("hex")) {
|
|
8177
|
+
const ext = extname(journalPath);
|
|
8178
|
+
const stamp2 = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
8179
|
+
return join3(dirname(journalPath), `${basename(journalPath, ext)}.${stamp2}-${suffix}${ext}`);
|
|
8180
|
+
}
|
|
8181
|
+
function journalSegmentPaths(journalPath) {
|
|
8182
|
+
const ext = extname(journalPath);
|
|
8183
|
+
const prefix = `${basename(journalPath, ext)}.`;
|
|
8184
|
+
let names;
|
|
8185
|
+
try {
|
|
8186
|
+
names = readdirSync(dirname(journalPath));
|
|
8187
|
+
} catch {
|
|
8188
|
+
return [];
|
|
8189
|
+
}
|
|
8190
|
+
return names.filter(
|
|
8191
|
+
(n) => n.startsWith(prefix) && n.endsWith(ext) && SEGMENT_STAMP.test(n.slice(prefix.length, n.length - ext.length))
|
|
8192
|
+
).sort().map((n) => join3(dirname(journalPath), n));
|
|
8193
|
+
}
|
|
8194
|
+
function journalSegmentRotatedAt(segmentPath) {
|
|
8195
|
+
const stamp2 = /\.(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-[0-9a-z]+\.[^.]+$/.exec(
|
|
8196
|
+
segmentPath
|
|
8197
|
+
);
|
|
8198
|
+
if (!stamp2) return NaN;
|
|
8199
|
+
const [, y, mo, d, h, mi, se] = stamp2;
|
|
8200
|
+
return Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${se}Z`);
|
|
8201
|
+
}
|
|
8202
|
+
function readIfExists(path) {
|
|
8203
|
+
try {
|
|
8204
|
+
return readFileSync2(path, "utf-8");
|
|
8205
|
+
} catch (err) {
|
|
8206
|
+
if (err.code === "ENOENT") return "";
|
|
8207
|
+
throw err;
|
|
8208
|
+
}
|
|
8209
|
+
}
|
|
8210
|
+
function readJournalEntries(journalPath) {
|
|
8211
|
+
const current = parseJournal(readIfExists(journalPath));
|
|
8212
|
+
const segments2 = journalSegmentPaths(journalPath).flatMap((p) => parseJournal(readIfExists(p)));
|
|
8213
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8214
|
+
const entries = [...segments2, ...current].filter((e) => {
|
|
8215
|
+
const key2 = `${String(e.id)}\0${String(e.phase)}`;
|
|
8216
|
+
if (seen.has(key2)) return false;
|
|
8217
|
+
seen.add(key2);
|
|
8218
|
+
return true;
|
|
8219
|
+
});
|
|
8220
|
+
const time = (e) => {
|
|
8221
|
+
const t = typeof e.ts === "string" ? Date.parse(e.ts) : NaN;
|
|
8222
|
+
return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY;
|
|
8223
|
+
};
|
|
8224
|
+
return entries.map((e, i) => ({ e, i, t: time(e) })).sort((a, b) => a.t - b.t || a.i - b.i).map(({ e }) => e);
|
|
8225
|
+
}
|
|
8124
8226
|
function recordOutcome(args) {
|
|
8125
8227
|
void appendLine(args.journalPath, {
|
|
8126
8228
|
id: args.journalId,
|
|
@@ -9026,6 +9128,104 @@ var applyPlanInputSchema = z25.object({
|
|
|
9026
9128
|
steps: z25.array(stepSchema).min(1).max(10).describe("Ordered list of write operations to apply in sequence"),
|
|
9027
9129
|
confirmationToken: z25.string().optional()
|
|
9028
9130
|
}).strict();
|
|
9131
|
+
function journalEntryWasDryRun(entry) {
|
|
9132
|
+
const params = entry.params ?? {};
|
|
9133
|
+
return entry.dryRun === true || params.dryRun === true;
|
|
9134
|
+
}
|
|
9135
|
+
var listJournalInputSchema = z25.object({
|
|
9136
|
+
routerId: z25.string().optional().describe("Only entries for this router id"),
|
|
9137
|
+
tool: z25.string().optional().describe("Only entries written by this tool"),
|
|
9138
|
+
limit: z25.number().int().min(1).max(500).default(20).describe("Max entries to return")
|
|
9139
|
+
}).strict();
|
|
9140
|
+
var listJournalTool = {
|
|
9141
|
+
name: "list_journal",
|
|
9142
|
+
title: "List Write Journal",
|
|
9143
|
+
description: "List recent entries from the server's write journal (write-journal.ndjson): every journaled write with its journalId, timestamp, tool, router, parameters (as redacted at write time), snapshot ids and outcome \u2014 newest first. Read-only. Use it to recover a lost journalId for rollback_change, or to audit what was written when. Only entries with rollbackable: true can be rolled back \u2014 dry runs and writes without snapshots (run_command, plan_changes) cannot.",
|
|
9144
|
+
inputSchema: listJournalInputSchema,
|
|
9145
|
+
annotations: {
|
|
9146
|
+
readOnlyHint: true,
|
|
9147
|
+
destructiveHint: false,
|
|
9148
|
+
idempotentHint: true,
|
|
9149
|
+
openWorldHint: false
|
|
9150
|
+
},
|
|
9151
|
+
skipRouterContext: true,
|
|
9152
|
+
async handler(params, context) {
|
|
9153
|
+
const parsed = listJournalInputSchema.parse(params);
|
|
9154
|
+
const journalPath = context.appConfig.journalPath;
|
|
9155
|
+
if (!journalPath) {
|
|
9156
|
+
return {
|
|
9157
|
+
content: "Write journal is disabled (no journal path configured on this server).",
|
|
9158
|
+
structuredContent: { journalEnabled: false, entries: [], total: 0, returned: 0 }
|
|
9159
|
+
};
|
|
9160
|
+
}
|
|
9161
|
+
let journal;
|
|
9162
|
+
try {
|
|
9163
|
+
journal = readJournalEntries(journalPath);
|
|
9164
|
+
} catch (err) {
|
|
9165
|
+
throw new MikroMCPError({
|
|
9166
|
+
category: "INTERNAL" /* INTERNAL */,
|
|
9167
|
+
code: "JOURNAL_READ_FAILED",
|
|
9168
|
+
message: `Could not read the write journal: ${err.message}`,
|
|
9169
|
+
details: { journalPath },
|
|
9170
|
+
recoverability: {
|
|
9171
|
+
retryable: false,
|
|
9172
|
+
suggestedAction: "Check the file's permissions under the MikroMCP data directory."
|
|
9173
|
+
}
|
|
9174
|
+
});
|
|
9175
|
+
}
|
|
9176
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
9177
|
+
const attempts = [];
|
|
9178
|
+
for (const entry of journal) {
|
|
9179
|
+
if (entry.phase === "attempt") attempts.push(entry);
|
|
9180
|
+
else
|
|
9181
|
+
outcomes.set(entry.id, {
|
|
9182
|
+
phase: entry.phase,
|
|
9183
|
+
outcome: entry.outcome,
|
|
9184
|
+
durationMs: entry.durationMs
|
|
9185
|
+
});
|
|
9186
|
+
}
|
|
9187
|
+
let entries = attempts.map((a) => {
|
|
9188
|
+
const outcome = outcomes.get(a.id);
|
|
9189
|
+
const snapshotIds = Array.isArray(a.snapshotIds) ? a.snapshotIds : [];
|
|
9190
|
+
const dryRun2 = journalEntryWasDryRun(a);
|
|
9191
|
+
return {
|
|
9192
|
+
journalId: a.id,
|
|
9193
|
+
ts: a.ts,
|
|
9194
|
+
tool: a.tool,
|
|
9195
|
+
routerId: a.routerId,
|
|
9196
|
+
identityId: a.identityId,
|
|
9197
|
+
role: a.role,
|
|
9198
|
+
params: a.params ?? {},
|
|
9199
|
+
snapshotIds,
|
|
9200
|
+
dryRun: dryRun2,
|
|
9201
|
+
rollbackable: !dryRun2 && snapshotIds.length > 0,
|
|
9202
|
+
status: outcome ? outcome.phase : "unknown",
|
|
9203
|
+
outcomeCode: outcome?.outcome,
|
|
9204
|
+
durationMs: outcome?.durationMs
|
|
9205
|
+
};
|
|
9206
|
+
}).reverse();
|
|
9207
|
+
if (parsed.routerId) entries = entries.filter((e) => e.routerId === parsed.routerId);
|
|
9208
|
+
if (parsed.tool) entries = entries.filter((e) => e.tool === parsed.tool);
|
|
9209
|
+
const scope = context.identity.allowedRouters;
|
|
9210
|
+
if (scope.length > 0) entries = entries.filter((e) => scope.includes(e.routerId));
|
|
9211
|
+
const total = entries.length;
|
|
9212
|
+
const returned = entries.slice(0, parsed.limit);
|
|
9213
|
+
const header = total === 0 ? "Write journal: no entries." : `Write journal: ${returned.length} of ${total}, newest first.`;
|
|
9214
|
+
const lines = returned.map(
|
|
9215
|
+
(e) => ` ${String(e.journalId)} ${String(e.tool)} ${String(e.routerId)} \u2192 ${e.status}${e.outcomeCode ? ` (${e.outcomeCode})` : ""}${e.dryRun ? " dry-run" : e.rollbackable ? "" : " not-rollbackable"} [${String(e.ts)}]`
|
|
9216
|
+
);
|
|
9217
|
+
return {
|
|
9218
|
+
content: total === 0 ? header : [header, ...lines].join("\n"),
|
|
9219
|
+
structuredContent: {
|
|
9220
|
+
journalEnabled: true,
|
|
9221
|
+
entries: returned,
|
|
9222
|
+
total,
|
|
9223
|
+
returned: returned.length,
|
|
9224
|
+
note: "Pass the journalId of an entry with rollbackable: true to rollback_change to restore that write. Dry runs and entries without snapshots (e.g. run_command) cannot be rolled back."
|
|
9225
|
+
}
|
|
9226
|
+
};
|
|
9227
|
+
}
|
|
9228
|
+
};
|
|
9029
9229
|
var rollbackChangeInputSchema = z25.object({
|
|
9030
9230
|
routerId,
|
|
9031
9231
|
journalId: z25.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
|
|
@@ -9246,7 +9446,8 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9246
9446
|
tool: step.tool,
|
|
9247
9447
|
routerId: context.routerId,
|
|
9248
9448
|
params: loggableParams(tool, stepParams),
|
|
9249
|
-
snapshotIds
|
|
9449
|
+
snapshotIds,
|
|
9450
|
+
dryRun: isEffectiveDryRun(tool, stepParams)
|
|
9250
9451
|
});
|
|
9251
9452
|
const stepStartMs = Date.now();
|
|
9252
9453
|
try {
|
|
@@ -9299,9 +9500,9 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9299
9500
|
}
|
|
9300
9501
|
};
|
|
9301
9502
|
function findJournalEntry(journalPath, journalId) {
|
|
9302
|
-
const
|
|
9303
|
-
|
|
9304
|
-
|
|
9503
|
+
const entry = readJournalEntries(journalPath).find(
|
|
9504
|
+
(e) => e.id === journalId && e.phase === "attempt"
|
|
9505
|
+
);
|
|
9305
9506
|
if (!entry) {
|
|
9306
9507
|
throw new MikroMCPError({
|
|
9307
9508
|
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
@@ -9313,7 +9514,10 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9313
9514
|
}
|
|
9314
9515
|
});
|
|
9315
9516
|
}
|
|
9316
|
-
return {
|
|
9517
|
+
return {
|
|
9518
|
+
snapshotIds: entry.snapshotIds ?? [],
|
|
9519
|
+
dryRun: journalEntryWasDryRun(entry)
|
|
9520
|
+
};
|
|
9317
9521
|
}
|
|
9318
9522
|
const rollbackChangeTool = {
|
|
9319
9523
|
name: "rollback_change",
|
|
@@ -9332,7 +9536,16 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9332
9536
|
const journalPath = context.appConfig.journalPath;
|
|
9333
9537
|
const snapshotDir = context.appConfig.snapshotDir;
|
|
9334
9538
|
log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
|
|
9335
|
-
const { snapshotIds } = findJournalEntry(
|
|
9539
|
+
const { snapshotIds, dryRun: entryWasDryRun } = findJournalEntry(
|
|
9540
|
+
journalPath,
|
|
9541
|
+
parsed.journalId
|
|
9542
|
+
);
|
|
9543
|
+
if (entryWasDryRun) {
|
|
9544
|
+
return {
|
|
9545
|
+
content: `Journal entry "${parsed.journalId}" was a dry run \u2014 nothing was changed, so there is nothing to roll back.`,
|
|
9546
|
+
structuredContent: { action: "dry_run_entry", journalId: parsed.journalId }
|
|
9547
|
+
};
|
|
9548
|
+
}
|
|
9336
9549
|
if (snapshotIds.length === 0) {
|
|
9337
9550
|
return {
|
|
9338
9551
|
content: `Journal entry "${parsed.journalId}" has no associated snapshots \u2014 cannot rollback automatically.`,
|
|
@@ -9387,7 +9600,7 @@ ${warnings.map((w) => ` - ${w}`).join("\n")}` : "";
|
|
|
9387
9600
|
};
|
|
9388
9601
|
}
|
|
9389
9602
|
};
|
|
9390
|
-
return [planChangesTool, applyPlanTool, rollbackChangeTool];
|
|
9603
|
+
return [planChangesTool, applyPlanTool, rollbackChangeTool, listJournalTool];
|
|
9391
9604
|
}
|
|
9392
9605
|
|
|
9393
9606
|
// src/domain/tools/ipsec-tools.ts
|
|
@@ -11860,8 +12073,8 @@ function sanitizeComment3(comment) {
|
|
|
11860
12073
|
var manageVlanInputSchema = z35.object({
|
|
11861
12074
|
routerId,
|
|
11862
12075
|
action: z35.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
11863
|
-
name: z35.string().
|
|
11864
|
-
"VLAN interface name \u2014 idempotency key
|
|
12076
|
+
name: z35.string().min(1).describe(
|
|
12077
|
+
"VLAN interface name \u2014 idempotency key, taken verbatim for remove/enable/disable; add accepts letters, digits, dot, hyphen, underscore, up to 64 chars"
|
|
11865
12078
|
),
|
|
11866
12079
|
vlanId: z35.number().int().min(1).max(4094).optional().describe("VLAN ID (1-4094; required for add)"),
|
|
11867
12080
|
parentInterface: z35.string().optional().describe("Parent interface name (e.g., ether1, bridge1; required for add)"),
|
|
@@ -11898,6 +12111,11 @@ var manageVlanTool = {
|
|
|
11898
12111
|
(v) => sameValue(v.name, parsed.name)
|
|
11899
12112
|
);
|
|
11900
12113
|
if (parsed.action === "add") {
|
|
12114
|
+
assertNewInterfaceName(parsed.name, {
|
|
12115
|
+
kind: "VLAN",
|
|
12116
|
+
action: "add",
|
|
12117
|
+
code: "VLAN_NAME_INVALID"
|
|
12118
|
+
});
|
|
11901
12119
|
if (parsed.vlanId === void 0) {
|
|
11902
12120
|
throw new MikroMCPError({
|
|
11903
12121
|
category: "VALIDATION" /* VALIDATION */,
|
|
@@ -12042,9 +12260,12 @@ var vlanTools = [manageVlanTool];
|
|
|
12042
12260
|
// src/domain/tools/dhcp-client-tools.ts
|
|
12043
12261
|
import { z as z36 } from "zod";
|
|
12044
12262
|
var log35 = createLogger("dhcp-client-tools");
|
|
12263
|
+
function findClient(clients, target, byNameToo = true) {
|
|
12264
|
+
return clients.find((c) => sameValue(c.interface, target)) ?? (byNameToo ? clients.find((c) => sameValue(c.name, target)) : void 0);
|
|
12265
|
+
}
|
|
12045
12266
|
var listDhcpClientsInputSchema = z36.object({
|
|
12046
12267
|
routerId,
|
|
12047
|
-
interface: z36.string().optional().describe("Filter by interface name (exact match)"),
|
|
12268
|
+
interface: z36.string().optional().describe("Filter by interface or display name (exact match)"),
|
|
12048
12269
|
status: z36.enum(["bound", "searching", "requesting", "init", "all"]).default("all").describe("Filter by DHCP client status"),
|
|
12049
12270
|
limit,
|
|
12050
12271
|
offset
|
|
@@ -12070,7 +12291,9 @@ var listDhcpClientsTool = {
|
|
|
12070
12291
|
});
|
|
12071
12292
|
let filtered = allClients;
|
|
12072
12293
|
if (parsed.interface) {
|
|
12073
|
-
filtered = filtered.filter(
|
|
12294
|
+
filtered = filtered.filter(
|
|
12295
|
+
(c) => sameValue(c.interface, parsed.interface) || sameValue(c.name, parsed.interface)
|
|
12296
|
+
);
|
|
12074
12297
|
}
|
|
12075
12298
|
if (parsed.status !== "all") {
|
|
12076
12299
|
filtered = filtered.filter((c) => c.status === parsed.status);
|
|
@@ -12083,7 +12306,7 @@ var listDhcpClientsTool = {
|
|
|
12083
12306
|
clients,
|
|
12084
12307
|
total,
|
|
12085
12308
|
parsed.offset,
|
|
12086
|
-
(c) => compactFields(c, ["interface", "status", "address", "gateway", "disabled"])
|
|
12309
|
+
(c) => compactFields(c, ["interface", "name", "status", "address", "gateway", "disabled"])
|
|
12087
12310
|
),
|
|
12088
12311
|
structuredContent: {
|
|
12089
12312
|
routerId: context.routerId,
|
|
@@ -12102,7 +12325,9 @@ var listDhcpClientsTool = {
|
|
|
12102
12325
|
var manageDhcpClientInputSchema = z36.object({
|
|
12103
12326
|
routerId,
|
|
12104
12327
|
action: z36.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
12105
|
-
interface: z36.string().describe(
|
|
12328
|
+
interface: z36.string().describe(
|
|
12329
|
+
"Interface name \u2014 idempotency key (e.g. ether1); for remove/enable/disable a client's display name also matches"
|
|
12330
|
+
),
|
|
12106
12331
|
usePeerDns: z36.boolean().default(true).describe("Use DNS servers provided by DHCP server (add only)"),
|
|
12107
12332
|
usePeerNtp: z36.boolean().default(false).describe("Use NTP servers provided by DHCP server (add only)"),
|
|
12108
12333
|
addDefaultRoute: z36.boolean().default(true).describe("Add default route from DHCP (add only)"),
|
|
@@ -12132,8 +12357,10 @@ var manageDhcpClientTool = {
|
|
|
12132
12357
|
limit: void 0,
|
|
12133
12358
|
offset: void 0
|
|
12134
12359
|
});
|
|
12135
|
-
const existing =
|
|
12136
|
-
|
|
12360
|
+
const existing = findClient(
|
|
12361
|
+
allClients,
|
|
12362
|
+
parsed.interface,
|
|
12363
|
+
parsed.action !== "add"
|
|
12137
12364
|
);
|
|
12138
12365
|
if (parsed.action === "add") {
|
|
12139
12366
|
if (existing) {
|
|
@@ -12174,9 +12401,19 @@ var manageDhcpClientTool = {
|
|
|
12174
12401
|
}
|
|
12175
12402
|
if (parsed.action === "remove") {
|
|
12176
12403
|
if (!existing) {
|
|
12404
|
+
const candidates = allClients.map((c) => ({
|
|
12405
|
+
interface: c.interface,
|
|
12406
|
+
name: c.name,
|
|
12407
|
+
status: c.status
|
|
12408
|
+
}));
|
|
12409
|
+
const names = candidates.map((c) => c.name ?? c.interface).slice(0, 5).join(", ");
|
|
12177
12410
|
return {
|
|
12178
|
-
content: `No DHCP client found on "${parsed.interface}". Nothing to remove
|
|
12179
|
-
structuredContent: {
|
|
12411
|
+
content: `No DHCP client found on "${parsed.interface}". Nothing to remove.` + (names ? ` Existing clients: ${names}.` : ""),
|
|
12412
|
+
structuredContent: {
|
|
12413
|
+
action: "not_found",
|
|
12414
|
+
interface: parsed.interface,
|
|
12415
|
+
clients: candidates
|
|
12416
|
+
}
|
|
12180
12417
|
};
|
|
12181
12418
|
}
|
|
12182
12419
|
if (parsed.dryRun) {
|
|
@@ -12184,7 +12421,7 @@ var manageDhcpClientTool = {
|
|
|
12184
12421
|
content: `Dry run: Would remove DHCP client on "${parsed.interface}".`,
|
|
12185
12422
|
structuredContent: {
|
|
12186
12423
|
action: "dry_run",
|
|
12187
|
-
diff: [{ property: "interface", before:
|
|
12424
|
+
diff: [{ property: "interface", before: existing.interface, after: null }]
|
|
12188
12425
|
}
|
|
12189
12426
|
};
|
|
12190
12427
|
}
|
|
@@ -13541,13 +13778,54 @@ var log41 = createLogger("backup-tools");
|
|
|
13541
13778
|
var createBackupInputSchema = z42.object({
|
|
13542
13779
|
routerId,
|
|
13543
13780
|
name: z42.string().default("backup").describe("Backup file name (without extension)"),
|
|
13544
|
-
password: z42.string().optional().describe(
|
|
13781
|
+
password: z42.string().optional().describe(
|
|
13782
|
+
"Literal password to encrypt the backup. It stays in the conversation history; prefer passwordSecret"
|
|
13783
|
+
),
|
|
13784
|
+
passwordSecret: z42.string().regex(/^[A-Z0-9_]+$/, "a secret name is the NAME of MIKROMCP_SECRET_<NAME>").optional().describe(
|
|
13785
|
+
"Encrypt with the server's MIKROMCP_SECRET_<NAME> value, bound to this router by its secrets list in routers.yaml. The value never reaches the conversation"
|
|
13786
|
+
),
|
|
13545
13787
|
dryRun: z42.boolean().default(false).describe("Preview the backup without creating it")
|
|
13546
13788
|
}).strict();
|
|
13789
|
+
function backupError(code, message, suggestedAction) {
|
|
13790
|
+
return new MikroMCPError({
|
|
13791
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
13792
|
+
code,
|
|
13793
|
+
message,
|
|
13794
|
+
recoverability: { retryable: false, suggestedAction }
|
|
13795
|
+
});
|
|
13796
|
+
}
|
|
13797
|
+
function resolveBackupPassword(parsed, context) {
|
|
13798
|
+
const name = parsed.passwordSecret;
|
|
13799
|
+
if (name === void 0) return parsed.password;
|
|
13800
|
+
if (parsed.password !== void 0) {
|
|
13801
|
+
throw backupError(
|
|
13802
|
+
"BACKUP_PASSWORD_CONFLICT",
|
|
13803
|
+
"Pass either password or passwordSecret, not both.",
|
|
13804
|
+
"Drop password and keep passwordSecret, so the password stays out of the conversation."
|
|
13805
|
+
);
|
|
13806
|
+
}
|
|
13807
|
+
const bound = context.routerConfig.secrets ?? [];
|
|
13808
|
+
if (!bound.includes(name)) {
|
|
13809
|
+
throw backupError(
|
|
13810
|
+
"BACKUP_SECRET_NOT_BOUND",
|
|
13811
|
+
bound.length ? `Secret ${name} is not bound to router "${context.routerId}" (its routers.yaml secrets list names ${bound.join(", ")}).` : `Router "${context.routerId}" has no secrets list in routers.yaml, so it may use no secret.`,
|
|
13812
|
+
`Add ${name} to the router's \`secrets\` list in routers.yaml if it is meant for it.`
|
|
13813
|
+
);
|
|
13814
|
+
}
|
|
13815
|
+
const value = context.appConfig.configSecrets[name];
|
|
13816
|
+
if (!value) {
|
|
13817
|
+
throw backupError(
|
|
13818
|
+
"BACKUP_SECRET_MISSING",
|
|
13819
|
+
`Secret ${name} is bound to router "${context.routerId}", but MIKROMCP_SECRET_${name} is not set or empty.`,
|
|
13820
|
+
`Set MIKROMCP_SECRET_${name} in the MikroMCP server's environment and restart it.`
|
|
13821
|
+
);
|
|
13822
|
+
}
|
|
13823
|
+
return value;
|
|
13824
|
+
}
|
|
13547
13825
|
var createBackupTool = {
|
|
13548
13826
|
name: "create_backup",
|
|
13549
13827
|
title: "Create Backup",
|
|
13550
|
-
description: "Create a binary configuration backup on a MikroTik router. The backup is saved as <name>.backup on the router's filesystem.
|
|
13828
|
+
description: "Create a binary configuration backup on a MikroTik router. The backup is saved as <name>.backup on the router's filesystem. To encrypt it, prefer passwordSecret (a MIKROMCP_SECRET_<NAME> bound to the router in routers.yaml \u2014 the password never reaches the conversation) over a literal password. Dry run checks that the secret resolves.",
|
|
13551
13829
|
inputSchema: createBackupInputSchema,
|
|
13552
13830
|
annotations: {
|
|
13553
13831
|
readOnlyHint: false,
|
|
@@ -13558,25 +13836,34 @@ var createBackupTool = {
|
|
|
13558
13836
|
async handler(params, context) {
|
|
13559
13837
|
const parsed = createBackupInputSchema.parse(params);
|
|
13560
13838
|
log41.info({ routerId: context.routerId, name: parsed.name }, "Creating backup");
|
|
13839
|
+
const password = resolveBackupPassword(parsed, context);
|
|
13840
|
+
const encrypted = password !== void 0 && password !== "";
|
|
13841
|
+
const how = encrypted ? "encrypted " : "";
|
|
13561
13842
|
if (parsed.dryRun) {
|
|
13562
13843
|
return {
|
|
13563
|
-
content: `Dry run: Would create backup "${parsed.name}.backup" on ${context.routerId}.`,
|
|
13564
|
-
structuredContent: {
|
|
13844
|
+
content: `Dry run: Would create ${how}backup "${parsed.name}.backup" on ${context.routerId}.`,
|
|
13845
|
+
structuredContent: {
|
|
13846
|
+
action: "dry_run",
|
|
13847
|
+
name: parsed.name,
|
|
13848
|
+
routerId: context.routerId,
|
|
13849
|
+
encrypted
|
|
13850
|
+
}
|
|
13565
13851
|
};
|
|
13566
13852
|
}
|
|
13567
13853
|
try {
|
|
13568
13854
|
const body = { name: parsed.name };
|
|
13569
|
-
if (
|
|
13570
|
-
body.password =
|
|
13855
|
+
if (password !== void 0) {
|
|
13856
|
+
body.password = password;
|
|
13571
13857
|
}
|
|
13572
13858
|
await context.routerClient.execute("system/backup/save", body);
|
|
13573
|
-
log41.info({ routerId: context.routerId, name: parsed.name }, "Backup created");
|
|
13859
|
+
log41.info({ routerId: context.routerId, name: parsed.name, encrypted }, "Backup created");
|
|
13574
13860
|
return {
|
|
13575
|
-
content: `Backup "${parsed.name}.backup" created on ${context.routerId}.`,
|
|
13861
|
+
content: `Backup "${parsed.name}.backup" created ${how}on ${context.routerId}.`,
|
|
13576
13862
|
structuredContent: {
|
|
13577
13863
|
action: "created",
|
|
13578
13864
|
routerId: context.routerId,
|
|
13579
|
-
filePath: `${parsed.name}.backup
|
|
13865
|
+
filePath: `${parsed.name}.backup`,
|
|
13866
|
+
encrypted
|
|
13580
13867
|
}
|
|
13581
13868
|
};
|
|
13582
13869
|
} catch (err) {
|
|
@@ -15877,7 +16164,7 @@ var swosConfigTools = [getSwosConfigTool, applySwosConfigTool];
|
|
|
15877
16164
|
import { z as z49 } from "zod";
|
|
15878
16165
|
import { createHash as createHash2 } from "crypto";
|
|
15879
16166
|
import { chmod, mkdir as mkdir3, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
15880
|
-
import { join as
|
|
16167
|
+
import { join as join4, resolve, sep } from "path";
|
|
15881
16168
|
var log48 = createLogger("swos-maintenance-tools");
|
|
15882
16169
|
var DOWNLOAD_BASE = "https://download.mikrotik.com/swos";
|
|
15883
16170
|
var FIRMWARE_MIN_BYTES = 16 * 1024;
|
|
@@ -15887,7 +16174,7 @@ var RESTORE_TIMEOUT_MS = 6e4;
|
|
|
15887
16174
|
var FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
|
|
15888
16175
|
var fileName = z49.string().regex(FILE_NAME, "a plain file name inside the server's SwOS folder, no path");
|
|
15889
16176
|
function swosDir(context) {
|
|
15890
|
-
return
|
|
16177
|
+
return join4(context.appConfig.dataDir, "swos");
|
|
15891
16178
|
}
|
|
15892
16179
|
function sha256(data) {
|
|
15893
16180
|
return createHash2("sha256").update(data).digest("hex");
|
|
@@ -16496,6 +16783,9 @@ function checkAuthz(identity, toolName, routerId2) {
|
|
|
16496
16783
|
}
|
|
16497
16784
|
});
|
|
16498
16785
|
}
|
|
16786
|
+
checkToolAllowed(identity, toolName);
|
|
16787
|
+
}
|
|
16788
|
+
function checkToolAllowed(identity, toolName) {
|
|
16499
16789
|
if (identity.allowedToolPatterns.length > 0) {
|
|
16500
16790
|
const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
|
|
16501
16791
|
if (!allowed) {
|
|
@@ -17332,6 +17622,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17332
17622
|
try {
|
|
17333
17623
|
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
17334
17624
|
if (tool.skipRouterContext) {
|
|
17625
|
+
checkToolAllowed(identity, tool.name);
|
|
17335
17626
|
const fleetContext = {
|
|
17336
17627
|
routerClient: fleetUnavailable("routerClient"),
|
|
17337
17628
|
deviceClient: fleetUnavailable("deviceClient"),
|
|
@@ -17410,6 +17701,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17410
17701
|
}
|
|
17411
17702
|
}
|
|
17412
17703
|
toolContext.snapshots = snapshots;
|
|
17704
|
+
const dryRun2 = !tool.annotations.readOnlyHint && isEffectiveDryRun(tool, handlerArgs);
|
|
17413
17705
|
if (!tool.annotations.readOnlyHint && config.journalPath) {
|
|
17414
17706
|
const { confirmationToken: _token, ...journalArgs } = loggedArgs;
|
|
17415
17707
|
journalId = recordAttempt({
|
|
@@ -17419,7 +17711,8 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17419
17711
|
tool: tool.name,
|
|
17420
17712
|
routerId: routerId2,
|
|
17421
17713
|
params: journalArgs,
|
|
17422
|
-
snapshotIds
|
|
17714
|
+
snapshotIds,
|
|
17715
|
+
dryRun: dryRun2
|
|
17423
17716
|
});
|
|
17424
17717
|
}
|
|
17425
17718
|
const runHandler = () => tool.handler(handlerArgs, toolContext);
|
|
@@ -17433,6 +17726,11 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17433
17726
|
phase: "success",
|
|
17434
17727
|
durationMs: Date.now() - startMs
|
|
17435
17728
|
});
|
|
17729
|
+
if (!dryRun2 && snapshotIds.length > 0) {
|
|
17730
|
+
result.structuredContent = { ...result.structuredContent, journalId };
|
|
17731
|
+
result.content += `
|
|
17732
|
+
Journal ID: ${journalId} (pass to rollback_change to undo).`;
|
|
17733
|
+
}
|
|
17436
17734
|
}
|
|
17437
17735
|
if (shouldAudit) {
|
|
17438
17736
|
auditLog(
|
|
@@ -17504,9 +17802,9 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17504
17802
|
}
|
|
17505
17803
|
|
|
17506
17804
|
// src/middleware/fleet-confirmation.ts
|
|
17507
|
-
import { createHmac as createHmac2, createHash as createHash5, randomBytes as
|
|
17805
|
+
import { createHmac as createHmac2, createHash as createHash5, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
17508
17806
|
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
17509
|
-
var processSecret =
|
|
17807
|
+
var processSecret = randomBytes3(32).toString("hex");
|
|
17510
17808
|
var usedTokens2 = /* @__PURE__ */ new Map();
|
|
17511
17809
|
function fingerprint(args) {
|
|
17512
17810
|
const sortedRouters = [...args.routerIds].sort();
|
|
@@ -17610,7 +17908,7 @@ var listRoutersInputSchema = z50.object({
|
|
|
17610
17908
|
var listRoutersTool = {
|
|
17611
17909
|
name: "list_routers",
|
|
17612
17910
|
title: "List Routers",
|
|
17613
|
-
description: "List the routers configured in the registry (routers.yaml): id, host, port, TLS status, tags, ROS version, and which is the default. Read-only reflection of local config \u2014 no RouterOS API call, no credentials in the response. Use it to discover valid routerId values and tags for targeting other tools (including bulk_read and bulk_execute).",
|
|
17911
|
+
description: "List the routers configured in the registry (routers.yaml): id, host, port, TLS status, tags, ROS version, and which is the default. Read-only reflection of local config \u2014 no RouterOS API call, no credentials in the response. rosVersion is what routers.yaml states and may lag a router upgrade \u2014 ask get_system_status for the live version. Use it to discover valid routerId values and tags for targeting other tools (including bulk_read and bulk_execute).",
|
|
17614
17912
|
inputSchema: listRoutersInputSchema,
|
|
17615
17913
|
annotations: {
|
|
17616
17914
|
readOnlyHint: true,
|
|
@@ -17653,9 +17951,17 @@ var listRoutersTool = {
|
|
|
17653
17951
|
"isDefault"
|
|
17654
17952
|
])}`
|
|
17655
17953
|
);
|
|
17954
|
+
const hasRouterOS = rows.some((r) => r.deviceType === "routeros");
|
|
17656
17955
|
return {
|
|
17657
17956
|
content: total === 0 ? header : [header, ...lines].join("\n"),
|
|
17658
|
-
structuredContent: {
|
|
17957
|
+
structuredContent: {
|
|
17958
|
+
routers: rows,
|
|
17959
|
+
total,
|
|
17960
|
+
returned: total,
|
|
17961
|
+
...hasRouterOS ? {
|
|
17962
|
+
note: "rosVersion comes from routers.yaml and may be stale; use get_system_status for the live version."
|
|
17963
|
+
} : {}
|
|
17964
|
+
}
|
|
17659
17965
|
};
|
|
17660
17966
|
}
|
|
17661
17967
|
};
|
|
@@ -17814,7 +18120,8 @@ async function fanOut(req) {
|
|
|
17814
18120
|
tool: input2.toolName,
|
|
17815
18121
|
routerId: router.id,
|
|
17816
18122
|
params: loggedParams,
|
|
17817
|
-
snapshotIds
|
|
18123
|
+
snapshotIds,
|
|
18124
|
+
dryRun: isEffectiveDryRun(targetTool, toolParams)
|
|
17818
18125
|
});
|
|
17819
18126
|
}
|
|
17820
18127
|
const cb = getOrCreateBreaker(context.circuitBreakers, router.id, context.appConfig);
|
|
@@ -18190,8 +18497,9 @@ var RouterConfigSchema = z52.object({
|
|
|
18190
18497
|
}).optional(),
|
|
18191
18498
|
cmdAllow: z52.array(z52.string()).optional(),
|
|
18192
18499
|
cmdDeny: z52.array(z52.string()).optional(),
|
|
18193
|
-
//
|
|
18194
|
-
//
|
|
18500
|
+
// The MIKROMCP_SECRET_<NAME> values this device may use: a switch config
|
|
18501
|
+
// applied to a SwOS switch, create_backup's passwordSecret on RouterOS.
|
|
18502
|
+
// Without the list it may use none.
|
|
18195
18503
|
secrets: z52.array(
|
|
18196
18504
|
z52.string().regex(/^[A-Z0-9_]+$/, "a secret name is the NAME of MIKROMCP_SECRET_<NAME>")
|
|
18197
18505
|
).optional(),
|
|
@@ -18219,13 +18527,6 @@ var RouterConfigSchema = z52.object({
|
|
|
18219
18527
|
message: "rosVersion is required for RouterOS devices"
|
|
18220
18528
|
});
|
|
18221
18529
|
}
|
|
18222
|
-
if (config.secrets) {
|
|
18223
|
-
ctx.addIssue({
|
|
18224
|
-
code: z52.ZodIssueCode.custom,
|
|
18225
|
-
path: ["secrets"],
|
|
18226
|
-
message: "secrets binds switch config secrets and applies to SwOS devices only"
|
|
18227
|
-
});
|
|
18228
|
-
}
|
|
18229
18530
|
return;
|
|
18230
18531
|
}
|
|
18231
18532
|
if (config.tls?.enabled) {
|
|
@@ -18398,7 +18699,7 @@ var ConnectionPool = class {
|
|
|
18398
18699
|
};
|
|
18399
18700
|
|
|
18400
18701
|
// src/version.ts
|
|
18401
|
-
var VERSION2 = "1.
|
|
18702
|
+
var VERSION2 = "1.16.0";
|
|
18402
18703
|
|
|
18403
18704
|
// src/mcp/server.ts
|
|
18404
18705
|
var SERVER_INSTRUCTIONS = [
|
|
@@ -18786,8 +19087,8 @@ ${issues}`);
|
|
|
18786
19087
|
};
|
|
18787
19088
|
|
|
18788
19089
|
// src/domain/snapshot/retention.ts
|
|
18789
|
-
import { readdir, stat, unlink } from "fs/promises";
|
|
18790
|
-
import { join as
|
|
19090
|
+
import { readdir, readFile as readFile3, rename, stat, unlink } from "fs/promises";
|
|
19091
|
+
import { join as join5 } from "path";
|
|
18791
19092
|
var log56 = createLogger("snapshot-retention");
|
|
18792
19093
|
async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
18793
19094
|
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
@@ -18799,7 +19100,7 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
18799
19100
|
return 0;
|
|
18800
19101
|
}
|
|
18801
19102
|
for (const routerDir of routerDirs) {
|
|
18802
|
-
const dirPath =
|
|
19103
|
+
const dirPath = join5(snapshotDir, routerDir);
|
|
18803
19104
|
let files;
|
|
18804
19105
|
try {
|
|
18805
19106
|
files = await readdir(dirPath);
|
|
@@ -18807,7 +19108,7 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
18807
19108
|
continue;
|
|
18808
19109
|
}
|
|
18809
19110
|
for (const file of files) {
|
|
18810
|
-
const filePath =
|
|
19111
|
+
const filePath = join5(dirPath, file);
|
|
18811
19112
|
try {
|
|
18812
19113
|
const info = await stat(filePath);
|
|
18813
19114
|
if (info.isFile() && info.mtimeMs < cutoffMs) {
|
|
@@ -18824,6 +19125,41 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
18824
19125
|
}
|
|
18825
19126
|
return removed;
|
|
18826
19127
|
}
|
|
19128
|
+
var JOURNAL_ROTATE_AFTER_MS = 864e5;
|
|
19129
|
+
async function rotateAndPruneJournal(journalPath, maxAgeDays, now = /* @__PURE__ */ new Date()) {
|
|
19130
|
+
const cutoffMs = now.getTime() - maxAgeDays * 864e5;
|
|
19131
|
+
let removed = 0;
|
|
19132
|
+
for (const segment of journalSegmentPaths(journalPath)) {
|
|
19133
|
+
try {
|
|
19134
|
+
if (journalSegmentRotatedAt(segment) < cutoffMs && (await stat(segment)).mtimeMs < cutoffMs) {
|
|
19135
|
+
await unlink(segment);
|
|
19136
|
+
removed++;
|
|
19137
|
+
}
|
|
19138
|
+
} catch (err) {
|
|
19139
|
+
if (err.code !== "ENOENT") {
|
|
19140
|
+
log56.warn({ err, segment }, "Failed to prune a write-journal segment");
|
|
19141
|
+
}
|
|
19142
|
+
}
|
|
19143
|
+
}
|
|
19144
|
+
if (removed > 0) {
|
|
19145
|
+
log56.info({ removed, maxAgeDays }, "Pruned expired write-journal segments");
|
|
19146
|
+
}
|
|
19147
|
+
let rotated = false;
|
|
19148
|
+
try {
|
|
19149
|
+
const oldest = parseJournal(await readFile3(journalPath, "utf-8")).find(
|
|
19150
|
+
(e) => typeof e.ts === "string" && Number.isFinite(Date.parse(e.ts))
|
|
19151
|
+
);
|
|
19152
|
+
if (oldest && Date.parse(oldest.ts) <= now.getTime() - JOURNAL_ROTATE_AFTER_MS) {
|
|
19153
|
+
await rename(journalPath, journalSegmentName(journalPath, now));
|
|
19154
|
+
rotated = true;
|
|
19155
|
+
}
|
|
19156
|
+
} catch (err) {
|
|
19157
|
+
if (err.code !== "ENOENT") {
|
|
19158
|
+
log56.warn({ err, journalPath }, "Failed to rotate the write journal");
|
|
19159
|
+
}
|
|
19160
|
+
}
|
|
19161
|
+
return { rotated, removed };
|
|
19162
|
+
}
|
|
18827
19163
|
|
|
18828
19164
|
// src/cli/serve.ts
|
|
18829
19165
|
var log57 = createLogger("main");
|
|
@@ -18831,6 +19167,7 @@ async function runServe() {
|
|
|
18831
19167
|
const config = loadAppConfig();
|
|
18832
19168
|
log57.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
|
|
18833
19169
|
void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
|
|
19170
|
+
void rotateAndPruneJournal(config.journalPath, config.retention.snapshotMaxAgeDays);
|
|
18834
19171
|
const identityRegistry = new IdentityRegistry(config.identitiesPath);
|
|
18835
19172
|
if (config.transport === "http") {
|
|
18836
19173
|
const hasLimitedRoles = identityRegistry.getIdentities().some((i) => i.role === "readonly" || i.role === "operator");
|
|
@@ -18876,7 +19213,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
|
|
|
18876
19213
|
import { homedir as homedir2 } from "os";
|
|
18877
19214
|
import * as net from "net";
|
|
18878
19215
|
import chalk from "chalk";
|
|
18879
|
-
var localVersion = "1.
|
|
19216
|
+
var localVersion = "1.16.0";
|
|
18880
19217
|
var results = [];
|
|
18881
19218
|
function ok(msg) {
|
|
18882
19219
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -18967,7 +19304,16 @@ function checkEnvVars(routers, transport2) {
|
|
|
18967
19304
|
}
|
|
18968
19305
|
const secretNames = Object.keys(process.env).filter((k) => k.startsWith("MIKROMCP_SECRET_"));
|
|
18969
19306
|
for (const router of routers) {
|
|
18970
|
-
if (router.deviceType !== "swos")
|
|
19307
|
+
if (router.deviceType !== "swos") {
|
|
19308
|
+
for (const name of router.secrets ?? []) {
|
|
19309
|
+
if (!process.env[`MIKROMCP_SECRET_${name}`]) {
|
|
19310
|
+
warn(
|
|
19311
|
+
`${router.id} binds secret ${name}, but MIKROMCP_SECRET_${name} is not set or empty \u2014 create_backup with passwordSecret ${name} will be refused`
|
|
19312
|
+
);
|
|
19313
|
+
}
|
|
19314
|
+
}
|
|
19315
|
+
continue;
|
|
19316
|
+
}
|
|
18971
19317
|
if (router.secrets) {
|
|
18972
19318
|
for (const name of router.secrets) {
|
|
18973
19319
|
if (!process.env[`MIKROMCP_SECRET_${name}`]) {
|
|
@@ -19273,18 +19619,18 @@ async function runDoctor() {
|
|
|
19273
19619
|
|
|
19274
19620
|
// src/cli/init.ts
|
|
19275
19621
|
import { input, confirm, select } from "@inquirer/prompts";
|
|
19276
|
-
import { randomBytes as
|
|
19622
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
19277
19623
|
import { existsSync as existsSync4, readFileSync as readFileSync8, writeFileSync, mkdirSync, copyFileSync } from "fs";
|
|
19278
19624
|
import { homedir as homedir3 } from "os";
|
|
19279
|
-
import { join as
|
|
19625
|
+
import { join as join6 } from "path";
|
|
19280
19626
|
import { parse as yamlParse, Document, visit } from "yaml";
|
|
19281
19627
|
import bcrypt2 from "bcryptjs";
|
|
19282
19628
|
import chalk2 from "chalk";
|
|
19283
19629
|
function mikromcpDir2() {
|
|
19284
|
-
return
|
|
19630
|
+
return join6(homedir3(), ".mikromcp");
|
|
19285
19631
|
}
|
|
19286
19632
|
function defaultAuditLogPath() {
|
|
19287
|
-
return
|
|
19633
|
+
return join6(mikromcpDir2(), "audit.ndjson");
|
|
19288
19634
|
}
|
|
19289
19635
|
function toYaml2(obj) {
|
|
19290
19636
|
const doc = new Document(obj);
|
|
@@ -19312,8 +19658,8 @@ function splitTags(val) {
|
|
|
19312
19658
|
}
|
|
19313
19659
|
function findClaudeDesktopConfig() {
|
|
19314
19660
|
const candidates = [
|
|
19315
|
-
|
|
19316
|
-
|
|
19661
|
+
join6(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
19662
|
+
join6(homedir3(), ".config", "Claude", "claude_desktop_config.json")
|
|
19317
19663
|
];
|
|
19318
19664
|
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
19319
19665
|
}
|
|
@@ -19433,7 +19779,7 @@ async function collectIdentityInfo() {
|
|
|
19433
19779
|
message: "Allowed tool patterns (comma-separated or * for all):",
|
|
19434
19780
|
default: "*"
|
|
19435
19781
|
});
|
|
19436
|
-
const rawToken =
|
|
19782
|
+
const rawToken = randomBytes4(32).toString("hex");
|
|
19437
19783
|
console.log(chalk2.green(`
|
|
19438
19784
|
Generated token: ${chalk2.bold(rawToken)}`));
|
|
19439
19785
|
console.log(chalk2.yellow(" \u26A0 Save this token \u2014 it will NOT be shown again.\n"));
|
|
@@ -19481,7 +19827,7 @@ async function collectClaudeDesktopPreference() {
|
|
|
19481
19827
|
return confirm({ message: "Register with Claude Desktop?", default: true });
|
|
19482
19828
|
}
|
|
19483
19829
|
function writeRoutersYaml(data) {
|
|
19484
|
-
const filePath =
|
|
19830
|
+
const filePath = join6(data.configDir, "routers.yaml");
|
|
19485
19831
|
const newEntry = {
|
|
19486
19832
|
host: data.host,
|
|
19487
19833
|
port: data.port,
|
|
@@ -19508,7 +19854,7 @@ function writeRoutersYaml(data) {
|
|
|
19508
19854
|
return true;
|
|
19509
19855
|
}
|
|
19510
19856
|
function writeIdentitiesYaml(data) {
|
|
19511
|
-
const filePath =
|
|
19857
|
+
const filePath = join6(data.configDir, "identities.yaml");
|
|
19512
19858
|
const newEntry = {
|
|
19513
19859
|
token: data.tokenHash,
|
|
19514
19860
|
role: data.role,
|
|
@@ -19530,7 +19876,7 @@ function writeDotEnv(data) {
|
|
|
19530
19876
|
const userKey = `${data.envPrefix}_USER`;
|
|
19531
19877
|
const passKey = `${data.envPrefix}_PASS`;
|
|
19532
19878
|
const auditLogPath = defaultAuditLogPath();
|
|
19533
|
-
const confirmationSecret = data.transport === "http" ?
|
|
19879
|
+
const confirmationSecret = data.transport === "http" ? randomBytes4(32).toString("hex") : "";
|
|
19534
19880
|
const lines = [
|
|
19535
19881
|
"# \u2500\u2500 Router credentials \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
19536
19882
|
`${userKey}=${data.routerUser}`,
|
|
@@ -19542,8 +19888,8 @@ function writeDotEnv(data) {
|
|
|
19542
19888
|
`MIKROMCP_TRANSPORT=${data.transport}`,
|
|
19543
19889
|
"",
|
|
19544
19890
|
"# \u2500\u2500 Config paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
19545
|
-
`MIKROMCP_CONFIG_PATH=${
|
|
19546
|
-
`MIKROMCP_IDENTITIES_PATH=${
|
|
19891
|
+
`MIKROMCP_CONFIG_PATH=${join6(data.configDir, "routers.yaml")}`,
|
|
19892
|
+
`MIKROMCP_IDENTITIES_PATH=${join6(data.configDir, "identities.yaml")}`,
|
|
19547
19893
|
"",
|
|
19548
19894
|
"# \u2500\u2500 Default router \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
19549
19895
|
"# Router used when a tool call omits routerId (handy for single-router setups).",
|
|
@@ -19597,9 +19943,9 @@ function registerClaudeDesktop() {
|
|
|
19597
19943
|
async function runInit() {
|
|
19598
19944
|
console.log(chalk2.bold.cyan("\n\u{1F527} MikroMCP \u2014 Interactive Setup Wizard\n"));
|
|
19599
19945
|
const configDir = mikromcpDir2();
|
|
19600
|
-
const envPath =
|
|
19946
|
+
const envPath = join6(configDir, ".env");
|
|
19601
19947
|
const existingFiles = [".env", "routers.yaml", "identities.yaml"].filter(
|
|
19602
|
-
(f2) => existsSync4(
|
|
19948
|
+
(f2) => existsSync4(join6(configDir, f2))
|
|
19603
19949
|
);
|
|
19604
19950
|
if (existingFiles.length > 0) {
|
|
19605
19951
|
console.log(chalk2.yellow(` \u26A0 Found existing files in ${configDir}:`));
|
|
@@ -19637,12 +19983,12 @@ async function runInit() {
|
|
|
19637
19983
|
if (data.writeRoutersYaml) {
|
|
19638
19984
|
writeRoutersYaml(data);
|
|
19639
19985
|
summary.push(`routers.yaml written to ${data.configDir}`);
|
|
19640
|
-
console.log(chalk2.green(` \u2714 ${
|
|
19986
|
+
console.log(chalk2.green(` \u2714 ${join6(data.configDir, "routers.yaml")}`));
|
|
19641
19987
|
}
|
|
19642
19988
|
if (data.writeIdentitiesYaml) {
|
|
19643
19989
|
writeIdentitiesYaml(data);
|
|
19644
19990
|
summary.push(`identities.yaml written to ${data.configDir}`);
|
|
19645
|
-
console.log(chalk2.green(` \u2714 ${
|
|
19991
|
+
console.log(chalk2.green(` \u2714 ${join6(data.configDir, "identities.yaml")}`));
|
|
19646
19992
|
}
|
|
19647
19993
|
if (data.writeEnv) {
|
|
19648
19994
|
writeDotEnv(data);
|
|
@@ -19692,7 +20038,7 @@ async function runInit() {
|
|
|
19692
20038
|
}
|
|
19693
20039
|
|
|
19694
20040
|
// src/cli/index.ts
|
|
19695
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.
|
|
20041
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.16.0");
|
|
19696
20042
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
19697
20043
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
19698
20044
|
program.command("init").description("Interactive setup wizard").action(runInit);
|
|
@@ -19702,6 +20048,6 @@ program.command("update").description("Update mikromcp to the latest version via
|
|
|
19702
20048
|
});
|
|
19703
20049
|
|
|
19704
20050
|
// src/main.ts
|
|
19705
|
-
loadDotenv({ path:
|
|
20051
|
+
loadDotenv({ path: join7(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
|
|
19706
20052
|
program.parse();
|
|
19707
20053
|
//# sourceMappingURL=main.js.map
|