mikromcp 1.14.1 → 1.15.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.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/dist/main.js +263 -62
  3. package/package.json +1 -1
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.15.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 |
package/dist/main.js CHANGED
@@ -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.`,
@@ -8117,6 +8151,7 @@ function recordAttempt(args) {
8117
8151
  routerId: args.routerId,
8118
8152
  params: redactParams(args.params),
8119
8153
  snapshotIds: args.snapshotIds,
8154
+ ...args.dryRun ? { dryRun: true } : {},
8120
8155
  phase: "attempt"
8121
8156
  });
8122
8157
  return id;
@@ -9011,6 +9046,47 @@ function unionVlanBlob(currentWire, targetWire) {
9011
9046
  return dumpBlob([...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row));
9012
9047
  }
9013
9048
 
9049
+ // src/middleware/authz.ts
9050
+ function matchesPattern(name, pattern) {
9051
+ return globMatch(pattern, name);
9052
+ }
9053
+ function checkAuthz(identity, toolName, routerId2) {
9054
+ if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
9055
+ throw new MikroMCPError({
9056
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
9057
+ code: "ROUTER_NOT_ALLOWED",
9058
+ message: `Identity "${identity.id}" is not allowed to access router "${routerId2}".`,
9059
+ details: { identityId: identity.id, routerId: routerId2, allowedRouters: identity.allowedRouters },
9060
+ recoverability: {
9061
+ retryable: false,
9062
+ suggestedAction: `Add "${routerId2}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
9063
+ }
9064
+ });
9065
+ }
9066
+ checkToolAllowed(identity, toolName);
9067
+ }
9068
+ function checkToolAllowed(identity, toolName) {
9069
+ if (identity.allowedToolPatterns.length > 0) {
9070
+ const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
9071
+ if (!allowed) {
9072
+ throw new MikroMCPError({
9073
+ category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
9074
+ code: "TOOL_NOT_ALLOWED",
9075
+ message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
9076
+ details: {
9077
+ identityId: identity.id,
9078
+ toolName,
9079
+ allowedToolPatterns: identity.allowedToolPatterns
9080
+ },
9081
+ recoverability: {
9082
+ retryable: false,
9083
+ suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
9084
+ }
9085
+ });
9086
+ }
9087
+ }
9088
+ }
9089
+
9014
9090
  // src/domain/tools/change-management-tools.ts
9015
9091
  var log24 = createLogger("change-management");
9016
9092
  var stepSchema = z25.object({
@@ -9026,6 +9102,116 @@ var applyPlanInputSchema = z25.object({
9026
9102
  steps: z25.array(stepSchema).min(1).max(10).describe("Ordered list of write operations to apply in sequence"),
9027
9103
  confirmationToken: z25.string().optional()
9028
9104
  }).strict();
9105
+ function journalEntryWasDryRun(entry) {
9106
+ const params = entry.params ?? {};
9107
+ return entry.dryRun === true || params.dryRun === true;
9108
+ }
9109
+ var listJournalInputSchema = z25.object({
9110
+ routerId: z25.string().optional().describe("Only entries for this router id"),
9111
+ tool: z25.string().optional().describe("Only entries written by this tool"),
9112
+ limit: z25.number().int().min(1).max(500).default(20).describe("Max entries to return")
9113
+ }).strict();
9114
+ var listJournalTool = {
9115
+ name: "list_journal",
9116
+ title: "List Write Journal",
9117
+ 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.",
9118
+ inputSchema: listJournalInputSchema,
9119
+ annotations: {
9120
+ readOnlyHint: true,
9121
+ destructiveHint: false,
9122
+ idempotentHint: true,
9123
+ openWorldHint: false
9124
+ },
9125
+ skipRouterContext: true,
9126
+ async handler(params, context) {
9127
+ const parsed = listJournalInputSchema.parse(params);
9128
+ checkToolAllowed(context.identity, "list_journal");
9129
+ const journalPath = context.appConfig.journalPath;
9130
+ if (!journalPath) {
9131
+ return {
9132
+ content: "Write journal is disabled (no journal path configured on this server).",
9133
+ structuredContent: { journalEnabled: false, entries: [], total: 0, returned: 0 }
9134
+ };
9135
+ }
9136
+ let raw;
9137
+ try {
9138
+ raw = readFileSync2(journalPath, "utf-8");
9139
+ } catch (err) {
9140
+ if (err.code === "ENOENT") {
9141
+ raw = "";
9142
+ } else {
9143
+ throw new MikroMCPError({
9144
+ category: "INTERNAL" /* INTERNAL */,
9145
+ code: "JOURNAL_READ_FAILED",
9146
+ message: `Could not read the write journal: ${err.message}`,
9147
+ details: { journalPath },
9148
+ recoverability: {
9149
+ retryable: false,
9150
+ suggestedAction: "Check the file's permissions under the MikroMCP data directory."
9151
+ }
9152
+ });
9153
+ }
9154
+ }
9155
+ const outcomes = /* @__PURE__ */ new Map();
9156
+ const attempts = [];
9157
+ for (const line of raw.split("\n")) {
9158
+ if (!line.trim()) continue;
9159
+ let entry;
9160
+ try {
9161
+ entry = JSON.parse(line);
9162
+ } catch {
9163
+ continue;
9164
+ }
9165
+ if (entry.phase === "attempt") attempts.push(entry);
9166
+ else
9167
+ outcomes.set(entry.id, {
9168
+ phase: entry.phase,
9169
+ outcome: entry.outcome,
9170
+ durationMs: entry.durationMs
9171
+ });
9172
+ }
9173
+ let entries = attempts.map((a) => {
9174
+ const outcome = outcomes.get(a.id);
9175
+ const snapshotIds = Array.isArray(a.snapshotIds) ? a.snapshotIds : [];
9176
+ const dryRun2 = journalEntryWasDryRun(a);
9177
+ return {
9178
+ journalId: a.id,
9179
+ ts: a.ts,
9180
+ tool: a.tool,
9181
+ routerId: a.routerId,
9182
+ identityId: a.identityId,
9183
+ role: a.role,
9184
+ params: a.params ?? {},
9185
+ snapshotIds,
9186
+ dryRun: dryRun2,
9187
+ rollbackable: !dryRun2 && snapshotIds.length > 0,
9188
+ status: outcome ? outcome.phase : "unknown",
9189
+ outcomeCode: outcome?.outcome,
9190
+ durationMs: outcome?.durationMs
9191
+ };
9192
+ }).reverse();
9193
+ if (parsed.routerId) entries = entries.filter((e) => e.routerId === parsed.routerId);
9194
+ if (parsed.tool) entries = entries.filter((e) => e.tool === parsed.tool);
9195
+ const scope = context.identity.allowedRouters;
9196
+ if (scope.length > 0) entries = entries.filter((e) => scope.includes(e.routerId));
9197
+ const total = entries.length;
9198
+ const returned = entries.slice(0, parsed.limit);
9199
+ const header = total === 0 ? "Write journal: no entries." : `Write journal: ${returned.length} of ${total}, newest first.`;
9200
+ const lines = returned.map(
9201
+ (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)}]`
9202
+ );
9203
+ return {
9204
+ content: total === 0 ? header : [header, ...lines].join("\n"),
9205
+ structuredContent: {
9206
+ journalEnabled: true,
9207
+ entries: returned,
9208
+ total,
9209
+ returned: returned.length,
9210
+ 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."
9211
+ }
9212
+ };
9213
+ }
9214
+ };
9029
9215
  var rollbackChangeInputSchema = z25.object({
9030
9216
  routerId,
9031
9217
  journalId: z25.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
@@ -9246,7 +9432,8 @@ function createChangeManagementTools(baseTools2) {
9246
9432
  tool: step.tool,
9247
9433
  routerId: context.routerId,
9248
9434
  params: loggableParams(tool, stepParams),
9249
- snapshotIds
9435
+ snapshotIds,
9436
+ dryRun: isEffectiveDryRun(tool, stepParams)
9250
9437
  });
9251
9438
  const stepStartMs = Date.now();
9252
9439
  try {
@@ -9313,7 +9500,10 @@ function createChangeManagementTools(baseTools2) {
9313
9500
  }
9314
9501
  });
9315
9502
  }
9316
- return { snapshotIds: entry.snapshotIds ?? [] };
9503
+ return {
9504
+ snapshotIds: entry.snapshotIds ?? [],
9505
+ dryRun: journalEntryWasDryRun(entry)
9506
+ };
9317
9507
  }
9318
9508
  const rollbackChangeTool = {
9319
9509
  name: "rollback_change",
@@ -9332,7 +9522,16 @@ function createChangeManagementTools(baseTools2) {
9332
9522
  const journalPath = context.appConfig.journalPath;
9333
9523
  const snapshotDir = context.appConfig.snapshotDir;
9334
9524
  log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
9335
- const { snapshotIds } = findJournalEntry(journalPath, parsed.journalId);
9525
+ const { snapshotIds, dryRun: entryWasDryRun } = findJournalEntry(
9526
+ journalPath,
9527
+ parsed.journalId
9528
+ );
9529
+ if (entryWasDryRun) {
9530
+ return {
9531
+ content: `Journal entry "${parsed.journalId}" was a dry run \u2014 nothing was changed, so there is nothing to roll back.`,
9532
+ structuredContent: { action: "dry_run_entry", journalId: parsed.journalId }
9533
+ };
9534
+ }
9336
9535
  if (snapshotIds.length === 0) {
9337
9536
  return {
9338
9537
  content: `Journal entry "${parsed.journalId}" has no associated snapshots \u2014 cannot rollback automatically.`,
@@ -9387,7 +9586,7 @@ ${warnings.map((w) => ` - ${w}`).join("\n")}` : "";
9387
9586
  };
9388
9587
  }
9389
9588
  };
9390
- return [planChangesTool, applyPlanTool, rollbackChangeTool];
9589
+ return [planChangesTool, applyPlanTool, rollbackChangeTool, listJournalTool];
9391
9590
  }
9392
9591
 
9393
9592
  // src/domain/tools/ipsec-tools.ts
@@ -11860,8 +12059,8 @@ function sanitizeComment3(comment) {
11860
12059
  var manageVlanInputSchema = z35.object({
11861
12060
  routerId,
11862
12061
  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)"
12062
+ name: z35.string().min(1).describe(
12063
+ "VLAN interface name \u2014 idempotency key, taken verbatim for remove/enable/disable; add accepts letters, digits, dot, hyphen, underscore, up to 64 chars"
11865
12064
  ),
11866
12065
  vlanId: z35.number().int().min(1).max(4094).optional().describe("VLAN ID (1-4094; required for add)"),
11867
12066
  parentInterface: z35.string().optional().describe("Parent interface name (e.g., ether1, bridge1; required for add)"),
@@ -11898,6 +12097,11 @@ var manageVlanTool = {
11898
12097
  (v) => sameValue(v.name, parsed.name)
11899
12098
  );
11900
12099
  if (parsed.action === "add") {
12100
+ assertNewInterfaceName(parsed.name, {
12101
+ kind: "VLAN",
12102
+ action: "add",
12103
+ code: "VLAN_NAME_INVALID"
12104
+ });
11901
12105
  if (parsed.vlanId === void 0) {
11902
12106
  throw new MikroMCPError({
11903
12107
  category: "VALIDATION" /* VALIDATION */,
@@ -12042,9 +12246,12 @@ var vlanTools = [manageVlanTool];
12042
12246
  // src/domain/tools/dhcp-client-tools.ts
12043
12247
  import { z as z36 } from "zod";
12044
12248
  var log35 = createLogger("dhcp-client-tools");
12249
+ function findClient(clients, target, byNameToo = true) {
12250
+ return clients.find((c) => sameValue(c.interface, target)) ?? (byNameToo ? clients.find((c) => sameValue(c.name, target)) : void 0);
12251
+ }
12045
12252
  var listDhcpClientsInputSchema = z36.object({
12046
12253
  routerId,
12047
- interface: z36.string().optional().describe("Filter by interface name (exact match)"),
12254
+ interface: z36.string().optional().describe("Filter by interface or display name (exact match)"),
12048
12255
  status: z36.enum(["bound", "searching", "requesting", "init", "all"]).default("all").describe("Filter by DHCP client status"),
12049
12256
  limit,
12050
12257
  offset
@@ -12070,7 +12277,9 @@ var listDhcpClientsTool = {
12070
12277
  });
12071
12278
  let filtered = allClients;
12072
12279
  if (parsed.interface) {
12073
- filtered = filtered.filter((c) => c.interface === parsed.interface);
12280
+ filtered = filtered.filter(
12281
+ (c) => sameValue(c.interface, parsed.interface) || sameValue(c.name, parsed.interface)
12282
+ );
12074
12283
  }
12075
12284
  if (parsed.status !== "all") {
12076
12285
  filtered = filtered.filter((c) => c.status === parsed.status);
@@ -12083,7 +12292,7 @@ var listDhcpClientsTool = {
12083
12292
  clients,
12084
12293
  total,
12085
12294
  parsed.offset,
12086
- (c) => compactFields(c, ["interface", "status", "address", "gateway", "disabled"])
12295
+ (c) => compactFields(c, ["interface", "name", "status", "address", "gateway", "disabled"])
12087
12296
  ),
12088
12297
  structuredContent: {
12089
12298
  routerId: context.routerId,
@@ -12102,7 +12311,9 @@ var listDhcpClientsTool = {
12102
12311
  var manageDhcpClientInputSchema = z36.object({
12103
12312
  routerId,
12104
12313
  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)"),
12314
+ interface: z36.string().describe(
12315
+ "Interface name \u2014 idempotency key (e.g. ether1); for remove/enable/disable a client's display name also matches"
12316
+ ),
12106
12317
  usePeerDns: z36.boolean().default(true).describe("Use DNS servers provided by DHCP server (add only)"),
12107
12318
  usePeerNtp: z36.boolean().default(false).describe("Use NTP servers provided by DHCP server (add only)"),
12108
12319
  addDefaultRoute: z36.boolean().default(true).describe("Add default route from DHCP (add only)"),
@@ -12132,8 +12343,10 @@ var manageDhcpClientTool = {
12132
12343
  limit: void 0,
12133
12344
  offset: void 0
12134
12345
  });
12135
- const existing = allClients.find(
12136
- (c) => c.interface === parsed.interface
12346
+ const existing = findClient(
12347
+ allClients,
12348
+ parsed.interface,
12349
+ parsed.action !== "add"
12137
12350
  );
12138
12351
  if (parsed.action === "add") {
12139
12352
  if (existing) {
@@ -12174,9 +12387,19 @@ var manageDhcpClientTool = {
12174
12387
  }
12175
12388
  if (parsed.action === "remove") {
12176
12389
  if (!existing) {
12390
+ const candidates = allClients.map((c) => ({
12391
+ interface: c.interface,
12392
+ name: c.name,
12393
+ status: c.status
12394
+ }));
12395
+ const names = candidates.map((c) => c.name ?? c.interface).slice(0, 5).join(", ");
12177
12396
  return {
12178
- content: `No DHCP client found on "${parsed.interface}". Nothing to remove.`,
12179
- structuredContent: { action: "not_found", interface: parsed.interface }
12397
+ content: `No DHCP client found on "${parsed.interface}". Nothing to remove.` + (names ? ` Existing clients: ${names}.` : ""),
12398
+ structuredContent: {
12399
+ action: "not_found",
12400
+ interface: parsed.interface,
12401
+ clients: candidates
12402
+ }
12180
12403
  };
12181
12404
  }
12182
12405
  if (parsed.dryRun) {
@@ -12184,7 +12407,7 @@ var manageDhcpClientTool = {
12184
12407
  content: `Dry run: Would remove DHCP client on "${parsed.interface}".`,
12185
12408
  structuredContent: {
12186
12409
  action: "dry_run",
12187
- diff: [{ property: "interface", before: parsed.interface, after: null }]
12410
+ diff: [{ property: "interface", before: existing.interface, after: null }]
12188
12411
  }
12189
12412
  };
12190
12413
  }
@@ -16479,44 +16702,6 @@ var swosMaintenanceTools = [
16479
16702
  // src/domain/tools/fleet-tools.ts
16480
16703
  import { z as z50 } from "zod";
16481
16704
 
16482
- // src/middleware/authz.ts
16483
- function matchesPattern(name, pattern) {
16484
- return globMatch(pattern, name);
16485
- }
16486
- function checkAuthz(identity, toolName, routerId2) {
16487
- if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
16488
- throw new MikroMCPError({
16489
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
16490
- code: "ROUTER_NOT_ALLOWED",
16491
- message: `Identity "${identity.id}" is not allowed to access router "${routerId2}".`,
16492
- details: { identityId: identity.id, routerId: routerId2, allowedRouters: identity.allowedRouters },
16493
- recoverability: {
16494
- retryable: false,
16495
- suggestedAction: `Add "${routerId2}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
16496
- }
16497
- });
16498
- }
16499
- if (identity.allowedToolPatterns.length > 0) {
16500
- const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
16501
- if (!allowed) {
16502
- throw new MikroMCPError({
16503
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
16504
- code: "TOOL_NOT_ALLOWED",
16505
- message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
16506
- details: {
16507
- identityId: identity.id,
16508
- toolName,
16509
- allowedToolPatterns: identity.allowedToolPatterns
16510
- },
16511
- recoverability: {
16512
- retryable: false,
16513
- suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
16514
- }
16515
- });
16516
- }
16517
- }
16518
- }
16519
-
16520
16705
  // src/config/secrets.ts
16521
16706
  function getCredentials(router) {
16522
16707
  const { source, envPrefix, vaultPath } = router.credentials;
@@ -17410,6 +17595,7 @@ async function executeToolCall(tool, args, deps) {
17410
17595
  }
17411
17596
  }
17412
17597
  toolContext.snapshots = snapshots;
17598
+ const dryRun2 = !tool.annotations.readOnlyHint && isEffectiveDryRun(tool, handlerArgs);
17413
17599
  if (!tool.annotations.readOnlyHint && config.journalPath) {
17414
17600
  const { confirmationToken: _token, ...journalArgs } = loggedArgs;
17415
17601
  journalId = recordAttempt({
@@ -17419,7 +17605,8 @@ async function executeToolCall(tool, args, deps) {
17419
17605
  tool: tool.name,
17420
17606
  routerId: routerId2,
17421
17607
  params: journalArgs,
17422
- snapshotIds
17608
+ snapshotIds,
17609
+ dryRun: dryRun2
17423
17610
  });
17424
17611
  }
17425
17612
  const runHandler = () => tool.handler(handlerArgs, toolContext);
@@ -17433,6 +17620,11 @@ async function executeToolCall(tool, args, deps) {
17433
17620
  phase: "success",
17434
17621
  durationMs: Date.now() - startMs
17435
17622
  });
17623
+ if (!dryRun2 && snapshotIds.length > 0) {
17624
+ result.structuredContent = { ...result.structuredContent, journalId };
17625
+ result.content += `
17626
+ Journal ID: ${journalId} (pass to rollback_change to undo).`;
17627
+ }
17436
17628
  }
17437
17629
  if (shouldAudit) {
17438
17630
  auditLog(
@@ -17610,7 +17802,7 @@ var listRoutersInputSchema = z50.object({
17610
17802
  var listRoutersTool = {
17611
17803
  name: "list_routers",
17612
17804
  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).",
17805
+ 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
17806
  inputSchema: listRoutersInputSchema,
17615
17807
  annotations: {
17616
17808
  readOnlyHint: true,
@@ -17653,9 +17845,17 @@ var listRoutersTool = {
17653
17845
  "isDefault"
17654
17846
  ])}`
17655
17847
  );
17848
+ const hasRouterOS = rows.some((r) => r.deviceType === "routeros");
17656
17849
  return {
17657
17850
  content: total === 0 ? header : [header, ...lines].join("\n"),
17658
- structuredContent: { routers: rows, total, returned: total }
17851
+ structuredContent: {
17852
+ routers: rows,
17853
+ total,
17854
+ returned: total,
17855
+ ...hasRouterOS ? {
17856
+ note: "rosVersion comes from routers.yaml and may be stale; use get_system_status for the live version."
17857
+ } : {}
17858
+ }
17659
17859
  };
17660
17860
  }
17661
17861
  };
@@ -17814,7 +18014,8 @@ async function fanOut(req) {
17814
18014
  tool: input2.toolName,
17815
18015
  routerId: router.id,
17816
18016
  params: loggedParams,
17817
- snapshotIds
18017
+ snapshotIds,
18018
+ dryRun: isEffectiveDryRun(targetTool, toolParams)
17818
18019
  });
17819
18020
  }
17820
18021
  const cb = getOrCreateBreaker(context.circuitBreakers, router.id, context.appConfig);
@@ -18398,7 +18599,7 @@ var ConnectionPool = class {
18398
18599
  };
18399
18600
 
18400
18601
  // src/version.ts
18401
- var VERSION2 = "1.14.1";
18602
+ var VERSION2 = "1.15.0";
18402
18603
 
18403
18604
  // src/mcp/server.ts
18404
18605
  var SERVER_INSTRUCTIONS = [
@@ -18876,7 +19077,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
18876
19077
  import { homedir as homedir2 } from "os";
18877
19078
  import * as net from "net";
18878
19079
  import chalk from "chalk";
18879
- var localVersion = "1.14.1";
19080
+ var localVersion = "1.15.0";
18880
19081
  var results = [];
18881
19082
  function ok(msg) {
18882
19083
  console.log(chalk.green("\u2705 ") + msg);
@@ -19692,7 +19893,7 @@ async function runInit() {
19692
19893
  }
19693
19894
 
19694
19895
  // src/cli/index.ts
19695
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.14.1");
19896
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.15.0");
19696
19897
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
19697
19898
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
19698
19899
  program.command("init").description("Interactive setup wizard").action(runInit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikromcp",
3
- "version": "1.14.1",
3
+ "version": "1.15.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",