mikromcp 1.14.1 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/main.js +263 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
|
|
14
14
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
|
|
15
|
-
[](package.json)
|
|
16
16
|
[](LICENSE)
|
|
17
17
|
[](package.json)
|
|
18
18
|
[](https://help.mikrotik.com/docs/display/ROS/REST+API)
|
|
19
19
|
[](https://modelcontextprotocol.io)
|
|
20
|
-
[](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools)
|
|
21
21
|
[](https://glama.ai/mcp/servers/AliKarami/MikroMCP)
|
|
22
22
|
|
|
23
23
|
MikroMCP exists because raw router CLI access is the wrong abstraction for AI agents. RouterOS is powerful, but asking an LLM to improvise shell commands against production network gear is risky. MikroMCP gives agents a controlled tool surface: strict schemas, idempotent writes, dry-run previews, per-router circuit breakers, retry policies, RBAC, audit logs, snapshots, and rollback-aware change workflows.
|
|
@@ -54,7 +54,7 @@ That's the whole setup for a single-router stdio deployment. For standalone bina
|
|
|
54
54
|
| 🤖 **AI-agent fit** | Human-readable responses plus structured JSON content for reasoning, chaining, and automation; server advertises an `instructions` string on MCP initialize so clients self-configure; optional [`routerId` resolved via `MIKROMCP_DEFAULT_ROUTER`](https://github.com/AliKarami/MikroMCP/wiki/Configuration) for single-router setups; [usage skill](docs/wiki/Using-the-Skill.md) for safe, guided tool use in Claude Code |
|
|
55
55
|
| 🧩 **MCP compatibility** | stdio for desktop clients, Streamable HTTP and legacy SSE for remote or service-style clients |
|
|
56
56
|
|
|
57
|
-
**
|
|
57
|
+
**133 typed tools** in total — browse the full catalog with parameters, defaults, and copy-paste example prompts in **[Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools)**.
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
@@ -140,7 +140,7 @@ AI assistants can use MikroMCP to:
|
|
|
140
140
|
|
|
141
141
|
### What is MikroMCP?
|
|
142
142
|
|
|
143
|
-
MikroMCP is an open-source [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that exposes MikroTik RouterOS as
|
|
143
|
+
MikroMCP is an open-source [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that exposes MikroTik RouterOS as 133 typed, auditable tools — letting AI assistants inspect, diagnose, and safely operate routers in natural language instead of improvising CLI commands.
|
|
144
144
|
|
|
145
145
|
### MikroMCP vs RouterOS API
|
|
146
146
|
|
|
@@ -189,7 +189,7 @@ The README stays intentionally short. Everything below is documented in depth in
|
|
|
189
189
|
| [Connecting to Claude Desktop](https://github.com/AliKarami/MikroMCP/wiki/Connecting-to-Claude-Desktop) | Register MikroMCP in Claude Desktop |
|
|
190
190
|
| [Connecting to AI Assistants](https://github.com/AliKarami/MikroMCP/wiki/Connecting-to-AI-Assistants) | Claude Code, Cursor, Codex, HTTP/Docker/systemd |
|
|
191
191
|
| [Using the Skill](docs/wiki/Using-the-Skill.md) | Install the MikroMCP usage skill so your assistant drives the tools safely |
|
|
192
|
-
| [Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools) | All
|
|
192
|
+
| [Available Tools](https://github.com/AliKarami/MikroMCP/wiki/Available-Tools) | All 133 tools — parameters and example prompts |
|
|
193
193
|
| [Architecture](https://github.com/AliKarami/MikroMCP/wiki/Architecture) | System layers, request pipeline, auth model |
|
|
194
194
|
| [Error Handling](https://github.com/AliKarami/MikroMCP/wiki/Error-Handling) | Error categories, retry engine, circuit breaker |
|
|
195
195
|
| [Security](https://github.com/AliKarami/MikroMCP/wiki/Security) | Threat model, hardening checklist, vulnerability reporting |
|
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().
|
|
2895
|
+
name: z11.string().min(1).describe(
|
|
2896
|
+
"Bridge interface name, taken verbatim for remove; create accepts letters, digits, dot, hyphen, underscore, up to 64 chars"
|
|
2897
|
+
),
|
|
2869
2898
|
comment: z11.string().max(255).optional().describe("Optional comment"),
|
|
2870
2899
|
disabled: z11.boolean().default(false).describe("Whether the bridge should be disabled"),
|
|
2871
2900
|
dryRun
|
|
@@ -2893,6 +2922,11 @@ var manageBridgeTool = {
|
|
|
2893
2922
|
filter: { name: parsed.name }
|
|
2894
2923
|
});
|
|
2895
2924
|
if (parsed.action === "create") {
|
|
2925
|
+
assertNewInterfaceName(parsed.name, {
|
|
2926
|
+
kind: "Bridge",
|
|
2927
|
+
action: "create",
|
|
2928
|
+
code: "BRIDGE_NAME_INVALID"
|
|
2929
|
+
});
|
|
2896
2930
|
if (existing.length > 0) {
|
|
2897
2931
|
return {
|
|
2898
2932
|
content: `Bridge "${parsed.name}" already exists. No changes made.`,
|
|
@@ -8117,6 +8151,7 @@ function recordAttempt(args) {
|
|
|
8117
8151
|
routerId: args.routerId,
|
|
8118
8152
|
params: redactParams(args.params),
|
|
8119
8153
|
snapshotIds: args.snapshotIds,
|
|
8154
|
+
...args.dryRun ? { dryRun: true } : {},
|
|
8120
8155
|
phase: "attempt"
|
|
8121
8156
|
});
|
|
8122
8157
|
return id;
|
|
@@ -9011,6 +9046,47 @@ function unionVlanBlob(currentWire, targetWire) {
|
|
|
9011
9046
|
return dumpBlob([...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row));
|
|
9012
9047
|
}
|
|
9013
9048
|
|
|
9049
|
+
// src/middleware/authz.ts
|
|
9050
|
+
function matchesPattern(name, pattern) {
|
|
9051
|
+
return globMatch(pattern, name);
|
|
9052
|
+
}
|
|
9053
|
+
function checkAuthz(identity, toolName, routerId2) {
|
|
9054
|
+
if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
|
|
9055
|
+
throw new MikroMCPError({
|
|
9056
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
9057
|
+
code: "ROUTER_NOT_ALLOWED",
|
|
9058
|
+
message: `Identity "${identity.id}" is not allowed to access router "${routerId2}".`,
|
|
9059
|
+
details: { identityId: identity.id, routerId: routerId2, allowedRouters: identity.allowedRouters },
|
|
9060
|
+
recoverability: {
|
|
9061
|
+
retryable: false,
|
|
9062
|
+
suggestedAction: `Add "${routerId2}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
|
|
9063
|
+
}
|
|
9064
|
+
});
|
|
9065
|
+
}
|
|
9066
|
+
checkToolAllowed(identity, toolName);
|
|
9067
|
+
}
|
|
9068
|
+
function checkToolAllowed(identity, toolName) {
|
|
9069
|
+
if (identity.allowedToolPatterns.length > 0) {
|
|
9070
|
+
const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
|
|
9071
|
+
if (!allowed) {
|
|
9072
|
+
throw new MikroMCPError({
|
|
9073
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
9074
|
+
code: "TOOL_NOT_ALLOWED",
|
|
9075
|
+
message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
|
|
9076
|
+
details: {
|
|
9077
|
+
identityId: identity.id,
|
|
9078
|
+
toolName,
|
|
9079
|
+
allowedToolPatterns: identity.allowedToolPatterns
|
|
9080
|
+
},
|
|
9081
|
+
recoverability: {
|
|
9082
|
+
retryable: false,
|
|
9083
|
+
suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
|
|
9084
|
+
}
|
|
9085
|
+
});
|
|
9086
|
+
}
|
|
9087
|
+
}
|
|
9088
|
+
}
|
|
9089
|
+
|
|
9014
9090
|
// src/domain/tools/change-management-tools.ts
|
|
9015
9091
|
var log24 = createLogger("change-management");
|
|
9016
9092
|
var stepSchema = z25.object({
|
|
@@ -9026,6 +9102,116 @@ var applyPlanInputSchema = z25.object({
|
|
|
9026
9102
|
steps: z25.array(stepSchema).min(1).max(10).describe("Ordered list of write operations to apply in sequence"),
|
|
9027
9103
|
confirmationToken: z25.string().optional()
|
|
9028
9104
|
}).strict();
|
|
9105
|
+
function journalEntryWasDryRun(entry) {
|
|
9106
|
+
const params = entry.params ?? {};
|
|
9107
|
+
return entry.dryRun === true || params.dryRun === true;
|
|
9108
|
+
}
|
|
9109
|
+
var listJournalInputSchema = z25.object({
|
|
9110
|
+
routerId: z25.string().optional().describe("Only entries for this router id"),
|
|
9111
|
+
tool: z25.string().optional().describe("Only entries written by this tool"),
|
|
9112
|
+
limit: z25.number().int().min(1).max(500).default(20).describe("Max entries to return")
|
|
9113
|
+
}).strict();
|
|
9114
|
+
var listJournalTool = {
|
|
9115
|
+
name: "list_journal",
|
|
9116
|
+
title: "List Write Journal",
|
|
9117
|
+
description: "List recent entries from the server's write journal (write-journal.ndjson): every journaled write with its journalId, timestamp, tool, router, parameters (as redacted at write time), snapshot ids and outcome \u2014 newest first. Read-only. Use it to recover a lost journalId for rollback_change, or to audit what was written when. Only entries with rollbackable: true can be rolled back \u2014 dry runs and writes without snapshots (run_command, plan_changes) cannot.",
|
|
9118
|
+
inputSchema: listJournalInputSchema,
|
|
9119
|
+
annotations: {
|
|
9120
|
+
readOnlyHint: true,
|
|
9121
|
+
destructiveHint: false,
|
|
9122
|
+
idempotentHint: true,
|
|
9123
|
+
openWorldHint: false
|
|
9124
|
+
},
|
|
9125
|
+
skipRouterContext: true,
|
|
9126
|
+
async handler(params, context) {
|
|
9127
|
+
const parsed = listJournalInputSchema.parse(params);
|
|
9128
|
+
checkToolAllowed(context.identity, "list_journal");
|
|
9129
|
+
const journalPath = context.appConfig.journalPath;
|
|
9130
|
+
if (!journalPath) {
|
|
9131
|
+
return {
|
|
9132
|
+
content: "Write journal is disabled (no journal path configured on this server).",
|
|
9133
|
+
structuredContent: { journalEnabled: false, entries: [], total: 0, returned: 0 }
|
|
9134
|
+
};
|
|
9135
|
+
}
|
|
9136
|
+
let raw;
|
|
9137
|
+
try {
|
|
9138
|
+
raw = readFileSync2(journalPath, "utf-8");
|
|
9139
|
+
} catch (err) {
|
|
9140
|
+
if (err.code === "ENOENT") {
|
|
9141
|
+
raw = "";
|
|
9142
|
+
} else {
|
|
9143
|
+
throw new MikroMCPError({
|
|
9144
|
+
category: "INTERNAL" /* INTERNAL */,
|
|
9145
|
+
code: "JOURNAL_READ_FAILED",
|
|
9146
|
+
message: `Could not read the write journal: ${err.message}`,
|
|
9147
|
+
details: { journalPath },
|
|
9148
|
+
recoverability: {
|
|
9149
|
+
retryable: false,
|
|
9150
|
+
suggestedAction: "Check the file's permissions under the MikroMCP data directory."
|
|
9151
|
+
}
|
|
9152
|
+
});
|
|
9153
|
+
}
|
|
9154
|
+
}
|
|
9155
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
9156
|
+
const attempts = [];
|
|
9157
|
+
for (const line of raw.split("\n")) {
|
|
9158
|
+
if (!line.trim()) continue;
|
|
9159
|
+
let entry;
|
|
9160
|
+
try {
|
|
9161
|
+
entry = JSON.parse(line);
|
|
9162
|
+
} catch {
|
|
9163
|
+
continue;
|
|
9164
|
+
}
|
|
9165
|
+
if (entry.phase === "attempt") attempts.push(entry);
|
|
9166
|
+
else
|
|
9167
|
+
outcomes.set(entry.id, {
|
|
9168
|
+
phase: entry.phase,
|
|
9169
|
+
outcome: entry.outcome,
|
|
9170
|
+
durationMs: entry.durationMs
|
|
9171
|
+
});
|
|
9172
|
+
}
|
|
9173
|
+
let entries = attempts.map((a) => {
|
|
9174
|
+
const outcome = outcomes.get(a.id);
|
|
9175
|
+
const snapshotIds = Array.isArray(a.snapshotIds) ? a.snapshotIds : [];
|
|
9176
|
+
const dryRun2 = journalEntryWasDryRun(a);
|
|
9177
|
+
return {
|
|
9178
|
+
journalId: a.id,
|
|
9179
|
+
ts: a.ts,
|
|
9180
|
+
tool: a.tool,
|
|
9181
|
+
routerId: a.routerId,
|
|
9182
|
+
identityId: a.identityId,
|
|
9183
|
+
role: a.role,
|
|
9184
|
+
params: a.params ?? {},
|
|
9185
|
+
snapshotIds,
|
|
9186
|
+
dryRun: dryRun2,
|
|
9187
|
+
rollbackable: !dryRun2 && snapshotIds.length > 0,
|
|
9188
|
+
status: outcome ? outcome.phase : "unknown",
|
|
9189
|
+
outcomeCode: outcome?.outcome,
|
|
9190
|
+
durationMs: outcome?.durationMs
|
|
9191
|
+
};
|
|
9192
|
+
}).reverse();
|
|
9193
|
+
if (parsed.routerId) entries = entries.filter((e) => e.routerId === parsed.routerId);
|
|
9194
|
+
if (parsed.tool) entries = entries.filter((e) => e.tool === parsed.tool);
|
|
9195
|
+
const scope = context.identity.allowedRouters;
|
|
9196
|
+
if (scope.length > 0) entries = entries.filter((e) => scope.includes(e.routerId));
|
|
9197
|
+
const total = entries.length;
|
|
9198
|
+
const returned = entries.slice(0, parsed.limit);
|
|
9199
|
+
const header = total === 0 ? "Write journal: no entries." : `Write journal: ${returned.length} of ${total}, newest first.`;
|
|
9200
|
+
const lines = returned.map(
|
|
9201
|
+
(e) => ` ${String(e.journalId)} ${String(e.tool)} ${String(e.routerId)} \u2192 ${e.status}${e.outcomeCode ? ` (${e.outcomeCode})` : ""}${e.dryRun ? " dry-run" : e.rollbackable ? "" : " not-rollbackable"} [${String(e.ts)}]`
|
|
9202
|
+
);
|
|
9203
|
+
return {
|
|
9204
|
+
content: total === 0 ? header : [header, ...lines].join("\n"),
|
|
9205
|
+
structuredContent: {
|
|
9206
|
+
journalEnabled: true,
|
|
9207
|
+
entries: returned,
|
|
9208
|
+
total,
|
|
9209
|
+
returned: returned.length,
|
|
9210
|
+
note: "Pass the journalId of an entry with rollbackable: true to rollback_change to restore that write. Dry runs and entries without snapshots (e.g. run_command) cannot be rolled back."
|
|
9211
|
+
}
|
|
9212
|
+
};
|
|
9213
|
+
}
|
|
9214
|
+
};
|
|
9029
9215
|
var rollbackChangeInputSchema = z25.object({
|
|
9030
9216
|
routerId,
|
|
9031
9217
|
journalId: z25.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
|
|
@@ -9246,7 +9432,8 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9246
9432
|
tool: step.tool,
|
|
9247
9433
|
routerId: context.routerId,
|
|
9248
9434
|
params: loggableParams(tool, stepParams),
|
|
9249
|
-
snapshotIds
|
|
9435
|
+
snapshotIds,
|
|
9436
|
+
dryRun: isEffectiveDryRun(tool, stepParams)
|
|
9250
9437
|
});
|
|
9251
9438
|
const stepStartMs = Date.now();
|
|
9252
9439
|
try {
|
|
@@ -9313,7 +9500,10 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9313
9500
|
}
|
|
9314
9501
|
});
|
|
9315
9502
|
}
|
|
9316
|
-
return {
|
|
9503
|
+
return {
|
|
9504
|
+
snapshotIds: entry.snapshotIds ?? [],
|
|
9505
|
+
dryRun: journalEntryWasDryRun(entry)
|
|
9506
|
+
};
|
|
9317
9507
|
}
|
|
9318
9508
|
const rollbackChangeTool = {
|
|
9319
9509
|
name: "rollback_change",
|
|
@@ -9332,7 +9522,16 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9332
9522
|
const journalPath = context.appConfig.journalPath;
|
|
9333
9523
|
const snapshotDir = context.appConfig.snapshotDir;
|
|
9334
9524
|
log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
|
|
9335
|
-
const { snapshotIds } = findJournalEntry(
|
|
9525
|
+
const { snapshotIds, dryRun: entryWasDryRun } = findJournalEntry(
|
|
9526
|
+
journalPath,
|
|
9527
|
+
parsed.journalId
|
|
9528
|
+
);
|
|
9529
|
+
if (entryWasDryRun) {
|
|
9530
|
+
return {
|
|
9531
|
+
content: `Journal entry "${parsed.journalId}" was a dry run \u2014 nothing was changed, so there is nothing to roll back.`,
|
|
9532
|
+
structuredContent: { action: "dry_run_entry", journalId: parsed.journalId }
|
|
9533
|
+
};
|
|
9534
|
+
}
|
|
9336
9535
|
if (snapshotIds.length === 0) {
|
|
9337
9536
|
return {
|
|
9338
9537
|
content: `Journal entry "${parsed.journalId}" has no associated snapshots \u2014 cannot rollback automatically.`,
|
|
@@ -9387,7 +9586,7 @@ ${warnings.map((w) => ` - ${w}`).join("\n")}` : "";
|
|
|
9387
9586
|
};
|
|
9388
9587
|
}
|
|
9389
9588
|
};
|
|
9390
|
-
return [planChangesTool, applyPlanTool, rollbackChangeTool];
|
|
9589
|
+
return [planChangesTool, applyPlanTool, rollbackChangeTool, listJournalTool];
|
|
9391
9590
|
}
|
|
9392
9591
|
|
|
9393
9592
|
// src/domain/tools/ipsec-tools.ts
|
|
@@ -11860,8 +12059,8 @@ function sanitizeComment3(comment) {
|
|
|
11860
12059
|
var manageVlanInputSchema = z35.object({
|
|
11861
12060
|
routerId,
|
|
11862
12061
|
action: z35.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
11863
|
-
name: z35.string().
|
|
11864
|
-
"VLAN interface name \u2014 idempotency key
|
|
12062
|
+
name: z35.string().min(1).describe(
|
|
12063
|
+
"VLAN interface name \u2014 idempotency key, taken verbatim for remove/enable/disable; add accepts letters, digits, dot, hyphen, underscore, up to 64 chars"
|
|
11865
12064
|
),
|
|
11866
12065
|
vlanId: z35.number().int().min(1).max(4094).optional().describe("VLAN ID (1-4094; required for add)"),
|
|
11867
12066
|
parentInterface: z35.string().optional().describe("Parent interface name (e.g., ether1, bridge1; required for add)"),
|
|
@@ -11898,6 +12097,11 @@ var manageVlanTool = {
|
|
|
11898
12097
|
(v) => sameValue(v.name, parsed.name)
|
|
11899
12098
|
);
|
|
11900
12099
|
if (parsed.action === "add") {
|
|
12100
|
+
assertNewInterfaceName(parsed.name, {
|
|
12101
|
+
kind: "VLAN",
|
|
12102
|
+
action: "add",
|
|
12103
|
+
code: "VLAN_NAME_INVALID"
|
|
12104
|
+
});
|
|
11901
12105
|
if (parsed.vlanId === void 0) {
|
|
11902
12106
|
throw new MikroMCPError({
|
|
11903
12107
|
category: "VALIDATION" /* VALIDATION */,
|
|
@@ -12042,9 +12246,12 @@ var vlanTools = [manageVlanTool];
|
|
|
12042
12246
|
// src/domain/tools/dhcp-client-tools.ts
|
|
12043
12247
|
import { z as z36 } from "zod";
|
|
12044
12248
|
var log35 = createLogger("dhcp-client-tools");
|
|
12249
|
+
function findClient(clients, target, byNameToo = true) {
|
|
12250
|
+
return clients.find((c) => sameValue(c.interface, target)) ?? (byNameToo ? clients.find((c) => sameValue(c.name, target)) : void 0);
|
|
12251
|
+
}
|
|
12045
12252
|
var listDhcpClientsInputSchema = z36.object({
|
|
12046
12253
|
routerId,
|
|
12047
|
-
interface: z36.string().optional().describe("Filter by interface name (exact match)"),
|
|
12254
|
+
interface: z36.string().optional().describe("Filter by interface or display name (exact match)"),
|
|
12048
12255
|
status: z36.enum(["bound", "searching", "requesting", "init", "all"]).default("all").describe("Filter by DHCP client status"),
|
|
12049
12256
|
limit,
|
|
12050
12257
|
offset
|
|
@@ -12070,7 +12277,9 @@ var listDhcpClientsTool = {
|
|
|
12070
12277
|
});
|
|
12071
12278
|
let filtered = allClients;
|
|
12072
12279
|
if (parsed.interface) {
|
|
12073
|
-
filtered = filtered.filter(
|
|
12280
|
+
filtered = filtered.filter(
|
|
12281
|
+
(c) => sameValue(c.interface, parsed.interface) || sameValue(c.name, parsed.interface)
|
|
12282
|
+
);
|
|
12074
12283
|
}
|
|
12075
12284
|
if (parsed.status !== "all") {
|
|
12076
12285
|
filtered = filtered.filter((c) => c.status === parsed.status);
|
|
@@ -12083,7 +12292,7 @@ var listDhcpClientsTool = {
|
|
|
12083
12292
|
clients,
|
|
12084
12293
|
total,
|
|
12085
12294
|
parsed.offset,
|
|
12086
|
-
(c) => compactFields(c, ["interface", "status", "address", "gateway", "disabled"])
|
|
12295
|
+
(c) => compactFields(c, ["interface", "name", "status", "address", "gateway", "disabled"])
|
|
12087
12296
|
),
|
|
12088
12297
|
structuredContent: {
|
|
12089
12298
|
routerId: context.routerId,
|
|
@@ -12102,7 +12311,9 @@ var listDhcpClientsTool = {
|
|
|
12102
12311
|
var manageDhcpClientInputSchema = z36.object({
|
|
12103
12312
|
routerId,
|
|
12104
12313
|
action: z36.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
12105
|
-
interface: z36.string().describe(
|
|
12314
|
+
interface: z36.string().describe(
|
|
12315
|
+
"Interface name \u2014 idempotency key (e.g. ether1); for remove/enable/disable a client's display name also matches"
|
|
12316
|
+
),
|
|
12106
12317
|
usePeerDns: z36.boolean().default(true).describe("Use DNS servers provided by DHCP server (add only)"),
|
|
12107
12318
|
usePeerNtp: z36.boolean().default(false).describe("Use NTP servers provided by DHCP server (add only)"),
|
|
12108
12319
|
addDefaultRoute: z36.boolean().default(true).describe("Add default route from DHCP (add only)"),
|
|
@@ -12132,8 +12343,10 @@ var manageDhcpClientTool = {
|
|
|
12132
12343
|
limit: void 0,
|
|
12133
12344
|
offset: void 0
|
|
12134
12345
|
});
|
|
12135
|
-
const existing =
|
|
12136
|
-
|
|
12346
|
+
const existing = findClient(
|
|
12347
|
+
allClients,
|
|
12348
|
+
parsed.interface,
|
|
12349
|
+
parsed.action !== "add"
|
|
12137
12350
|
);
|
|
12138
12351
|
if (parsed.action === "add") {
|
|
12139
12352
|
if (existing) {
|
|
@@ -12174,9 +12387,19 @@ var manageDhcpClientTool = {
|
|
|
12174
12387
|
}
|
|
12175
12388
|
if (parsed.action === "remove") {
|
|
12176
12389
|
if (!existing) {
|
|
12390
|
+
const candidates = allClients.map((c) => ({
|
|
12391
|
+
interface: c.interface,
|
|
12392
|
+
name: c.name,
|
|
12393
|
+
status: c.status
|
|
12394
|
+
}));
|
|
12395
|
+
const names = candidates.map((c) => c.name ?? c.interface).slice(0, 5).join(", ");
|
|
12177
12396
|
return {
|
|
12178
|
-
content: `No DHCP client found on "${parsed.interface}". Nothing to remove
|
|
12179
|
-
structuredContent: {
|
|
12397
|
+
content: `No DHCP client found on "${parsed.interface}". Nothing to remove.` + (names ? ` Existing clients: ${names}.` : ""),
|
|
12398
|
+
structuredContent: {
|
|
12399
|
+
action: "not_found",
|
|
12400
|
+
interface: parsed.interface,
|
|
12401
|
+
clients: candidates
|
|
12402
|
+
}
|
|
12180
12403
|
};
|
|
12181
12404
|
}
|
|
12182
12405
|
if (parsed.dryRun) {
|
|
@@ -12184,7 +12407,7 @@ var manageDhcpClientTool = {
|
|
|
12184
12407
|
content: `Dry run: Would remove DHCP client on "${parsed.interface}".`,
|
|
12185
12408
|
structuredContent: {
|
|
12186
12409
|
action: "dry_run",
|
|
12187
|
-
diff: [{ property: "interface", before:
|
|
12410
|
+
diff: [{ property: "interface", before: existing.interface, after: null }]
|
|
12188
12411
|
}
|
|
12189
12412
|
};
|
|
12190
12413
|
}
|
|
@@ -16479,44 +16702,6 @@ var swosMaintenanceTools = [
|
|
|
16479
16702
|
// src/domain/tools/fleet-tools.ts
|
|
16480
16703
|
import { z as z50 } from "zod";
|
|
16481
16704
|
|
|
16482
|
-
// src/middleware/authz.ts
|
|
16483
|
-
function matchesPattern(name, pattern) {
|
|
16484
|
-
return globMatch(pattern, name);
|
|
16485
|
-
}
|
|
16486
|
-
function checkAuthz(identity, toolName, routerId2) {
|
|
16487
|
-
if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
|
|
16488
|
-
throw new MikroMCPError({
|
|
16489
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
16490
|
-
code: "ROUTER_NOT_ALLOWED",
|
|
16491
|
-
message: `Identity "${identity.id}" is not allowed to access router "${routerId2}".`,
|
|
16492
|
-
details: { identityId: identity.id, routerId: routerId2, allowedRouters: identity.allowedRouters },
|
|
16493
|
-
recoverability: {
|
|
16494
|
-
retryable: false,
|
|
16495
|
-
suggestedAction: `Add "${routerId2}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
|
|
16496
|
-
}
|
|
16497
|
-
});
|
|
16498
|
-
}
|
|
16499
|
-
if (identity.allowedToolPatterns.length > 0) {
|
|
16500
|
-
const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
|
|
16501
|
-
if (!allowed) {
|
|
16502
|
-
throw new MikroMCPError({
|
|
16503
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
16504
|
-
code: "TOOL_NOT_ALLOWED",
|
|
16505
|
-
message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
|
|
16506
|
-
details: {
|
|
16507
|
-
identityId: identity.id,
|
|
16508
|
-
toolName,
|
|
16509
|
-
allowedToolPatterns: identity.allowedToolPatterns
|
|
16510
|
-
},
|
|
16511
|
-
recoverability: {
|
|
16512
|
-
retryable: false,
|
|
16513
|
-
suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
|
|
16514
|
-
}
|
|
16515
|
-
});
|
|
16516
|
-
}
|
|
16517
|
-
}
|
|
16518
|
-
}
|
|
16519
|
-
|
|
16520
16705
|
// src/config/secrets.ts
|
|
16521
16706
|
function getCredentials(router) {
|
|
16522
16707
|
const { source, envPrefix, vaultPath } = router.credentials;
|
|
@@ -17410,6 +17595,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17410
17595
|
}
|
|
17411
17596
|
}
|
|
17412
17597
|
toolContext.snapshots = snapshots;
|
|
17598
|
+
const dryRun2 = !tool.annotations.readOnlyHint && isEffectiveDryRun(tool, handlerArgs);
|
|
17413
17599
|
if (!tool.annotations.readOnlyHint && config.journalPath) {
|
|
17414
17600
|
const { confirmationToken: _token, ...journalArgs } = loggedArgs;
|
|
17415
17601
|
journalId = recordAttempt({
|
|
@@ -17419,7 +17605,8 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17419
17605
|
tool: tool.name,
|
|
17420
17606
|
routerId: routerId2,
|
|
17421
17607
|
params: journalArgs,
|
|
17422
|
-
snapshotIds
|
|
17608
|
+
snapshotIds,
|
|
17609
|
+
dryRun: dryRun2
|
|
17423
17610
|
});
|
|
17424
17611
|
}
|
|
17425
17612
|
const runHandler = () => tool.handler(handlerArgs, toolContext);
|
|
@@ -17433,6 +17620,11 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17433
17620
|
phase: "success",
|
|
17434
17621
|
durationMs: Date.now() - startMs
|
|
17435
17622
|
});
|
|
17623
|
+
if (!dryRun2 && snapshotIds.length > 0) {
|
|
17624
|
+
result.structuredContent = { ...result.structuredContent, journalId };
|
|
17625
|
+
result.content += `
|
|
17626
|
+
Journal ID: ${journalId} (pass to rollback_change to undo).`;
|
|
17627
|
+
}
|
|
17436
17628
|
}
|
|
17437
17629
|
if (shouldAudit) {
|
|
17438
17630
|
auditLog(
|
|
@@ -17610,7 +17802,7 @@ var listRoutersInputSchema = z50.object({
|
|
|
17610
17802
|
var listRoutersTool = {
|
|
17611
17803
|
name: "list_routers",
|
|
17612
17804
|
title: "List Routers",
|
|
17613
|
-
description: "List the routers configured in the registry (routers.yaml): id, host, port, TLS status, tags, ROS version, and which is the default. Read-only reflection of local config \u2014 no RouterOS API call, no credentials in the response. Use it to discover valid routerId values and tags for targeting other tools (including bulk_read and bulk_execute).",
|
|
17805
|
+
description: "List the routers configured in the registry (routers.yaml): id, host, port, TLS status, tags, ROS version, and which is the default. Read-only reflection of local config \u2014 no RouterOS API call, no credentials in the response. rosVersion is what routers.yaml states and may lag a router upgrade \u2014 ask get_system_status for the live version. Use it to discover valid routerId values and tags for targeting other tools (including bulk_read and bulk_execute).",
|
|
17614
17806
|
inputSchema: listRoutersInputSchema,
|
|
17615
17807
|
annotations: {
|
|
17616
17808
|
readOnlyHint: true,
|
|
@@ -17653,9 +17845,17 @@ var listRoutersTool = {
|
|
|
17653
17845
|
"isDefault"
|
|
17654
17846
|
])}`
|
|
17655
17847
|
);
|
|
17848
|
+
const hasRouterOS = rows.some((r) => r.deviceType === "routeros");
|
|
17656
17849
|
return {
|
|
17657
17850
|
content: total === 0 ? header : [header, ...lines].join("\n"),
|
|
17658
|
-
structuredContent: {
|
|
17851
|
+
structuredContent: {
|
|
17852
|
+
routers: rows,
|
|
17853
|
+
total,
|
|
17854
|
+
returned: total,
|
|
17855
|
+
...hasRouterOS ? {
|
|
17856
|
+
note: "rosVersion comes from routers.yaml and may be stale; use get_system_status for the live version."
|
|
17857
|
+
} : {}
|
|
17858
|
+
}
|
|
17659
17859
|
};
|
|
17660
17860
|
}
|
|
17661
17861
|
};
|
|
@@ -17814,7 +18014,8 @@ async function fanOut(req) {
|
|
|
17814
18014
|
tool: input2.toolName,
|
|
17815
18015
|
routerId: router.id,
|
|
17816
18016
|
params: loggedParams,
|
|
17817
|
-
snapshotIds
|
|
18017
|
+
snapshotIds,
|
|
18018
|
+
dryRun: isEffectiveDryRun(targetTool, toolParams)
|
|
17818
18019
|
});
|
|
17819
18020
|
}
|
|
17820
18021
|
const cb = getOrCreateBreaker(context.circuitBreakers, router.id, context.appConfig);
|
|
@@ -18398,7 +18599,7 @@ var ConnectionPool = class {
|
|
|
18398
18599
|
};
|
|
18399
18600
|
|
|
18400
18601
|
// src/version.ts
|
|
18401
|
-
var VERSION2 = "1.
|
|
18602
|
+
var VERSION2 = "1.15.0";
|
|
18402
18603
|
|
|
18403
18604
|
// src/mcp/server.ts
|
|
18404
18605
|
var SERVER_INSTRUCTIONS = [
|
|
@@ -18876,7 +19077,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
|
|
|
18876
19077
|
import { homedir as homedir2 } from "os";
|
|
18877
19078
|
import * as net from "net";
|
|
18878
19079
|
import chalk from "chalk";
|
|
18879
|
-
var localVersion = "1.
|
|
19080
|
+
var localVersion = "1.15.0";
|
|
18880
19081
|
var results = [];
|
|
18881
19082
|
function ok(msg) {
|
|
18882
19083
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -19692,7 +19893,7 @@ async function runInit() {
|
|
|
19692
19893
|
}
|
|
19693
19894
|
|
|
19694
19895
|
// src/cli/index.ts
|
|
19695
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.
|
|
19896
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.15.0");
|
|
19696
19897
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
19697
19898
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
19698
19899
|
program.command("init").description("Interactive setup wizard").action(runInit);
|