mikromcp 1.2.0 → 1.3.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 +6 -5
  2. package/dist/main.js +795 -93
  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.2.0-EA580C)](package.json)
15
+ [![Version](https://img.shields.io/badge/version-v1.3.0-EA580C)](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-82-0f766e)](#available-tools)
20
+ [![Tools](https://img.shields.io/badge/MCP%20tools-88-0f766e)](#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.
@@ -71,7 +71,7 @@ MikroMCP exists because raw router CLI access is the wrong abstraction for AI ag
71
71
 
72
72
  ### MCP Inspector
73
73
 
74
- ![The 82 registered MikroMCP tools with schemas](docs/assets/mcp-inspector-tools.png)
74
+ ![The 88 registered MikroMCP tools with schemas](docs/assets/mcp-inspector-tools.png)
75
75
 
76
76
  ---
77
77
 
@@ -285,7 +285,7 @@ All settings can be placed in `~/.mikromcp/.env` or passed as environment variab
285
285
 
286
286
  ## Available Tools
287
287
 
288
- MikroMCP currently registers **82 MCP tools**.
288
+ MikroMCP currently registers **88 MCP tools**.
289
289
 
290
290
  | Area | Tools |
291
291
  | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -295,6 +295,7 @@ MikroMCP currently registers **82 MCP tools**.
295
295
  | DHCP Servers & Pools | `list_dhcp_servers`, `manage_dhcp_server`, `list_ip_pools`, `manage_ip_pool` |
296
296
  | DHCP Clients | `list_dhcp_clients`, `manage_dhcp_client` |
297
297
  | IP Services | `list_ip_services`, `manage_ip_service` |
298
+ | PPPoE & OpenVPN | `list_pppoe_clients`, `manage_pppoe_client`, `list_ovpn_clients`, `manage_ovpn_client`, `get_ovpn_server`, `manage_ovpn_server` |
298
299
  | Routing | `list_routes`, `manage_route`, `list_routing_rules`, `manage_routing_rule`, `list_routing_tables`, `manage_routing_table` |
299
300
  | Routing protocols | `list_bgp_peers`, `list_ospf_neighbors` |
300
301
  | Firewall | `list_firewall_rules`, `manage_firewall_rule`, `list_mangle_rules`, `manage_mangle_rule`, `list_address_list_entries`, `manage_address_list_entry` |
@@ -421,7 +422,7 @@ AI assistants can use MikroMCP to:
421
422
  | [Configuration](https://github.com/AliKarami/MikroMCP/wiki/Configuration) | Router registry, credentials, all environment variables |
422
423
  | [Connecting to Claude Desktop](https://github.com/AliKarami/MikroMCP/wiki/Connecting-to-Claude-Desktop) | Register MikroMCP in Claude Desktop |
423
424
  | [Connecting to AI Assistants](https://github.com/AliKarami/MikroMCP/wiki/Connecting-to-AI-Assistants) | Claude Code, Cursor, Codex, HTTP/Docker/systemd |
424
- | [Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools) | All 82 tools — parameters and example prompts |
425
+ | [Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools) | All 88 tools — parameters and example prompts |
425
426
  | [Architecture](https://github.com/AliKarami/MikroMCP/wiki/Architecture) | System layers, request pipeline, auth model |
426
427
  | [Error Handling](https://github.com/AliKarami/MikroMCP/wiki/Error-Handling) | Error categories, retry engine, circuit breaker |
427
428
  | [Running](https://github.com/AliKarami/MikroMCP/wiki/Running) | Run commands, HTTP transport, troubleshooting |
package/dist/main.js CHANGED
@@ -60,7 +60,7 @@ function loadAppConfig() {
60
60
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
61
61
 
62
62
  // src/mcp/tool-registry.ts
63
- import { z as z36 } from "zod";
63
+ import { z as z38 } from "zod";
64
64
 
65
65
  // src/domain/tools/system-tools.ts
66
66
  import { z } from "zod";
@@ -497,9 +497,9 @@ var listInterfacesTool = {
497
497
  const paginated = interfaces.slice(parsed.offset, parsed.offset + parsed.limit);
498
498
  const results2 = paginated.map((iface) => {
499
499
  const rec = iface;
500
- const isRunning = rec.running === true || rec.running === "true";
500
+ const isRunning2 = rec.running === true || rec.running === "true";
501
501
  const isDisabled = rec.disabled === true || rec.disabled === "true";
502
- const enriched = { status: isDisabled ? "disabled" : isRunning ? "up" : "down" };
502
+ const enriched = { status: isDisabled ? "disabled" : isRunning2 ? "up" : "down" };
503
503
  for (const [key2, value] of Object.entries(rec)) {
504
504
  if (parsed.includeCounters || !COUNTER_PROPS.includes(key2)) {
505
505
  enriched[key2] = value;
@@ -7775,8 +7775,708 @@ var manageIpServiceTool = {
7775
7775
  };
7776
7776
  var ipServiceTools = [listIpServicesTool, manageIpServiceTool];
7777
7777
 
7778
- // src/domain/tools/fleet-tools.ts
7778
+ // src/domain/tools/pppoe-tools.ts
7779
7779
  import { z as z35 } from "zod";
7780
+ var log36 = createLogger("pppoe-tools");
7781
+ var PPPOE_PATH = "interface/pppoe-client";
7782
+ var listPppoeClientsInputSchema = z35.object({
7783
+ routerId: z35.string().describe("Target router identifier from the router registry"),
7784
+ interface: z35.string().optional().describe("Filter by parent interface name (exact match)"),
7785
+ status: z35.enum(["connected", "disconnected", "all"]).default("all").describe("Filter by running status"),
7786
+ limit: z35.number().int().min(1).max(500).default(100).describe("Maximum number of clients to return"),
7787
+ offset: z35.number().int().min(0).default(0).describe("Offset for pagination")
7788
+ }).strict();
7789
+ function isRunning(record) {
7790
+ return record.running === true || record.running === "true";
7791
+ }
7792
+ var listPppoeClientsTool = {
7793
+ name: "list_pppoe_clients",
7794
+ title: "List PPPoE Clients",
7795
+ description: "List PPPoE client interfaces on a MikroTik router. Shows name, parent interface, ISP username, and connection status.",
7796
+ inputSchema: listPppoeClientsInputSchema,
7797
+ annotations: {
7798
+ readOnlyHint: true,
7799
+ destructiveHint: false,
7800
+ idempotentHint: true,
7801
+ openWorldHint: false
7802
+ },
7803
+ async handler(params, context) {
7804
+ const parsed = listPppoeClientsInputSchema.parse(params);
7805
+ log36.info({ routerId: context.routerId }, "Listing PPPoE clients");
7806
+ try {
7807
+ const allClients = await context.routerClient.get(PPPOE_PATH, {
7808
+ limit: void 0,
7809
+ offset: void 0
7810
+ });
7811
+ let filtered = allClients;
7812
+ if (parsed.interface) {
7813
+ filtered = filtered.filter((c) => c.interface === parsed.interface);
7814
+ }
7815
+ if (parsed.status === "connected") {
7816
+ filtered = filtered.filter((c) => isRunning(c));
7817
+ } else if (parsed.status === "disconnected") {
7818
+ filtered = filtered.filter((c) => !isRunning(c));
7819
+ }
7820
+ const total = filtered.length;
7821
+ const clients = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
7822
+ const hasMore = parsed.offset + parsed.limit < total;
7823
+ const lines = [
7824
+ `PPPoE clients on ${context.routerId}: ${total} total, showing ${clients.length} (offset ${parsed.offset})`
7825
+ ];
7826
+ for (const c of clients) {
7827
+ const name = c.name ?? "unknown";
7828
+ const iface = c.interface ?? "unknown";
7829
+ const user = c.user ?? "-";
7830
+ const status = isRunning(c) ? "connected" : "disconnected";
7831
+ lines.push(` ${name} [${iface}] user=${user} ${status}`);
7832
+ }
7833
+ return {
7834
+ content: lines.join("\n"),
7835
+ structuredContent: {
7836
+ routerId: context.routerId,
7837
+ clients,
7838
+ total,
7839
+ hasMore,
7840
+ offset: parsed.offset,
7841
+ limit: parsed.limit
7842
+ }
7843
+ };
7844
+ } catch (err) {
7845
+ throw enrichError(err, { routerId: context.routerId, tool: "list_pppoe_clients" });
7846
+ }
7847
+ }
7848
+ };
7849
+ var managePppoeClientInputSchema = z35.object({
7850
+ routerId: z35.string().describe("Target router identifier from the router registry"),
7851
+ action: z35.enum(["add", "update", "remove"]).describe("Action to perform"),
7852
+ name: z35.string().describe("PPPoE client interface name \u2014 idempotency key (e.g. pppoe-wan)"),
7853
+ interface: z35.string().optional().describe("Parent interface (required for add)"),
7854
+ user: z35.string().optional().describe("PPPoE username (required for add)"),
7855
+ password: z35.string().optional().describe("PPPoE password (never logged)"),
7856
+ serviceName: z35.string().optional().describe("PPPoE service name filter (leave empty to match any)"),
7857
+ addDefaultRoute: z35.boolean().optional().describe("Add default route via PPPoE (yes/no)"),
7858
+ dialOnDemand: z35.boolean().optional().describe("Dial on demand instead of always-on (yes/no)"),
7859
+ dryRun: z35.boolean().default(false).describe("Preview changes without applying")
7860
+ }).strict();
7861
+ var managePppoeClientTool = {
7862
+ name: "manage_pppoe_client",
7863
+ title: "Manage PPPoE Client",
7864
+ description: "Add, update, or remove a PPPoE client interface. Idempotent by name: add returns already_exists if same name+interface+user exists; throws CONFLICT if same name exists with different config. Update builds a diff of changed fields and returns no_change when nothing differs. Password is always written when provided because RouterOS does not expose it in GET.",
7865
+ inputSchema: managePppoeClientInputSchema,
7866
+ annotations: {
7867
+ readOnlyHint: false,
7868
+ destructiveHint: true,
7869
+ idempotentHint: true,
7870
+ openWorldHint: false
7871
+ },
7872
+ snapshotPaths: [PPPOE_PATH],
7873
+ async handler(params, context) {
7874
+ const parsed = managePppoeClientInputSchema.parse(params);
7875
+ log36.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing PPPoE client");
7876
+ try {
7877
+ const allClients = await context.routerClient.get(PPPOE_PATH, {
7878
+ limit: void 0,
7879
+ offset: void 0
7880
+ });
7881
+ const existing = allClients.find((c) => c.name === parsed.name);
7882
+ if (parsed.action === "add") {
7883
+ if (!parsed.interface) {
7884
+ throw new MikroMCPError({
7885
+ category: "VALIDATION" /* VALIDATION */,
7886
+ code: "PPPOE_CLIENT_INTERFACE_REQUIRED",
7887
+ message: "interface is required for action add.",
7888
+ details: { name: parsed.name },
7889
+ recoverability: {
7890
+ retryable: false,
7891
+ suggestedAction: "Provide the parent interface (e.g. ether1).",
7892
+ alternativeTools: []
7893
+ }
7894
+ });
7895
+ }
7896
+ if (!parsed.user) {
7897
+ throw new MikroMCPError({
7898
+ category: "VALIDATION" /* VALIDATION */,
7899
+ code: "PPPOE_CLIENT_USER_REQUIRED",
7900
+ message: "user is required for action add.",
7901
+ details: { name: parsed.name },
7902
+ recoverability: {
7903
+ retryable: false,
7904
+ suggestedAction: "Provide the PPPoE username.",
7905
+ alternativeTools: []
7906
+ }
7907
+ });
7908
+ }
7909
+ if (existing) {
7910
+ const sameConfig = existing.interface === parsed.interface && existing.user === parsed.user;
7911
+ if (sameConfig) {
7912
+ return {
7913
+ content: `PPPoE client "${parsed.name}" already exists with the same configuration. No changes made.`,
7914
+ structuredContent: { action: "already_exists", client: existing }
7915
+ };
7916
+ }
7917
+ throw new MikroMCPError({
7918
+ category: "CONFLICT" /* CONFLICT */,
7919
+ code: "PPPOE_CLIENT_CONFLICT",
7920
+ message: `PPPoE client "${parsed.name}" already exists with a different configuration.`,
7921
+ details: {
7922
+ existing: { interface: existing.interface, user: existing.user },
7923
+ requested: { interface: parsed.interface, user: parsed.user }
7924
+ },
7925
+ recoverability: {
7926
+ retryable: false,
7927
+ suggestedAction: "Remove the existing client first or use action update.",
7928
+ alternativeTools: ["manage_pppoe_client"]
7929
+ }
7930
+ });
7931
+ }
7932
+ const diff = [
7933
+ { property: "name", before: null, after: parsed.name },
7934
+ { property: "interface", before: null, after: parsed.interface },
7935
+ { property: "user", before: null, after: parsed.user }
7936
+ ];
7937
+ if (parsed.serviceName !== void 0) {
7938
+ diff.push({ property: "service-name", before: null, after: parsed.serviceName });
7939
+ }
7940
+ if (parsed.addDefaultRoute !== void 0) {
7941
+ diff.push({ property: "add-default-route", before: null, after: parsed.addDefaultRoute ? "yes" : "no" });
7942
+ }
7943
+ if (parsed.dialOnDemand !== void 0) {
7944
+ diff.push({ property: "dial-on-demand", before: null, after: parsed.dialOnDemand ? "yes" : "no" });
7945
+ }
7946
+ if (parsed.dryRun) {
7947
+ return {
7948
+ content: `Dry run: Would create PPPoE client "${parsed.name}" on "${parsed.interface}".`,
7949
+ structuredContent: { action: "dry_run", diff }
7950
+ };
7951
+ }
7952
+ const body = {
7953
+ name: parsed.name,
7954
+ interface: parsed.interface,
7955
+ user: parsed.user
7956
+ };
7957
+ if (parsed.password !== void 0) body.password = parsed.password;
7958
+ if (parsed.serviceName !== void 0) body["service-name"] = parsed.serviceName;
7959
+ if (parsed.addDefaultRoute !== void 0) body["add-default-route"] = parsed.addDefaultRoute ? "yes" : "no";
7960
+ if (parsed.dialOnDemand !== void 0) body["dial-on-demand"] = parsed.dialOnDemand ? "yes" : "no";
7961
+ const created = await context.routerClient.create(PPPOE_PATH, body);
7962
+ log36.info({ name: parsed.name, id: created[".id"] }, "PPPoE client created");
7963
+ return {
7964
+ content: `Created PPPoE client "${parsed.name}" on "${parsed.interface}".`,
7965
+ structuredContent: { action: "created", client: created }
7966
+ };
7967
+ }
7968
+ if (parsed.action === "update") {
7969
+ if (!existing) {
7970
+ throw new MikroMCPError({
7971
+ category: "NOT_FOUND" /* NOT_FOUND */,
7972
+ code: "PPPOE_CLIENT_NOT_FOUND",
7973
+ message: `PPPoE client "${parsed.name}" not found.`,
7974
+ details: { name: parsed.name },
7975
+ recoverability: {
7976
+ retryable: false,
7977
+ suggestedAction: "Verify with list_pppoe_clients.",
7978
+ alternativeTools: ["list_pppoe_clients"]
7979
+ }
7980
+ });
7981
+ }
7982
+ const updates = {};
7983
+ const diff = [];
7984
+ if (parsed.interface !== void 0 && existing.interface !== parsed.interface) {
7985
+ updates.interface = parsed.interface;
7986
+ diff.push({ property: "interface", before: existing.interface, after: parsed.interface });
7987
+ }
7988
+ if (parsed.user !== void 0 && existing.user !== parsed.user) {
7989
+ updates.user = parsed.user;
7990
+ diff.push({ property: "user", before: existing.user, after: parsed.user });
7991
+ }
7992
+ if (parsed.serviceName !== void 0 && existing["service-name"] !== parsed.serviceName) {
7993
+ updates["service-name"] = parsed.serviceName;
7994
+ diff.push({ property: "service-name", before: existing["service-name"] ?? null, after: parsed.serviceName });
7995
+ }
7996
+ if (parsed.addDefaultRoute !== void 0) {
7997
+ const desired = parsed.addDefaultRoute ? "yes" : "no";
7998
+ if (existing["add-default-route"] !== desired) {
7999
+ updates["add-default-route"] = desired;
8000
+ diff.push({ property: "add-default-route", before: existing["add-default-route"] ?? null, after: desired });
8001
+ }
8002
+ }
8003
+ if (parsed.dialOnDemand !== void 0) {
8004
+ const desired = parsed.dialOnDemand ? "yes" : "no";
8005
+ if (existing["dial-on-demand"] !== desired) {
8006
+ updates["dial-on-demand"] = desired;
8007
+ diff.push({ property: "dial-on-demand", before: existing["dial-on-demand"] ?? null, after: desired });
8008
+ }
8009
+ }
8010
+ if (parsed.password !== void 0) {
8011
+ updates.password = parsed.password;
8012
+ }
8013
+ const hasUpdates = Object.keys(updates).length > 0;
8014
+ if (!hasUpdates) {
8015
+ return {
8016
+ content: `PPPoE client "${parsed.name}" already matches the requested configuration. No changes made.`,
8017
+ structuredContent: { action: "no_change", client: existing }
8018
+ };
8019
+ }
8020
+ if (parsed.dryRun) {
8021
+ return {
8022
+ content: `Dry run: Would update PPPoE client "${parsed.name}".`,
8023
+ structuredContent: { action: "dry_run", diff }
8024
+ };
8025
+ }
8026
+ await context.routerClient.update(PPPOE_PATH, existing[".id"], updates);
8027
+ log36.info({ name: parsed.name }, "PPPoE client updated");
8028
+ return {
8029
+ content: `Updated PPPoE client "${parsed.name}".`,
8030
+ structuredContent: { action: "updated", name: parsed.name, diff }
8031
+ };
8032
+ }
8033
+ if (!existing) {
8034
+ return {
8035
+ content: `PPPoE client "${parsed.name}" not found. Nothing to remove.`,
8036
+ structuredContent: { action: "not_found", name: parsed.name }
8037
+ };
8038
+ }
8039
+ if (parsed.dryRun) {
8040
+ return {
8041
+ content: `Dry run: Would remove PPPoE client "${parsed.name}".`,
8042
+ structuredContent: {
8043
+ action: "dry_run",
8044
+ diff: [{ property: "name", before: parsed.name, after: null }]
8045
+ }
8046
+ };
8047
+ }
8048
+ await context.routerClient.remove(PPPOE_PATH, existing[".id"]);
8049
+ log36.info({ name: parsed.name }, "PPPoE client removed");
8050
+ return {
8051
+ content: `Removed PPPoE client "${parsed.name}".`,
8052
+ structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
8053
+ };
8054
+ } catch (err) {
8055
+ if (err instanceof MikroMCPError) throw err;
8056
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_pppoe_client" });
8057
+ }
8058
+ }
8059
+ };
8060
+ var pppoeTools = [listPppoeClientsTool, managePppoeClientTool];
8061
+
8062
+ // src/domain/tools/ovpn-tools.ts
8063
+ import { z as z36 } from "zod";
8064
+ var log37 = createLogger("ovpn-tools");
8065
+ var OVPN_CLIENT_PATH = "interface/ovpn-client";
8066
+ var OVPN_SERVER_PATH = "interface/ovpn-server/server";
8067
+ var listOvpnClientsInputSchema = z36.object({
8068
+ routerId: z36.string().describe("Target router identifier from the router registry"),
8069
+ limit: z36.number().int().min(1).max(500).default(100).describe("Maximum number of clients to return"),
8070
+ offset: z36.number().int().min(0).default(0).describe("Offset for pagination")
8071
+ }).strict();
8072
+ var listOvpnClientsTool = {
8073
+ name: "list_ovpn_clients",
8074
+ title: "List OpenVPN Clients",
8075
+ description: "List OpenVPN client interfaces on a MikroTik router. Shows name, remote server, and connection status.",
8076
+ inputSchema: listOvpnClientsInputSchema,
8077
+ annotations: {
8078
+ readOnlyHint: true,
8079
+ destructiveHint: false,
8080
+ idempotentHint: true,
8081
+ openWorldHint: false
8082
+ },
8083
+ async handler(params, context) {
8084
+ const parsed = listOvpnClientsInputSchema.parse(params);
8085
+ log37.info({ routerId: context.routerId }, "Listing OpenVPN clients");
8086
+ try {
8087
+ const allClients = await context.routerClient.get(OVPN_CLIENT_PATH, {
8088
+ limit: void 0,
8089
+ offset: void 0
8090
+ });
8091
+ const total = allClients.length;
8092
+ const clients = allClients.slice(parsed.offset, parsed.offset + parsed.limit);
8093
+ const hasMore = parsed.offset + parsed.limit < total;
8094
+ return {
8095
+ content: `OpenVPN clients on ${context.routerId}: ${total} total, showing ${clients.length} (offset ${parsed.offset})`,
8096
+ structuredContent: {
8097
+ routerId: context.routerId,
8098
+ clients,
8099
+ total,
8100
+ hasMore,
8101
+ offset: parsed.offset,
8102
+ limit: parsed.limit
8103
+ }
8104
+ };
8105
+ } catch (err) {
8106
+ throw enrichError(err, { routerId: context.routerId, tool: "list_ovpn_clients" });
8107
+ }
8108
+ }
8109
+ };
8110
+ var manageOvpnClientInputSchema = z36.object({
8111
+ routerId: z36.string().describe("Target router identifier from the router registry"),
8112
+ action: z36.enum(["add", "update", "remove"]).describe("Action to perform"),
8113
+ name: z36.string().describe("OpenVPN client interface name \u2014 idempotency key"),
8114
+ connectTo: z36.string().optional().describe("Remote server address (required for add)"),
8115
+ port: z36.number().int().min(1).max(65535).optional().describe("Remote port number"),
8116
+ mode: z36.enum(["ip", "ethernet"]).optional().describe("Tunnel mode"),
8117
+ protocol: z36.enum(["tcp-client", "udp"]).optional().describe("Transport protocol"),
8118
+ certificate: z36.string().optional().describe("Client certificate name"),
8119
+ user: z36.string().optional().describe("VPN username"),
8120
+ password: z36.string().optional().describe("VPN password (never logged)"),
8121
+ dryRun: z36.boolean().default(false).describe("Preview changes without applying")
8122
+ }).strict();
8123
+ var manageOvpnClientTool = {
8124
+ name: "manage_ovpn_client",
8125
+ title: "Manage OpenVPN Client",
8126
+ description: "Add, update, or remove an OpenVPN client interface. Idempotent by name: add returns already_exists if same name+connectTo exists; throws CONFLICT if same name exists with different connectTo. Update builds a diff of changed fields and returns no_change when nothing differs. Password is always written when provided because RouterOS does not expose it in GET.",
8127
+ inputSchema: manageOvpnClientInputSchema,
8128
+ annotations: {
8129
+ readOnlyHint: false,
8130
+ destructiveHint: true,
8131
+ idempotentHint: true,
8132
+ openWorldHint: false
8133
+ },
8134
+ snapshotPaths: [OVPN_CLIENT_PATH],
8135
+ async handler(params, context) {
8136
+ const parsed = manageOvpnClientInputSchema.parse(params);
8137
+ log37.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing OpenVPN client");
8138
+ try {
8139
+ const allClients = await context.routerClient.get(OVPN_CLIENT_PATH, {
8140
+ limit: void 0,
8141
+ offset: void 0
8142
+ });
8143
+ const existing = allClients.find((c) => c.name === parsed.name);
8144
+ if (parsed.action === "add") {
8145
+ if (!parsed.connectTo) {
8146
+ throw new MikroMCPError({
8147
+ category: "VALIDATION" /* VALIDATION */,
8148
+ code: "OVPN_CLIENT_CONNECT_TO_REQUIRED",
8149
+ message: "connectTo is required for action add.",
8150
+ details: { name: parsed.name },
8151
+ recoverability: {
8152
+ retryable: false,
8153
+ suggestedAction: "Provide the remote server address.",
8154
+ alternativeTools: []
8155
+ }
8156
+ });
8157
+ }
8158
+ if (existing) {
8159
+ const sameConfig = existing["connect-to"] === parsed.connectTo;
8160
+ if (sameConfig) {
8161
+ return {
8162
+ content: `OpenVPN client "${parsed.name}" already exists with the same configuration. No changes made.`,
8163
+ structuredContent: { action: "already_exists", client: existing }
8164
+ };
8165
+ }
8166
+ throw new MikroMCPError({
8167
+ category: "CONFLICT" /* CONFLICT */,
8168
+ code: "OVPN_CLIENT_CONFLICT",
8169
+ message: `OpenVPN client "${parsed.name}" already exists with a different configuration.`,
8170
+ details: {
8171
+ existing: { "connect-to": existing["connect-to"] },
8172
+ requested: { "connect-to": parsed.connectTo }
8173
+ },
8174
+ recoverability: {
8175
+ retryable: false,
8176
+ suggestedAction: "Remove the existing client first or use action update.",
8177
+ alternativeTools: ["manage_ovpn_client"]
8178
+ }
8179
+ });
8180
+ }
8181
+ const diff = [
8182
+ { property: "name", before: null, after: parsed.name },
8183
+ { property: "connect-to", before: null, after: parsed.connectTo }
8184
+ ];
8185
+ if (parsed.dryRun) {
8186
+ return {
8187
+ content: `Dry run: Would create OpenVPN client "${parsed.name}" connecting to "${parsed.connectTo}".`,
8188
+ structuredContent: { action: "dry_run", diff }
8189
+ };
8190
+ }
8191
+ const body = {
8192
+ name: parsed.name,
8193
+ "connect-to": parsed.connectTo
8194
+ };
8195
+ if (parsed.port !== void 0) body.port = String(parsed.port);
8196
+ if (parsed.mode !== void 0) body.mode = parsed.mode;
8197
+ if (parsed.protocol !== void 0) body.protocol = parsed.protocol;
8198
+ if (parsed.certificate !== void 0) body.certificate = parsed.certificate;
8199
+ if (parsed.user !== void 0) body.user = parsed.user;
8200
+ if (parsed.password !== void 0) body.password = parsed.password;
8201
+ const created = await context.routerClient.create(OVPN_CLIENT_PATH, body);
8202
+ log37.info({ name: parsed.name, id: created[".id"] }, "OpenVPN client created");
8203
+ return {
8204
+ content: `Created OpenVPN client "${parsed.name}" connecting to "${parsed.connectTo}".`,
8205
+ structuredContent: { action: "created", client: created }
8206
+ };
8207
+ }
8208
+ if (parsed.action === "update") {
8209
+ if (!existing) {
8210
+ throw new MikroMCPError({
8211
+ category: "NOT_FOUND" /* NOT_FOUND */,
8212
+ code: "OVPN_CLIENT_NOT_FOUND",
8213
+ message: `OpenVPN client "${parsed.name}" not found.`,
8214
+ details: { name: parsed.name },
8215
+ recoverability: {
8216
+ retryable: false,
8217
+ suggestedAction: "Verify with list_ovpn_clients.",
8218
+ alternativeTools: ["list_ovpn_clients"]
8219
+ }
8220
+ });
8221
+ }
8222
+ const updates = {};
8223
+ const diff = [];
8224
+ if (parsed.connectTo !== void 0 && existing["connect-to"] !== parsed.connectTo) {
8225
+ updates["connect-to"] = parsed.connectTo;
8226
+ diff.push({ property: "connect-to", before: existing["connect-to"] ?? null, after: parsed.connectTo });
8227
+ }
8228
+ if (parsed.port !== void 0 && existing.port !== String(parsed.port)) {
8229
+ updates.port = String(parsed.port);
8230
+ diff.push({ property: "port", before: existing.port ?? null, after: String(parsed.port) });
8231
+ }
8232
+ if (parsed.mode !== void 0 && existing.mode !== parsed.mode) {
8233
+ updates.mode = parsed.mode;
8234
+ diff.push({ property: "mode", before: existing.mode ?? null, after: parsed.mode });
8235
+ }
8236
+ if (parsed.protocol !== void 0 && existing.protocol !== parsed.protocol) {
8237
+ updates.protocol = parsed.protocol;
8238
+ diff.push({ property: "protocol", before: existing.protocol ?? null, after: parsed.protocol });
8239
+ }
8240
+ if (parsed.certificate !== void 0 && existing.certificate !== parsed.certificate) {
8241
+ updates.certificate = parsed.certificate;
8242
+ diff.push({ property: "certificate", before: existing.certificate ?? null, after: parsed.certificate });
8243
+ }
8244
+ if (parsed.user !== void 0 && existing.user !== parsed.user) {
8245
+ updates.user = parsed.user;
8246
+ diff.push({ property: "user", before: existing.user ?? null, after: parsed.user });
8247
+ }
8248
+ if (parsed.password !== void 0) {
8249
+ updates.password = parsed.password;
8250
+ }
8251
+ const hasUpdates = Object.keys(updates).length > 0;
8252
+ if (!hasUpdates) {
8253
+ return {
8254
+ content: `OpenVPN client "${parsed.name}" already matches the requested configuration. No changes made.`,
8255
+ structuredContent: { action: "no_change", client: existing }
8256
+ };
8257
+ }
8258
+ if (parsed.dryRun) {
8259
+ return {
8260
+ content: `Dry run: Would update OpenVPN client "${parsed.name}".`,
8261
+ structuredContent: { action: "dry_run", diff }
8262
+ };
8263
+ }
8264
+ await context.routerClient.update(OVPN_CLIENT_PATH, existing[".id"], updates);
8265
+ log37.info({ name: parsed.name }, "OpenVPN client updated");
8266
+ return {
8267
+ content: `Updated OpenVPN client "${parsed.name}".`,
8268
+ structuredContent: { action: "updated", name: parsed.name, diff }
8269
+ };
8270
+ }
8271
+ if (!existing) {
8272
+ return {
8273
+ content: `OpenVPN client "${parsed.name}" not found. Nothing to remove.`,
8274
+ structuredContent: { action: "not_found", name: parsed.name }
8275
+ };
8276
+ }
8277
+ if (parsed.dryRun) {
8278
+ return {
8279
+ content: `Dry run: Would remove OpenVPN client "${parsed.name}".`,
8280
+ structuredContent: {
8281
+ action: "dry_run",
8282
+ diff: [{ property: "name", before: parsed.name, after: null }]
8283
+ }
8284
+ };
8285
+ }
8286
+ await context.routerClient.remove(OVPN_CLIENT_PATH, existing[".id"]);
8287
+ log37.info({ name: parsed.name }, "OpenVPN client removed");
8288
+ return {
8289
+ content: `Removed OpenVPN client "${parsed.name}".`,
8290
+ structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
8291
+ };
8292
+ } catch (err) {
8293
+ if (err instanceof MikroMCPError) throw err;
8294
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_ovpn_client" });
8295
+ }
8296
+ }
8297
+ };
8298
+ var getOvpnServerInputSchema = z36.object({
8299
+ routerId: z36.string().describe("Target router identifier from the router registry")
8300
+ }).strict();
8301
+ function fetchOvpnServer(allRecords, routerId) {
8302
+ if (allRecords.length === 0) {
8303
+ throw new MikroMCPError({
8304
+ category: "NOT_FOUND" /* NOT_FOUND */,
8305
+ code: "OVPN_SERVER_NOT_FOUND",
8306
+ message: "OpenVPN server not found. The OpenVPN package may not be installed on this router.",
8307
+ details: { routerId },
8308
+ recoverability: {
8309
+ retryable: false,
8310
+ suggestedAction: "Install the OpenVPN package via manage_package.",
8311
+ alternativeTools: ["list_packages", "manage_package"]
8312
+ }
8313
+ });
8314
+ }
8315
+ return allRecords[0];
8316
+ }
8317
+ var getOvpnServerTool = {
8318
+ name: "get_ovpn_server",
8319
+ title: "Get OpenVPN Server",
8320
+ description: "Get the OpenVPN server configuration on a MikroTik router. Throws NOT_FOUND if the OpenVPN package is not installed.",
8321
+ inputSchema: getOvpnServerInputSchema,
8322
+ annotations: {
8323
+ readOnlyHint: true,
8324
+ destructiveHint: false,
8325
+ idempotentHint: true,
8326
+ openWorldHint: false
8327
+ },
8328
+ async handler(params, context) {
8329
+ const parsed = getOvpnServerInputSchema.parse(params);
8330
+ log37.info({ routerId: context.routerId }, "Getting OpenVPN server configuration");
8331
+ try {
8332
+ const records = await context.routerClient.get(OVPN_SERVER_PATH, {
8333
+ limit: void 0,
8334
+ offset: void 0
8335
+ });
8336
+ const server = fetchOvpnServer(records, parsed.routerId);
8337
+ return {
8338
+ content: `OpenVPN server on ${context.routerId}: enabled=${server.enabled}, port=${server.port}, protocol=${server.protocol}`,
8339
+ structuredContent: { routerId: context.routerId, server }
8340
+ };
8341
+ } catch (err) {
8342
+ if (err instanceof MikroMCPError) throw err;
8343
+ throw enrichError(err, { routerId: context.routerId, tool: "get_ovpn_server" });
8344
+ }
8345
+ }
8346
+ };
8347
+ var manageOvpnServerInputSchema = z36.object({
8348
+ routerId: z36.string().describe("Target router identifier from the router registry"),
8349
+ action: z36.enum(["enable", "disable", "set"]).describe("Action to perform"),
8350
+ port: z36.number().int().min(1).max(65535).optional().describe("Listening port (set action only)"),
8351
+ mode: z36.enum(["ip", "ethernet"]).optional().describe("Tunnel mode (set action only)"),
8352
+ protocol: z36.enum(["tcp-server", "udp"]).optional().describe("Transport protocol (set action only)"),
8353
+ certificate: z36.string().optional().describe("Server certificate name (set action only)"),
8354
+ cipher: z36.enum(["blowfish128", "aes128-cbc", "aes192-cbc", "aes256-cbc", "aes128-gcm", "aes256-gcm", "none"]).optional().describe("Encryption cipher (set action only)"),
8355
+ auth: z36.enum(["md5", "sha1", "sha256", "sha512", "null"]).optional().describe("Authentication algorithm (set action only)"),
8356
+ dryRun: z36.boolean().default(false).describe("Preview changes without applying")
8357
+ }).strict();
8358
+ var manageOvpnServerTool = {
8359
+ name: "manage_ovpn_server",
8360
+ title: "Manage OpenVPN Server",
8361
+ description: "Enable, disable, or configure the OpenVPN server on a MikroTik router. The server is a singleton \u2014 there is only one per router. Throws NOT_FOUND if the OpenVPN package is not installed. For set action, at least one configuration field must be provided.",
8362
+ inputSchema: manageOvpnServerInputSchema,
8363
+ annotations: {
8364
+ readOnlyHint: false,
8365
+ destructiveHint: true,
8366
+ idempotentHint: true,
8367
+ openWorldHint: false
8368
+ },
8369
+ snapshotPaths: [OVPN_SERVER_PATH],
8370
+ async handler(params, context) {
8371
+ const parsed = manageOvpnServerInputSchema.parse(params);
8372
+ log37.info({ routerId: context.routerId, action: parsed.action }, "Managing OpenVPN server");
8373
+ try {
8374
+ const records = await context.routerClient.get(OVPN_SERVER_PATH, {
8375
+ limit: void 0,
8376
+ offset: void 0
8377
+ });
8378
+ const server = fetchOvpnServer(records, parsed.routerId);
8379
+ const serverId = server[".id"];
8380
+ if (parsed.action === "enable" || parsed.action === "disable") {
8381
+ const desiredEnabled = parsed.action === "enable";
8382
+ const currentEnabled = server.enabled === "yes";
8383
+ if (desiredEnabled === currentEnabled) {
8384
+ return {
8385
+ content: `OpenVPN server on ${context.routerId} is already ${parsed.action}d. No changes made.`,
8386
+ structuredContent: { action: "no_change", server }
8387
+ };
8388
+ }
8389
+ if (parsed.dryRun) {
8390
+ return {
8391
+ content: `Dry run: Would ${parsed.action} OpenVPN server on ${context.routerId}.`,
8392
+ structuredContent: {
8393
+ action: "dry_run",
8394
+ diff: [{ property: "enabled", before: server.enabled, after: desiredEnabled ? "yes" : "no" }]
8395
+ }
8396
+ };
8397
+ }
8398
+ await context.routerClient.update(OVPN_SERVER_PATH, serverId, {
8399
+ enabled: desiredEnabled ? "yes" : "no"
8400
+ });
8401
+ log37.info({ routerId: context.routerId, action: parsed.action }, "OpenVPN server updated");
8402
+ return {
8403
+ content: `OpenVPN server on ${context.routerId} ${parsed.action}d.`,
8404
+ structuredContent: { action: parsed.action === "enable" ? "enabled" : "disabled", server }
8405
+ };
8406
+ }
8407
+ const hasFields = parsed.port !== void 0 || parsed.mode !== void 0 || parsed.protocol !== void 0 || parsed.certificate !== void 0 || parsed.cipher !== void 0 || parsed.auth !== void 0;
8408
+ if (!hasFields) {
8409
+ throw new MikroMCPError({
8410
+ category: "VALIDATION" /* VALIDATION */,
8411
+ code: "OVPN_SERVER_NO_FIELDS",
8412
+ message: "At least one field must be provided for action set.",
8413
+ details: {},
8414
+ recoverability: {
8415
+ retryable: false,
8416
+ suggestedAction: "Provide at least one of: port, mode, protocol, certificate, cipher, auth.",
8417
+ alternativeTools: []
8418
+ }
8419
+ });
8420
+ }
8421
+ const updates = {};
8422
+ const diff = [];
8423
+ if (parsed.port !== void 0 && server.port !== String(parsed.port)) {
8424
+ updates.port = String(parsed.port);
8425
+ diff.push({ property: "port", before: server.port ?? null, after: String(parsed.port) });
8426
+ }
8427
+ if (parsed.mode !== void 0 && server.mode !== parsed.mode) {
8428
+ updates.mode = parsed.mode;
8429
+ diff.push({ property: "mode", before: server.mode ?? null, after: parsed.mode });
8430
+ }
8431
+ if (parsed.protocol !== void 0 && server.protocol !== parsed.protocol) {
8432
+ updates.protocol = parsed.protocol;
8433
+ diff.push({ property: "protocol", before: server.protocol ?? null, after: parsed.protocol });
8434
+ }
8435
+ if (parsed.certificate !== void 0 && server.certificate !== parsed.certificate) {
8436
+ updates.certificate = parsed.certificate;
8437
+ diff.push({ property: "certificate", before: server.certificate ?? null, after: parsed.certificate });
8438
+ }
8439
+ if (parsed.cipher !== void 0 && server.cipher !== parsed.cipher) {
8440
+ updates.cipher = parsed.cipher;
8441
+ diff.push({ property: "cipher", before: server.cipher ?? null, after: parsed.cipher });
8442
+ }
8443
+ if (parsed.auth !== void 0 && server.auth !== parsed.auth) {
8444
+ updates.auth = parsed.auth;
8445
+ diff.push({ property: "auth", before: server.auth ?? null, after: parsed.auth });
8446
+ }
8447
+ if (Object.keys(updates).length === 0) {
8448
+ return {
8449
+ content: `OpenVPN server on ${context.routerId} already matches the requested configuration. No changes made.`,
8450
+ structuredContent: { action: "no_change", server }
8451
+ };
8452
+ }
8453
+ if (parsed.dryRun) {
8454
+ return {
8455
+ content: `Dry run: Would update OpenVPN server on ${context.routerId}.`,
8456
+ structuredContent: { action: "dry_run", diff }
8457
+ };
8458
+ }
8459
+ await context.routerClient.update(OVPN_SERVER_PATH, serverId, updates);
8460
+ log37.info({ routerId: context.routerId }, "OpenVPN server configuration updated");
8461
+ return {
8462
+ content: `Updated OpenVPN server configuration on ${context.routerId}.`,
8463
+ structuredContent: { action: "updated", diff }
8464
+ };
8465
+ } catch (err) {
8466
+ if (err instanceof MikroMCPError) throw err;
8467
+ throw enrichError(err, { routerId: context.routerId, tool: "manage_ovpn_server" });
8468
+ }
8469
+ }
8470
+ };
8471
+ var ovpnTools = [
8472
+ listOvpnClientsTool,
8473
+ manageOvpnClientTool,
8474
+ getOvpnServerTool,
8475
+ manageOvpnServerTool
8476
+ ];
8477
+
8478
+ // src/domain/tools/fleet-tools.ts
8479
+ import { z as z37 } from "zod";
7780
8480
 
7781
8481
  // src/middleware/authz.ts
7782
8482
  function matchesPattern(name, pattern) {
@@ -7944,7 +8644,7 @@ var SshClient = class {
7944
8644
  // src/adapter/ftp-client.ts
7945
8645
  import { Client as Client2 } from "basic-ftp";
7946
8646
  import { Readable } from "stream";
7947
- var log36 = createLogger("ftp-client");
8647
+ var log38 = createLogger("ftp-client");
7948
8648
  var FtpClient = class {
7949
8649
  #host;
7950
8650
  #port;
@@ -7957,7 +8657,7 @@ var FtpClient = class {
7957
8657
  this.#password = password;
7958
8658
  }
7959
8659
  async upload(remoteName, content) {
7960
- log36.info({ host: this.#host, remoteName }, "Uploading file via FTP");
8660
+ log38.info({ host: this.#host, remoteName }, "Uploading file via FTP");
7961
8661
  const client = new Client2();
7962
8662
  try {
7963
8663
  await client.access({
@@ -7967,7 +8667,7 @@ var FtpClient = class {
7967
8667
  password: this.#password
7968
8668
  });
7969
8669
  await client.uploadFrom(Readable.from(Buffer.from(content, "utf-8")), remoteName);
7970
- log36.info({ host: this.#host, remoteName }, "FTP upload complete");
8670
+ log38.info({ host: this.#host, remoteName }, "FTP upload complete");
7971
8671
  } finally {
7972
8672
  client.close();
7973
8673
  }
@@ -8075,17 +8775,17 @@ function checkFleetConfirmation(args, secret) {
8075
8775
  }
8076
8776
 
8077
8777
  // src/domain/tools/fleet-tools.ts
8078
- var log37 = createLogger("fleet-tools");
8079
- var checkHealthInputSchema = z35.object({
8080
- routerId: z35.string().describe("Target router to health-check")
8778
+ var log39 = createLogger("fleet-tools");
8779
+ var checkHealthInputSchema = z37.object({
8780
+ routerId: z37.string().describe("Target router to health-check")
8081
8781
  }).strict();
8082
- var bulkExecuteInputSchema = z35.object({
8083
- toolName: z35.string().describe("Name of the tool to fan out (must be a single-router tool)"),
8084
- routerIds: z35.array(z35.string()).optional().describe("Explicit list of router IDs to target"),
8085
- tags: z35.array(z35.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
8086
- params: z35.record(z35.string(), z35.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
8087
- concurrency: z35.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
8088
- confirmationToken: z35.string().optional().describe(
8782
+ var bulkExecuteInputSchema = z37.object({
8783
+ toolName: z37.string().describe("Name of the tool to fan out (must be a single-router tool)"),
8784
+ routerIds: z37.array(z37.string()).optional().describe("Explicit list of router IDs to target"),
8785
+ tags: z37.array(z37.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
8786
+ params: z37.record(z37.string(), z37.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
8787
+ concurrency: z37.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
8788
+ confirmationToken: z37.string().optional().describe(
8089
8789
  "Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
8090
8790
  )
8091
8791
  }).strict();
@@ -8104,7 +8804,7 @@ function createFleetTools(baseTools2) {
8104
8804
  },
8105
8805
  async handler(params, context) {
8106
8806
  checkHealthInputSchema.parse(params);
8107
- log37.info({ routerId: context.routerId }, "Checking router health");
8807
+ log39.info({ routerId: context.routerId }, "Checking router health");
8108
8808
  const startMs = Date.now();
8109
8809
  try {
8110
8810
  const resources = await context.routerClient.get("system/resource");
@@ -8166,7 +8866,7 @@ function createFleetTools(baseTools2) {
8166
8866
  }
8167
8867
  });
8168
8868
  }
8169
- log37.info(
8869
+ log39.info(
8170
8870
  { toolName: parsed.toolName, concurrency: parsed.concurrency },
8171
8871
  "bulk_execute invoked"
8172
8872
  );
@@ -8213,7 +8913,7 @@ function createFleetTools(baseTools2) {
8213
8913
  try {
8214
8914
  resolved.push(context.routerRegistry.getRouter(id));
8215
8915
  } catch {
8216
- log37.warn({ routerId: id }, "bulk_execute: router not found");
8916
+ log39.warn({ routerId: id }, "bulk_execute: router not found");
8217
8917
  preErrors.push({
8218
8918
  routerId: id,
8219
8919
  status: "error",
@@ -8391,7 +9091,9 @@ var baseTools = [
8391
9091
  ...networkServicesTools,
8392
9092
  ...vlanTools,
8393
9093
  ...dhcpClientTools,
8394
- ...ipServiceTools
9094
+ ...ipServiceTools,
9095
+ ...pppoeTools,
9096
+ ...ovpnTools
8395
9097
  ];
8396
9098
  var allTools = [
8397
9099
  ...baseTools,
@@ -8790,7 +9492,7 @@ var CircuitBreaker = class {
8790
9492
  };
8791
9493
 
8792
9494
  // src/mcp/tool-executor.ts
8793
- var log38 = createLogger("tool-executor");
9495
+ var log40 = createLogger("tool-executor");
8794
9496
  async function executeToolCall(tool, args, deps) {
8795
9497
  const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
8796
9498
  const correlationId = nanoid4();
@@ -8886,9 +9588,9 @@ async function executeToolCall(tool, args, deps) {
8886
9588
  try {
8887
9589
  const meta = await takeSnapshot(toolContext.routerClient, routerId, path, config.snapshotDir);
8888
9590
  snapshotIds.push(meta.id);
8889
- log38.debug({ snapshotId: meta.id, path }, "Snapshot taken");
9591
+ log40.debug({ snapshotId: meta.id, path }, "Snapshot taken");
8890
9592
  } catch (err) {
8891
- log38.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
9593
+ log40.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
8892
9594
  }
8893
9595
  }
8894
9596
  }
@@ -8930,7 +9632,7 @@ async function executeToolCall(tool, args, deps) {
8930
9632
  durationMs: Date.now() - startMs
8931
9633
  }, config.auditLogPath);
8932
9634
  }
8933
- log38.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
9635
+ log40.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
8934
9636
  recordToolCall(tool.name, "success");
8935
9637
  return formatToolResult(result);
8936
9638
  } catch (err) {
@@ -8939,7 +9641,7 @@ async function executeToolCall(tool, args, deps) {
8939
9641
  const failedRouterId = args.routerId;
8940
9642
  if (failedRouterId) {
8941
9643
  pool.removeClient(failedRouterId);
8942
- log38.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
9644
+ log40.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
8943
9645
  }
8944
9646
  }
8945
9647
  if (journalId) {
@@ -8969,7 +9671,7 @@ async function executeToolCall(tool, args, deps) {
8969
9671
  durationMs: Date.now() - startMs
8970
9672
  }, config.auditLogPath);
8971
9673
  }
8972
- log38.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
9674
+ log40.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
8973
9675
  recordToolCall(tool.name, "error");
8974
9676
  return formatError(error);
8975
9677
  }
@@ -8977,12 +9679,12 @@ async function executeToolCall(tool, args, deps) {
8977
9679
  }
8978
9680
 
8979
9681
  // src/mcp/tool-registry.ts
8980
- var log39 = createLogger("tool-registry");
9682
+ var log41 = createLogger("tool-registry");
8981
9683
  function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
8982
9684
  const deps = { registry, pool, circuitBreakers, config, identityRegistry };
8983
9685
  for (const tool of allTools) {
8984
9686
  const registrationSchema = tool.annotations.destructiveHint ? tool.inputSchema.extend({
8985
- confirmationToken: z36.string().optional().describe(
9687
+ confirmationToken: z38.string().optional().describe(
8986
9688
  "Token from a prior APPROVAL_REQUIRED response. Re-submit the identical call with this token to confirm the destructive action."
8987
9689
  )
8988
9690
  }) : tool.inputSchema;
@@ -8996,56 +9698,56 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
8996
9698
  },
8997
9699
  (args) => executeToolCall(tool, args, deps)
8998
9700
  );
8999
- log39.debug({ tool: tool.name }, "Registered tool");
9701
+ log41.debug({ tool: tool.name }, "Registered tool");
9000
9702
  }
9001
- log39.info({ count: allTools.length }, "All tools registered");
9703
+ log41.info({ count: allTools.length }, "All tools registered");
9002
9704
  }
9003
9705
 
9004
9706
  // src/config/router-registry.ts
9005
9707
  import { readFileSync as readFileSync2, existsSync } from "fs";
9006
9708
  import { parse } from "yaml";
9007
- import { z as z37 } from "zod";
9008
- var log40 = createLogger("router-registry");
9009
- var RouterConfigSchema = z37.object({
9010
- host: z37.string().min(1, "host is required"),
9011
- port: z37.number().int().min(1).max(65535),
9012
- tls: z37.object({
9013
- enabled: z37.boolean(),
9014
- rejectUnauthorized: z37.boolean(),
9015
- ca: z37.string().optional(),
9016
- fingerprint: z37.string().optional()
9709
+ import { z as z39 } from "zod";
9710
+ var log42 = createLogger("router-registry");
9711
+ var RouterConfigSchema = z39.object({
9712
+ host: z39.string().min(1, "host is required"),
9713
+ port: z39.number().int().min(1).max(65535),
9714
+ tls: z39.object({
9715
+ enabled: z39.boolean(),
9716
+ rejectUnauthorized: z39.boolean(),
9717
+ ca: z39.string().optional(),
9718
+ fingerprint: z39.string().optional()
9017
9719
  }).strict(),
9018
- credentials: z37.object({
9019
- source: z37.enum(["env", "vault"]),
9020
- envPrefix: z37.string().optional(),
9021
- vaultPath: z37.string().optional()
9720
+ credentials: z39.object({
9721
+ source: z39.enum(["env", "vault"]),
9722
+ envPrefix: z39.string().optional(),
9723
+ vaultPath: z39.string().optional()
9022
9724
  }).strict(),
9023
- tags: z37.array(z37.string()).default([]),
9024
- rosVersion: z37.string().min(1),
9025
- sshPort: z37.number().int().min(1).max(65535).optional(),
9026
- sshFingerprint: z37.string().optional(),
9027
- cmdAllow: z37.array(z37.string()).optional(),
9028
- cmdDeny: z37.array(z37.string()).optional(),
9029
- maintenanceWindows: z37.array(
9030
- z37.object({
9031
- days: z37.array(
9032
- z37.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
9725
+ tags: z39.array(z39.string()).default([]),
9726
+ rosVersion: z39.string().min(1),
9727
+ sshPort: z39.number().int().min(1).max(65535).optional(),
9728
+ sshFingerprint: z39.string().optional(),
9729
+ cmdAllow: z39.array(z39.string()).optional(),
9730
+ cmdDeny: z39.array(z39.string()).optional(),
9731
+ maintenanceWindows: z39.array(
9732
+ z39.object({
9733
+ days: z39.array(
9734
+ z39.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
9033
9735
  ),
9034
- startTime: z37.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
9035
- endTime: z37.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
9036
- timezone: z37.string().min(1)
9736
+ startTime: z39.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
9737
+ endTime: z39.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
9738
+ timezone: z39.string().min(1)
9037
9739
  }).strict()
9038
9740
  ).optional()
9039
9741
  }).strict();
9040
- var ConfigFileSchema = z37.object({
9041
- routers: z37.record(z37.string(), RouterConfigSchema)
9742
+ var ConfigFileSchema = z39.object({
9743
+ routers: z39.record(z39.string(), RouterConfigSchema)
9042
9744
  }).strict();
9043
9745
  var RouterRegistry = class {
9044
9746
  routers;
9045
9747
  constructor(configPath) {
9046
9748
  this.routers = /* @__PURE__ */ new Map();
9047
9749
  if (!existsSync(configPath)) {
9048
- log40.warn({ configPath }, "Router config file not found; starting with empty registry");
9750
+ log42.warn({ configPath }, "Router config file not found; starting with empty registry");
9049
9751
  return;
9050
9752
  }
9051
9753
  const raw = readFileSync2(configPath, "utf-8");
@@ -9059,14 +9761,14 @@ ${issues}`);
9059
9761
  for (const [id, config] of Object.entries(result.data.routers)) {
9060
9762
  const routerConfig = { ...config, id };
9061
9763
  if (routerConfig.tls.enabled && !routerConfig.tls.rejectUnauthorized) {
9062
- log40.warn(
9764
+ log42.warn(
9063
9765
  { routerId: id },
9064
9766
  "TLS certificate validation is DISABLED (rejectUnauthorized=false). Set tls.fingerprint to pin the server certificate instead."
9065
9767
  );
9066
9768
  }
9067
9769
  this.routers.set(id, routerConfig);
9068
9770
  }
9069
- log40.info({ count: this.routers.size }, "Loaded routers from config");
9771
+ log42.info({ count: this.routers.size }, "Loaded routers from config");
9070
9772
  }
9071
9773
  getRouter(id) {
9072
9774
  const router = this.routers.get(id);
@@ -9351,7 +10053,7 @@ var ConnectionPool = class {
9351
10053
  };
9352
10054
 
9353
10055
  // src/version.ts
9354
- var VERSION = "1.2.0";
10056
+ var VERSION = "1.3.0";
9355
10057
 
9356
10058
  // src/mcp/server.ts
9357
10059
  function createServerFactory(config, identityRegistry) {
@@ -9381,7 +10083,7 @@ import { randomUUID } from "crypto";
9381
10083
  import { createServer } from "http";
9382
10084
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9383
10085
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
9384
- var log41 = createLogger("transport-http");
10086
+ var log43 = createLogger("transport-http");
9385
10087
  var BodyTooLargeError = class extends Error {
9386
10088
  constructor() {
9387
10089
  super("Request body exceeds maximum allowed size");
@@ -9473,7 +10175,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
9473
10175
  return;
9474
10176
  }
9475
10177
  if (!checkRateLimit(ip)) {
9476
- log41.warn({ ip }, "Rate limit exceeded");
10178
+ log43.warn({ ip }, "Rate limit exceeded");
9477
10179
  res.writeHead(429, { "Content-Type": "application/json" });
9478
10180
  res.end(JSON.stringify({ error: "Too many requests. Retry after 60 seconds." }));
9479
10181
  return;
@@ -9514,7 +10216,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
9514
10216
  }
9515
10217
  return;
9516
10218
  }
9517
- log41.error({ err }, "HTTP transport error");
10219
+ log43.error({ err }, "HTTP transport error");
9518
10220
  if (!res.headersSent) {
9519
10221
  res.writeHead(500, { "Content-Type": "application/json" });
9520
10222
  res.end(JSON.stringify({ error: "Internal server error" }));
@@ -9524,7 +10226,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
9524
10226
  httpServer.on("close", () => clearInterval(sweepTimer));
9525
10227
  await new Promise((resolve, reject) => {
9526
10228
  httpServer.listen(port, bindHost, () => {
9527
- log41.info(
10229
+ log43.info(
9528
10230
  { port, bindHost },
9529
10231
  "HTTP transport listening (POST /mcp for Streamable HTTP, GET /sse for legacy SSE)"
9530
10232
  );
@@ -9548,13 +10250,13 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
9548
10250
  sessionIdGenerator: () => randomUUID(),
9549
10251
  onsessioninitialized: (id) => {
9550
10252
  streamableSessions.set(id, transport2);
9551
- log41.info({ sessionId: id }, "Streamable HTTP session created");
10253
+ log43.info({ sessionId: id }, "Streamable HTTP session created");
9552
10254
  }
9553
10255
  });
9554
10256
  transport2.onclose = () => {
9555
10257
  if (transport2.sessionId) {
9556
10258
  streamableSessions.delete(transport2.sessionId);
9557
- log41.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
10259
+ log43.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
9558
10260
  }
9559
10261
  };
9560
10262
  await makeServer().connect(transport2);
@@ -9571,7 +10273,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
9571
10273
  sseTransport.onclose = () => {
9572
10274
  sseTransports.delete(sseTransport.sessionId);
9573
10275
  };
9574
- log41.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
10276
+ log43.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
9575
10277
  await sseTransport.start();
9576
10278
  return;
9577
10279
  }
@@ -9592,23 +10294,23 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
9592
10294
  // src/config/identity-registry.ts
9593
10295
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
9594
10296
  import { parse as parse2 } from "yaml";
9595
- import { z as z38 } from "zod";
10297
+ import { z as z40 } from "zod";
9596
10298
  import bcrypt from "bcryptjs";
9597
- var log42 = createLogger("identity-registry");
9598
- var IdentityConfigSchema = z38.object({
9599
- token: z38.string().min(1, "token hash is required"),
9600
- role: z38.enum(["readonly", "operator", "admin", "superadmin"]),
9601
- allowedRouters: z38.array(z38.string()).default([]),
9602
- allowedToolPatterns: z38.array(z38.string()).default([])
10299
+ var log44 = createLogger("identity-registry");
10300
+ var IdentityConfigSchema = z40.object({
10301
+ token: z40.string().min(1, "token hash is required"),
10302
+ role: z40.enum(["readonly", "operator", "admin", "superadmin"]),
10303
+ allowedRouters: z40.array(z40.string()).default([]),
10304
+ allowedToolPatterns: z40.array(z40.string()).default([])
9603
10305
  }).strict();
9604
- var ConfigFileSchema2 = z38.object({
9605
- identities: z38.record(z38.string(), IdentityConfigSchema)
10306
+ var ConfigFileSchema2 = z40.object({
10307
+ identities: z40.record(z40.string(), IdentityConfigSchema)
9606
10308
  }).strict();
9607
10309
  var IdentityRegistry = class {
9608
10310
  identities = [];
9609
10311
  constructor(configPath) {
9610
10312
  if (!existsSync2(configPath)) {
9611
- log42.warn(
10313
+ log44.warn(
9612
10314
  { configPath },
9613
10315
  "Identity config file not found; starting with empty registry (stdio callers do not require it)"
9614
10316
  );
@@ -9631,7 +10333,7 @@ ${issues}`);
9631
10333
  allowedToolPatterns: config.allowedToolPatterns
9632
10334
  });
9633
10335
  }
9634
- log42.info({ count: this.identities.length }, "Loaded identities from config");
10336
+ log44.info({ count: this.identities.length }, "Loaded identities from config");
9635
10337
  }
9636
10338
  getIdentities() {
9637
10339
  return this.identities.map(({ id, role, allowedRouters, allowedToolPatterns }) => ({
@@ -9660,7 +10362,7 @@ ${issues}`);
9660
10362
  // src/domain/snapshot/retention.ts
9661
10363
  import { readdir, stat, unlink } from "fs/promises";
9662
10364
  import { join as join3 } from "path";
9663
- var log43 = createLogger("snapshot-retention");
10365
+ var log45 = createLogger("snapshot-retention");
9664
10366
  async function pruneSnapshots(snapshotDir, maxAgeDays) {
9665
10367
  const cutoffMs = Date.now() - maxAgeDays * 864e5;
9666
10368
  let removed = 0;
@@ -9687,21 +10389,21 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
9687
10389
  removed++;
9688
10390
  }
9689
10391
  } catch (err) {
9690
- log43.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
10392
+ log45.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
9691
10393
  }
9692
10394
  }
9693
10395
  }
9694
10396
  if (removed > 0) {
9695
- log43.info({ removed, maxAgeDays }, "Pruned expired snapshots");
10397
+ log45.info({ removed, maxAgeDays }, "Pruned expired snapshots");
9696
10398
  }
9697
10399
  return removed;
9698
10400
  }
9699
10401
 
9700
10402
  // src/cli/serve.ts
9701
- var log44 = createLogger("main");
10403
+ var log46 = createLogger("main");
9702
10404
  async function runServe() {
9703
10405
  const config = loadAppConfig();
9704
- log44.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
10406
+ log46.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
9705
10407
  void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
9706
10408
  const identityRegistry = new IdentityRegistry(config.identitiesPath);
9707
10409
  if (config.transport === "http") {
@@ -9709,7 +10411,7 @@ async function runServe() {
9709
10411
  (i) => i.role === "readonly" || i.role === "operator"
9710
10412
  );
9711
10413
  if (hasLimitedRoles && !config.confirmationSecret) {
9712
- log44.error(
10414
+ log46.error(
9713
10415
  "MIKROMCP_CONFIRMATION_SECRET is required in HTTP mode when identities with role readonly or operator are configured"
9714
10416
  );
9715
10417
  process.exit(1);
@@ -9719,7 +10421,7 @@ async function runServe() {
9719
10421
  if (config.transport === "stdio") {
9720
10422
  const stdioIdentity = getStdioIdentity(config.stdioIdentity, identityRegistry);
9721
10423
  await withIdentity(stdioIdentity, () => connectStdio(makeServer()));
9722
- log44.info("MikroMCP server running via stdio");
10424
+ log46.info("MikroMCP server running via stdio");
9723
10425
  } else if (config.transport === "http") {
9724
10426
  await connectHttp(
9725
10427
  makeServer,
@@ -9731,13 +10433,13 @@ async function runServe() {
9731
10433
  },
9732
10434
  identityRegistry
9733
10435
  );
9734
- log44.info(
10436
+ log46.info(
9735
10437
  { port: config.port, bindHost: config.bindHost },
9736
10438
  "MikroMCP server running via HTTP/SSE"
9737
10439
  );
9738
10440
  }
9739
10441
  const shutdown = () => {
9740
- log44.info("Shutting down MikroMCP server");
10442
+ log46.info("Shutting down MikroMCP server");
9741
10443
  pool.closeAll();
9742
10444
  process.exit(0);
9743
10445
  };
@@ -9750,7 +10452,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
9750
10452
  import { homedir as homedir2 } from "os";
9751
10453
  import * as net from "net";
9752
10454
  import chalk from "chalk";
9753
- var localVersion = "1.2.0";
10455
+ var localVersion = "1.3.0";
9754
10456
  var results = [];
9755
10457
  function ok(msg) {
9756
10458
  console.log(chalk.green("\u2705 ") + msg);
@@ -10402,7 +11104,7 @@ async function runInit() {
10402
11104
  }
10403
11105
 
10404
11106
  // src/cli/index.ts
10405
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.2.0");
11107
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.3.0");
10406
11108
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
10407
11109
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
10408
11110
  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.2.0",
3
+ "version": "1.3.0",
4
4
  "mcpName": "io.github.AliKarami/mikromcp",
5
5
  "description": "Production-grade MCP server for MikroTik RouterOS",
6
6
  "type": "module",