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 CHANGED
@@ -12,12 +12,12 @@
12
12
 
13
13
  [![CI](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
14
14
  [![Release](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
15
- [![Version](https://img.shields.io/badge/version-v1.14.1-2DD4BF)](package.json)
15
+ [![Version](https://img.shields.io/badge/version-v1.16.0-2DD4BF)](package.json)
16
16
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
17
17
  [![Node.js >= 22](https://img.shields.io/badge/node-%3E%3D22-339933)](package.json)
18
18
  [![RouterOS 7.x](https://img.shields.io/badge/RouterOS-7.x-293239)](https://help.mikrotik.com/docs/display/ROS/REST+API)
19
19
  [![MCP Server](https://img.shields.io/badge/MCP-server-6f42c1)](https://modelcontextprotocol.io)
20
- [![Tools](https://img.shields.io/badge/MCP%20tools-132-0f766e)](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools)
20
+ [![Tools](https://img.shields.io/badge/MCP%20tools-133-0f766e)](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools)
21
21
  [![MikroMCP MCP server](https://glama.ai/mcp/servers/AliKarami/MikroMCP/badges/score.svg)](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
- **132 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)**.
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 132 typed, auditable tools — letting AI assistants inspect, diagnose, and safely operate routers in natural language instead of improvising CLI commands.
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 132 tools — parameters and example prompts |
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 join6 } from "path";
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().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe("Bridge interface name"),
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 raw = readFileSync2(journalPath, "utf-8");
9303
- const lines = raw.split("\n").filter(Boolean);
9304
- const entry = lines.map((l) => JSON.parse(l)).find((e) => e.id === journalId && e.phase === "attempt");
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 { snapshotIds: entry.snapshotIds ?? [] };
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(journalPath, parsed.journalId);
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().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe(
11864
- "VLAN interface name \u2014 idempotency key (alphanumeric, hyphens, underscores, max 15 chars)"
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((c) => c.interface === parsed.interface);
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("Interface name \u2014 idempotency key (e.g. ether1, ether2)"),
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 = allClients.find(
12136
- (c) => c.interface === parsed.interface
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: { action: "not_found", interface: parsed.interface }
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: parsed.interface, after: null }]
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("Password to encrypt the backup file"),
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. Supports optional encryption via password and dry-run mode.",
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: { action: "dry_run", name: parsed.name, routerId: context.routerId }
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 (parsed.password !== void 0) {
13570
- body.password = parsed.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 join3, resolve, sep } from "path";
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 join3(context.appConfig.dataDir, "swos");
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 randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
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 = randomBytes2(32).toString("hex");
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: { routers: rows, total, returned: total }
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
- // SwOS only: the MIKROMCP_SECRET_<NAME> values a switch config applied to
18194
- // this switch may reference. Without the list it may reference none.
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.14.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 join4 } from "path";
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 = join4(snapshotDir, routerDir);
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 = join4(dirPath, file);
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.14.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") continue;
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 randomBytes3 } from "crypto";
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 join5 } from "path";
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 join5(homedir3(), ".mikromcp");
19630
+ return join6(homedir3(), ".mikromcp");
19285
19631
  }
19286
19632
  function defaultAuditLogPath() {
19287
- return join5(mikromcpDir2(), "audit.ndjson");
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
- join5(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
19316
- join5(homedir3(), ".config", "Claude", "claude_desktop_config.json")
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 = randomBytes3(32).toString("hex");
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 = join5(data.configDir, "routers.yaml");
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 = join5(data.configDir, "identities.yaml");
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" ? randomBytes3(32).toString("hex") : "";
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=${join5(data.configDir, "routers.yaml")}`,
19546
- `MIKROMCP_IDENTITIES_PATH=${join5(data.configDir, "identities.yaml")}`,
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 = join5(configDir, ".env");
19946
+ const envPath = join6(configDir, ".env");
19601
19947
  const existingFiles = [".env", "routers.yaml", "identities.yaml"].filter(
19602
- (f2) => existsSync4(join5(configDir, f2))
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 ${join5(data.configDir, "routers.yaml")}`));
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 ${join5(data.configDir, "identities.yaml")}`));
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.14.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: join6(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
20051
+ loadDotenv({ path: join7(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
19706
20052
  program.parse();
19707
20053
  //# sourceMappingURL=main.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikromcp",
3
- "version": "1.14.1",
3
+ "version": "1.16.0",
4
4
  "mcpName": "io.github.AliKarami/mikromcp",
5
5
  "description": "Production-grade MCP server for MikroTik RouterOS routers and SwOS switches",
6
6
  "type": "module",