mikromcp 1.14.0 → 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 +361 -70
  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.0-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.`,
@@ -3270,6 +3304,7 @@ var wifiTools = [listWifiTool, listWifiClientsTool, manageWifiTool];
3270
3304
 
3271
3305
  // src/domain/tools/wireguard-tools.ts
3272
3306
  import { z as z13 } from "zod";
3307
+ import { isIP } from "net";
3273
3308
  var log11 = createLogger("wireguard-tools");
3274
3309
  var listWgInputSchema = z13.object({
3275
3310
  routerId,
@@ -3363,6 +3398,7 @@ var listPeersTool = {
3363
3398
  "interface",
3364
3399
  "public-key",
3365
3400
  "endpoint-address",
3401
+ "endpoint-port",
3366
3402
  "allowed-address",
3367
3403
  "disabled"
3368
3404
  ])
@@ -3381,20 +3417,71 @@ var listPeersTool = {
3381
3417
  }
3382
3418
  }
3383
3419
  };
3420
+ function parsePeerEndpoint(endpoint) {
3421
+ if (/\s/.test(endpoint) || endpoint.includes("%")) return void 0;
3422
+ if (isIP(endpoint) === 6) return { "endpoint-address": endpoint };
3423
+ const ipv6 = /^\[([^\]]+)\](?::(\d+))?$/.exec(endpoint);
3424
+ const host = /^([^:]+)(?::(\d+))?$/.exec(endpoint);
3425
+ const match = ipv6 ?? host;
3426
+ if (!match) return void 0;
3427
+ const [, address, port] = match;
3428
+ if (ipv6) {
3429
+ if (isIP(address) !== 6) return void 0;
3430
+ } else if (isIP(address) !== 4) {
3431
+ const hostname = address.endsWith(".") ? address.slice(0, -1) : address;
3432
+ if (hostname.length > 253 || /^[\d.]+$/.test(hostname) || !hostname.split(".").every((label) => /^[a-z\d_](?:[a-z\d_-]{0,61}[a-z\d_])?$/i.test(label))) {
3433
+ return void 0;
3434
+ }
3435
+ }
3436
+ if (port !== void 0 && (Number(port) < 1 || Number(port) > 65535)) return void 0;
3437
+ return {
3438
+ "endpoint-address": address,
3439
+ ...port === void 0 ? {} : { "endpoint-port": String(Number(port)) }
3440
+ };
3441
+ }
3442
+ var peerEndpointSchema = z13.string().transform((endpoint, ctx) => {
3443
+ const fields = parsePeerEndpoint(endpoint);
3444
+ if (fields) return fields;
3445
+ ctx.addIssue({
3446
+ code: z13.ZodIssueCode.custom,
3447
+ message: "Use IPv4:port, hostname:port or [IPv6]:port with port 1\u201365535, or an address without a port"
3448
+ });
3449
+ return z13.NEVER;
3450
+ });
3451
+ function canonicalPeerAddress(value) {
3452
+ const address = String(value ?? "");
3453
+ if (isIP(address) === 6) return new URL(`http://[${address}]/`).hostname;
3454
+ return address.toLowerCase().replace(/\.$/, "");
3455
+ }
3456
+ function canonicalAllowedAddresses(value) {
3457
+ const addresses = String(value ?? "").split(",").map((item) => {
3458
+ const cidr2 = item.trim();
3459
+ const [address, prefix] = cidr2.split("/");
3460
+ const family = isIP(address);
3461
+ if (!family || prefix !== void 0 && !/^\d+$/.test(prefix)) return cidr2;
3462
+ const bits = family === 4 ? 32 : 128;
3463
+ const length = Number(prefix ?? bits);
3464
+ if (length > bits || cidr2.split("/").length > 2) return cidr2;
3465
+ return `${canonicalPeerAddress(address)}/${length}`;
3466
+ });
3467
+ return [...new Set(addresses)].sort().join(",");
3468
+ }
3384
3469
  var managePeerInputSchema = z13.object({
3385
3470
  routerId,
3386
3471
  action: z13.enum(["add", "remove"]).describe("Action to perform"),
3387
3472
  interface: z13.string().describe("WireGuard interface name (e.g. wg0)"),
3388
3473
  publicKey: z13.string().min(1).describe("Peer public key in base64 format (44 characters)"),
3389
3474
  allowedAddress: z13.string().optional().describe("Allowed IP address/CIDR for this peer (e.g. 10.0.0.2/32)"),
3390
- endpoint: z13.string().optional().describe("Peer endpoint as IP:port (e.g. 1.2.3.4:51820)"),
3475
+ endpoint: z13.string().optional().describe(
3476
+ "Add only: IPv4:port, hostname:port or [IPv6]:port (port 1\u201365535); hostname labels may contain underscores. Address-only IPv4/hostname/IPv6 (bare or bracketed) leaves the RouterOS port default unchanged, which may not allow initiation. Ignored for remove; omit for a roaming peer."
3477
+ ),
3391
3478
  comment: z13.string().max(255).optional().describe("Optional comment"),
3392
3479
  dryRun
3393
3480
  }).strict();
3394
3481
  var managePeerTool = {
3395
3482
  name: "manage_wireguard_peer",
3396
3483
  title: "Manage WireGuard Peer",
3397
- description: "Add or remove a WireGuard peer. Idempotent by public key: add returns already_exists if a peer with the same public key already exists on the interface.",
3484
+ description: "Add or remove a WireGuard peer. Add returns already_exists when the public key and explicitly requested endpoint/allowed addresses match; different settings produce CONFLICT without changing the peer. Omitted settings are left unchanged.",
3398
3485
  inputSchema: managePeerInputSchema,
3399
3486
  annotations: {
3400
3487
  readOnlyHint: false,
@@ -3410,11 +3497,44 @@ var managePeerTool = {
3410
3497
  "Managing WireGuard peer"
3411
3498
  );
3412
3499
  try {
3500
+ const endpointFields = parsed.action === "add" && parsed.endpoint !== void 0 ? peerEndpointSchema.parse(parsed.endpoint) : {};
3413
3501
  const existing = await context.routerClient.get("interface/wireguard/peers", {
3414
3502
  filter: { interface: parsed.interface, "public-key": parsed.publicKey }
3415
3503
  });
3416
3504
  if (parsed.action === "add") {
3417
3505
  if (existing.length > 0) {
3506
+ const rec2 = existing[0];
3507
+ const requestedFields = {
3508
+ ...endpointFields,
3509
+ ...parsed.allowedAddress === void 0 ? {} : { "allowed-address": parsed.allowedAddress }
3510
+ };
3511
+ const mismatched = Object.entries(requestedFields).filter(([property, desired]) => {
3512
+ if (property === "endpoint-address") {
3513
+ return canonicalPeerAddress(rec2[property]) !== canonicalPeerAddress(desired);
3514
+ }
3515
+ if (property === "allowed-address") {
3516
+ return canonicalAllowedAddresses(rec2[property]) !== canonicalAllowedAddresses(desired);
3517
+ }
3518
+ return !sameValue(rec2[property], desired);
3519
+ });
3520
+ if (mismatched.length > 0) {
3521
+ throw new MikroMCPError({
3522
+ category: "CONFLICT" /* CONFLICT */,
3523
+ code: "WIREGUARD_PEER_CONFLICT",
3524
+ message: `WireGuard peer already exists on "${parsed.interface}" with different requested settings.`,
3525
+ details: {
3526
+ existing: Object.fromEntries(
3527
+ mismatched.map(([property]) => [property, rec2[property]])
3528
+ ),
3529
+ requested: Object.fromEntries(mismatched)
3530
+ },
3531
+ recoverability: {
3532
+ retryable: false,
3533
+ suggestedAction: "Inspect the peer with list_wireguard_peers, then remove it before adding different settings.",
3534
+ alternativeTools: ["list_wireguard_peers", "manage_wireguard_peer"]
3535
+ }
3536
+ });
3537
+ }
3418
3538
  return {
3419
3539
  content: `WireGuard peer with this public key already exists on "${parsed.interface}". No changes made.`,
3420
3540
  structuredContent: { action: "already_exists", peer: existing[0] }
@@ -3424,8 +3544,12 @@ var managePeerTool = {
3424
3544
  const diff = [
3425
3545
  { property: "interface", before: null, after: parsed.interface },
3426
3546
  { property: "public-key", before: null, after: parsed.publicKey },
3427
- ...parsed.allowedAddress ? [{ property: "allowed-address", before: null, after: parsed.allowedAddress }] : [],
3428
- ...parsed.endpoint ? [{ property: "endpoint-address", before: null, after: parsed.endpoint }] : []
3547
+ ...parsed.allowedAddress !== void 0 ? [{ property: "allowed-address", before: null, after: parsed.allowedAddress }] : [],
3548
+ ...Object.entries(endpointFields).map(([property, after]) => ({
3549
+ property,
3550
+ before: null,
3551
+ after
3552
+ }))
3429
3553
  ];
3430
3554
  return {
3431
3555
  content: `Dry run: Would add WireGuard peer on "${parsed.interface}".`,
@@ -3434,10 +3558,10 @@ var managePeerTool = {
3434
3558
  }
3435
3559
  const body = {
3436
3560
  interface: parsed.interface,
3437
- "public-key": parsed.publicKey
3561
+ "public-key": parsed.publicKey,
3562
+ ...endpointFields
3438
3563
  };
3439
- if (parsed.allowedAddress) body["allowed-address"] = parsed.allowedAddress;
3440
- if (parsed.endpoint) body["endpoint-address"] = parsed.endpoint;
3564
+ if (parsed.allowedAddress !== void 0) body["allowed-address"] = parsed.allowedAddress;
3441
3565
  if (parsed.comment) body.comment = parsed.comment.replace(/[\x00-\x1f\x7f]/g, "");
3442
3566
  const created = await context.routerClient.create("interface/wireguard/peers", body);
3443
3567
  log11.info({ interface: parsed.interface, id: created[".id"] }, "WireGuard peer added");
@@ -8027,6 +8151,7 @@ function recordAttempt(args) {
8027
8151
  routerId: args.routerId,
8028
8152
  params: redactParams(args.params),
8029
8153
  snapshotIds: args.snapshotIds,
8154
+ ...args.dryRun ? { dryRun: true } : {},
8030
8155
  phase: "attempt"
8031
8156
  });
8032
8157
  return id;
@@ -8921,6 +9046,47 @@ function unionVlanBlob(currentWire, targetWire) {
8921
9046
  return dumpBlob([...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row));
8922
9047
  }
8923
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
+
8924
9090
  // src/domain/tools/change-management-tools.ts
8925
9091
  var log24 = createLogger("change-management");
8926
9092
  var stepSchema = z25.object({
@@ -8936,6 +9102,116 @@ var applyPlanInputSchema = z25.object({
8936
9102
  steps: z25.array(stepSchema).min(1).max(10).describe("Ordered list of write operations to apply in sequence"),
8937
9103
  confirmationToken: z25.string().optional()
8938
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
+ };
8939
9215
  var rollbackChangeInputSchema = z25.object({
8940
9216
  routerId,
8941
9217
  journalId: z25.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
@@ -9156,7 +9432,8 @@ function createChangeManagementTools(baseTools2) {
9156
9432
  tool: step.tool,
9157
9433
  routerId: context.routerId,
9158
9434
  params: loggableParams(tool, stepParams),
9159
- snapshotIds
9435
+ snapshotIds,
9436
+ dryRun: isEffectiveDryRun(tool, stepParams)
9160
9437
  });
9161
9438
  const stepStartMs = Date.now();
9162
9439
  try {
@@ -9223,7 +9500,10 @@ function createChangeManagementTools(baseTools2) {
9223
9500
  }
9224
9501
  });
9225
9502
  }
9226
- return { snapshotIds: entry.snapshotIds ?? [] };
9503
+ return {
9504
+ snapshotIds: entry.snapshotIds ?? [],
9505
+ dryRun: journalEntryWasDryRun(entry)
9506
+ };
9227
9507
  }
9228
9508
  const rollbackChangeTool = {
9229
9509
  name: "rollback_change",
@@ -9242,7 +9522,16 @@ function createChangeManagementTools(baseTools2) {
9242
9522
  const journalPath = context.appConfig.journalPath;
9243
9523
  const snapshotDir = context.appConfig.snapshotDir;
9244
9524
  log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
9245
- 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
+ }
9246
9535
  if (snapshotIds.length === 0) {
9247
9536
  return {
9248
9537
  content: `Journal entry "${parsed.journalId}" has no associated snapshots \u2014 cannot rollback automatically.`,
@@ -9297,7 +9586,7 @@ ${warnings.map((w) => ` - ${w}`).join("\n")}` : "";
9297
9586
  };
9298
9587
  }
9299
9588
  };
9300
- return [planChangesTool, applyPlanTool, rollbackChangeTool];
9589
+ return [planChangesTool, applyPlanTool, rollbackChangeTool, listJournalTool];
9301
9590
  }
9302
9591
 
9303
9592
  // src/domain/tools/ipsec-tools.ts
@@ -11770,8 +12059,8 @@ function sanitizeComment3(comment) {
11770
12059
  var manageVlanInputSchema = z35.object({
11771
12060
  routerId,
11772
12061
  action: z35.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
11773
- name: z35.string().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe(
11774
- "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"
11775
12064
  ),
11776
12065
  vlanId: z35.number().int().min(1).max(4094).optional().describe("VLAN ID (1-4094; required for add)"),
11777
12066
  parentInterface: z35.string().optional().describe("Parent interface name (e.g., ether1, bridge1; required for add)"),
@@ -11808,6 +12097,11 @@ var manageVlanTool = {
11808
12097
  (v) => sameValue(v.name, parsed.name)
11809
12098
  );
11810
12099
  if (parsed.action === "add") {
12100
+ assertNewInterfaceName(parsed.name, {
12101
+ kind: "VLAN",
12102
+ action: "add",
12103
+ code: "VLAN_NAME_INVALID"
12104
+ });
11811
12105
  if (parsed.vlanId === void 0) {
11812
12106
  throw new MikroMCPError({
11813
12107
  category: "VALIDATION" /* VALIDATION */,
@@ -11952,9 +12246,12 @@ var vlanTools = [manageVlanTool];
11952
12246
  // src/domain/tools/dhcp-client-tools.ts
11953
12247
  import { z as z36 } from "zod";
11954
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
+ }
11955
12252
  var listDhcpClientsInputSchema = z36.object({
11956
12253
  routerId,
11957
- 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)"),
11958
12255
  status: z36.enum(["bound", "searching", "requesting", "init", "all"]).default("all").describe("Filter by DHCP client status"),
11959
12256
  limit,
11960
12257
  offset
@@ -11980,7 +12277,9 @@ var listDhcpClientsTool = {
11980
12277
  });
11981
12278
  let filtered = allClients;
11982
12279
  if (parsed.interface) {
11983
- 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
+ );
11984
12283
  }
11985
12284
  if (parsed.status !== "all") {
11986
12285
  filtered = filtered.filter((c) => c.status === parsed.status);
@@ -11993,7 +12292,7 @@ var listDhcpClientsTool = {
11993
12292
  clients,
11994
12293
  total,
11995
12294
  parsed.offset,
11996
- (c) => compactFields(c, ["interface", "status", "address", "gateway", "disabled"])
12295
+ (c) => compactFields(c, ["interface", "name", "status", "address", "gateway", "disabled"])
11997
12296
  ),
11998
12297
  structuredContent: {
11999
12298
  routerId: context.routerId,
@@ -12012,7 +12311,9 @@ var listDhcpClientsTool = {
12012
12311
  var manageDhcpClientInputSchema = z36.object({
12013
12312
  routerId,
12014
12313
  action: z36.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
12015
- 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
+ ),
12016
12317
  usePeerDns: z36.boolean().default(true).describe("Use DNS servers provided by DHCP server (add only)"),
12017
12318
  usePeerNtp: z36.boolean().default(false).describe("Use NTP servers provided by DHCP server (add only)"),
12018
12319
  addDefaultRoute: z36.boolean().default(true).describe("Add default route from DHCP (add only)"),
@@ -12042,8 +12343,10 @@ var manageDhcpClientTool = {
12042
12343
  limit: void 0,
12043
12344
  offset: void 0
12044
12345
  });
12045
- const existing = allClients.find(
12046
- (c) => c.interface === parsed.interface
12346
+ const existing = findClient(
12347
+ allClients,
12348
+ parsed.interface,
12349
+ parsed.action !== "add"
12047
12350
  );
12048
12351
  if (parsed.action === "add") {
12049
12352
  if (existing) {
@@ -12084,9 +12387,19 @@ var manageDhcpClientTool = {
12084
12387
  }
12085
12388
  if (parsed.action === "remove") {
12086
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(", ");
12087
12396
  return {
12088
- content: `No DHCP client found on "${parsed.interface}". Nothing to remove.`,
12089
- 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
+ }
12090
12403
  };
12091
12404
  }
12092
12405
  if (parsed.dryRun) {
@@ -12094,7 +12407,7 @@ var manageDhcpClientTool = {
12094
12407
  content: `Dry run: Would remove DHCP client on "${parsed.interface}".`,
12095
12408
  structuredContent: {
12096
12409
  action: "dry_run",
12097
- diff: [{ property: "interface", before: parsed.interface, after: null }]
12410
+ diff: [{ property: "interface", before: existing.interface, after: null }]
12098
12411
  }
12099
12412
  };
12100
12413
  }
@@ -12236,7 +12549,7 @@ var manageIpServiceInputSchema = z37.object({
12236
12549
  var manageIpServiceTool = {
12237
12550
  name: "manage_ip_service",
12238
12551
  title: "Manage IP Service",
12239
- description: "Enable or disable a RouterOS IP service (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp). Port number changes are intentionally not supported to prevent accidental lockout.",
12552
+ description: "Enable or disable a RouterOS IP service (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp). Port number changes are intentionally not supported to prevent accidental lockout. MikroMCP reaches RouterOS through the REST API on www-ssl (or www for plaintext): disabling the service its connection uses cuts MikroMCP off from the router, including any later call to re-enable it.",
12240
12553
  inputSchema: manageIpServiceInputSchema,
12241
12554
  annotations: {
12242
12555
  readOnlyHint: false,
@@ -16389,44 +16702,6 @@ var swosMaintenanceTools = [
16389
16702
  // src/domain/tools/fleet-tools.ts
16390
16703
  import { z as z50 } from "zod";
16391
16704
 
16392
- // src/middleware/authz.ts
16393
- function matchesPattern(name, pattern) {
16394
- return globMatch(pattern, name);
16395
- }
16396
- function checkAuthz(identity, toolName, routerId2) {
16397
- if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
16398
- throw new MikroMCPError({
16399
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
16400
- code: "ROUTER_NOT_ALLOWED",
16401
- message: `Identity "${identity.id}" is not allowed to access router "${routerId2}".`,
16402
- details: { identityId: identity.id, routerId: routerId2, allowedRouters: identity.allowedRouters },
16403
- recoverability: {
16404
- retryable: false,
16405
- suggestedAction: `Add "${routerId2}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
16406
- }
16407
- });
16408
- }
16409
- if (identity.allowedToolPatterns.length > 0) {
16410
- const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
16411
- if (!allowed) {
16412
- throw new MikroMCPError({
16413
- category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
16414
- code: "TOOL_NOT_ALLOWED",
16415
- message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
16416
- details: {
16417
- identityId: identity.id,
16418
- toolName,
16419
- allowedToolPatterns: identity.allowedToolPatterns
16420
- },
16421
- recoverability: {
16422
- retryable: false,
16423
- suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
16424
- }
16425
- });
16426
- }
16427
- }
16428
- }
16429
-
16430
16705
  // src/config/secrets.ts
16431
16706
  function getCredentials(router) {
16432
16707
  const { source, envPrefix, vaultPath } = router.credentials;
@@ -17320,6 +17595,7 @@ async function executeToolCall(tool, args, deps) {
17320
17595
  }
17321
17596
  }
17322
17597
  toolContext.snapshots = snapshots;
17598
+ const dryRun2 = !tool.annotations.readOnlyHint && isEffectiveDryRun(tool, handlerArgs);
17323
17599
  if (!tool.annotations.readOnlyHint && config.journalPath) {
17324
17600
  const { confirmationToken: _token, ...journalArgs } = loggedArgs;
17325
17601
  journalId = recordAttempt({
@@ -17329,7 +17605,8 @@ async function executeToolCall(tool, args, deps) {
17329
17605
  tool: tool.name,
17330
17606
  routerId: routerId2,
17331
17607
  params: journalArgs,
17332
- snapshotIds
17608
+ snapshotIds,
17609
+ dryRun: dryRun2
17333
17610
  });
17334
17611
  }
17335
17612
  const runHandler = () => tool.handler(handlerArgs, toolContext);
@@ -17343,6 +17620,11 @@ async function executeToolCall(tool, args, deps) {
17343
17620
  phase: "success",
17344
17621
  durationMs: Date.now() - startMs
17345
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
+ }
17346
17628
  }
17347
17629
  if (shouldAudit) {
17348
17630
  auditLog(
@@ -17520,7 +17802,7 @@ var listRoutersInputSchema = z50.object({
17520
17802
  var listRoutersTool = {
17521
17803
  name: "list_routers",
17522
17804
  title: "List Routers",
17523
- 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).",
17524
17806
  inputSchema: listRoutersInputSchema,
17525
17807
  annotations: {
17526
17808
  readOnlyHint: true,
@@ -17563,9 +17845,17 @@ var listRoutersTool = {
17563
17845
  "isDefault"
17564
17846
  ])}`
17565
17847
  );
17848
+ const hasRouterOS = rows.some((r) => r.deviceType === "routeros");
17566
17849
  return {
17567
17850
  content: total === 0 ? header : [header, ...lines].join("\n"),
17568
- 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
+ }
17569
17859
  };
17570
17860
  }
17571
17861
  };
@@ -17724,7 +18014,8 @@ async function fanOut(req) {
17724
18014
  tool: input2.toolName,
17725
18015
  routerId: router.id,
17726
18016
  params: loggedParams,
17727
- snapshotIds
18017
+ snapshotIds,
18018
+ dryRun: isEffectiveDryRun(targetTool, toolParams)
17728
18019
  });
17729
18020
  }
17730
18021
  const cb = getOrCreateBreaker(context.circuitBreakers, router.id, context.appConfig);
@@ -18308,7 +18599,7 @@ var ConnectionPool = class {
18308
18599
  };
18309
18600
 
18310
18601
  // src/version.ts
18311
- var VERSION2 = "1.14.0";
18602
+ var VERSION2 = "1.15.0";
18312
18603
 
18313
18604
  // src/mcp/server.ts
18314
18605
  var SERVER_INSTRUCTIONS = [
@@ -18786,7 +19077,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
18786
19077
  import { homedir as homedir2 } from "os";
18787
19078
  import * as net from "net";
18788
19079
  import chalk from "chalk";
18789
- var localVersion = "1.14.0";
19080
+ var localVersion = "1.15.0";
18790
19081
  var results = [];
18791
19082
  function ok(msg) {
18792
19083
  console.log(chalk.green("\u2705 ") + msg);
@@ -19602,7 +19893,7 @@ async function runInit() {
19602
19893
  }
19603
19894
 
19604
19895
  // src/cli/index.ts
19605
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.14.0");
19896
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.15.0");
19606
19897
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
19607
19898
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
19608
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.0",
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",