mikromcp 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/config/identities.example.yaml +2 -0
- package/dist/main.js +264 -119
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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,6 +19,8 @@
|
|
|
19
19
|
#
|
|
20
20
|
# allowedRouters: [] means all routers are accessible.
|
|
21
21
|
# allowedToolPatterns: [] means all tools are accessible. Supports * wildcard.
|
|
22
|
+
# Fleet tools (bulk_read, bulk_execute) must match by their own name too;
|
|
23
|
+
# they also check each tool they fan out.
|
|
22
24
|
|
|
23
25
|
identities:
|
|
24
26
|
# CI pipeline: operator access to specific routers, limited tool set
|
package/dist/main.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/main.ts
|
|
4
4
|
import { config as loadDotenv } from "dotenv";
|
|
5
|
-
import { join as
|
|
5
|
+
import { join as join7 } from "path";
|
|
6
6
|
import { homedir as homedir4 } from "os";
|
|
7
7
|
|
|
8
8
|
// src/cli/index.ts
|
|
@@ -6360,7 +6360,6 @@ var containerTools = [listContainersTool, manageContainerTool];
|
|
|
6360
6360
|
|
|
6361
6361
|
// src/domain/tools/change-management-tools.ts
|
|
6362
6362
|
import { z as z25 } from "zod";
|
|
6363
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
6364
6363
|
|
|
6365
6364
|
// src/domain/snapshot/snapshot-engine.ts
|
|
6366
6365
|
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
@@ -8081,8 +8080,10 @@ async function loadSnapshot(filePath) {
|
|
|
8081
8080
|
}
|
|
8082
8081
|
|
|
8083
8082
|
// src/domain/snapshot/write-journal.ts
|
|
8083
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
8084
|
+
import { readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
8084
8085
|
import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
|
|
8085
|
-
import { dirname } from "path";
|
|
8086
|
+
import { basename, dirname, extname, join as join3 } from "path";
|
|
8086
8087
|
import { nanoid as nanoid2 } from "nanoid";
|
|
8087
8088
|
|
|
8088
8089
|
// src/observability/audit-log.ts
|
|
@@ -8156,6 +8157,72 @@ function recordAttempt(args) {
|
|
|
8156
8157
|
});
|
|
8157
8158
|
return id;
|
|
8158
8159
|
}
|
|
8160
|
+
function parseJournal(raw) {
|
|
8161
|
+
const entries = [];
|
|
8162
|
+
for (const line of raw.split("\n")) {
|
|
8163
|
+
if (!line.trim()) continue;
|
|
8164
|
+
try {
|
|
8165
|
+
const entry = JSON.parse(line);
|
|
8166
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
8167
|
+
entries.push(entry);
|
|
8168
|
+
}
|
|
8169
|
+
} catch {
|
|
8170
|
+
continue;
|
|
8171
|
+
}
|
|
8172
|
+
}
|
|
8173
|
+
return entries;
|
|
8174
|
+
}
|
|
8175
|
+
var SEGMENT_STAMP = /^\d{8}T\d{6}Z-[0-9a-z]+$/;
|
|
8176
|
+
function journalSegmentName(journalPath, now, suffix = randomBytes2(6).toString("hex")) {
|
|
8177
|
+
const ext = extname(journalPath);
|
|
8178
|
+
const stamp2 = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
8179
|
+
return join3(dirname(journalPath), `${basename(journalPath, ext)}.${stamp2}-${suffix}${ext}`);
|
|
8180
|
+
}
|
|
8181
|
+
function journalSegmentPaths(journalPath) {
|
|
8182
|
+
const ext = extname(journalPath);
|
|
8183
|
+
const prefix = `${basename(journalPath, ext)}.`;
|
|
8184
|
+
let names;
|
|
8185
|
+
try {
|
|
8186
|
+
names = readdirSync(dirname(journalPath));
|
|
8187
|
+
} catch {
|
|
8188
|
+
return [];
|
|
8189
|
+
}
|
|
8190
|
+
return names.filter(
|
|
8191
|
+
(n) => n.startsWith(prefix) && n.endsWith(ext) && SEGMENT_STAMP.test(n.slice(prefix.length, n.length - ext.length))
|
|
8192
|
+
).sort().map((n) => join3(dirname(journalPath), n));
|
|
8193
|
+
}
|
|
8194
|
+
function journalSegmentRotatedAt(segmentPath) {
|
|
8195
|
+
const stamp2 = /\.(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-[0-9a-z]+\.[^.]+$/.exec(
|
|
8196
|
+
segmentPath
|
|
8197
|
+
);
|
|
8198
|
+
if (!stamp2) return NaN;
|
|
8199
|
+
const [, y, mo, d, h, mi, se] = stamp2;
|
|
8200
|
+
return Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${se}Z`);
|
|
8201
|
+
}
|
|
8202
|
+
function readIfExists(path) {
|
|
8203
|
+
try {
|
|
8204
|
+
return readFileSync2(path, "utf-8");
|
|
8205
|
+
} catch (err) {
|
|
8206
|
+
if (err.code === "ENOENT") return "";
|
|
8207
|
+
throw err;
|
|
8208
|
+
}
|
|
8209
|
+
}
|
|
8210
|
+
function readJournalEntries(journalPath) {
|
|
8211
|
+
const current = parseJournal(readIfExists(journalPath));
|
|
8212
|
+
const segments2 = journalSegmentPaths(journalPath).flatMap((p) => parseJournal(readIfExists(p)));
|
|
8213
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8214
|
+
const entries = [...segments2, ...current].filter((e) => {
|
|
8215
|
+
const key2 = `${String(e.id)}\0${String(e.phase)}`;
|
|
8216
|
+
if (seen.has(key2)) return false;
|
|
8217
|
+
seen.add(key2);
|
|
8218
|
+
return true;
|
|
8219
|
+
});
|
|
8220
|
+
const time = (e) => {
|
|
8221
|
+
const t = typeof e.ts === "string" ? Date.parse(e.ts) : NaN;
|
|
8222
|
+
return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY;
|
|
8223
|
+
};
|
|
8224
|
+
return entries.map((e, i) => ({ e, i, t: time(e) })).sort((a, b) => a.t - b.t || a.i - b.i).map(({ e }) => e);
|
|
8225
|
+
}
|
|
8159
8226
|
function recordOutcome(args) {
|
|
8160
8227
|
void appendLine(args.journalPath, {
|
|
8161
8228
|
id: args.journalId,
|
|
@@ -9046,47 +9113,6 @@ function unionVlanBlob(currentWire, targetWire) {
|
|
|
9046
9113
|
return dumpBlob([...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row));
|
|
9047
9114
|
}
|
|
9048
9115
|
|
|
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
|
-
|
|
9090
9116
|
// src/domain/tools/change-management-tools.ts
|
|
9091
9117
|
var log24 = createLogger("change-management");
|
|
9092
9118
|
var stepSchema = z25.object({
|
|
@@ -9125,7 +9151,6 @@ var listJournalTool = {
|
|
|
9125
9151
|
skipRouterContext: true,
|
|
9126
9152
|
async handler(params, context) {
|
|
9127
9153
|
const parsed = listJournalInputSchema.parse(params);
|
|
9128
|
-
checkToolAllowed(context.identity, "list_journal");
|
|
9129
9154
|
const journalPath = context.appConfig.journalPath;
|
|
9130
9155
|
if (!journalPath) {
|
|
9131
9156
|
return {
|
|
@@ -9133,35 +9158,24 @@ var listJournalTool = {
|
|
|
9133
9158
|
structuredContent: { journalEnabled: false, entries: [], total: 0, returned: 0 }
|
|
9134
9159
|
};
|
|
9135
9160
|
}
|
|
9136
|
-
let
|
|
9161
|
+
let journal;
|
|
9137
9162
|
try {
|
|
9138
|
-
|
|
9163
|
+
journal = readJournalEntries(journalPath);
|
|
9139
9164
|
} catch (err) {
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
suggestedAction: "Check the file's permissions under the MikroMCP data directory."
|
|
9151
|
-
}
|
|
9152
|
-
});
|
|
9153
|
-
}
|
|
9165
|
+
throw new MikroMCPError({
|
|
9166
|
+
category: "INTERNAL" /* INTERNAL */,
|
|
9167
|
+
code: "JOURNAL_READ_FAILED",
|
|
9168
|
+
message: `Could not read the write journal: ${err.message}`,
|
|
9169
|
+
details: { journalPath },
|
|
9170
|
+
recoverability: {
|
|
9171
|
+
retryable: false,
|
|
9172
|
+
suggestedAction: "Check the file's permissions under the MikroMCP data directory."
|
|
9173
|
+
}
|
|
9174
|
+
});
|
|
9154
9175
|
}
|
|
9155
9176
|
const outcomes = /* @__PURE__ */ new Map();
|
|
9156
9177
|
const attempts = [];
|
|
9157
|
-
for (const
|
|
9158
|
-
if (!line.trim()) continue;
|
|
9159
|
-
let entry;
|
|
9160
|
-
try {
|
|
9161
|
-
entry = JSON.parse(line);
|
|
9162
|
-
} catch {
|
|
9163
|
-
continue;
|
|
9164
|
-
}
|
|
9178
|
+
for (const entry of journal) {
|
|
9165
9179
|
if (entry.phase === "attempt") attempts.push(entry);
|
|
9166
9180
|
else
|
|
9167
9181
|
outcomes.set(entry.id, {
|
|
@@ -9486,9 +9500,9 @@ function createChangeManagementTools(baseTools2) {
|
|
|
9486
9500
|
}
|
|
9487
9501
|
};
|
|
9488
9502
|
function findJournalEntry(journalPath, journalId) {
|
|
9489
|
-
const
|
|
9490
|
-
|
|
9491
|
-
|
|
9503
|
+
const entry = readJournalEntries(journalPath).find(
|
|
9504
|
+
(e) => e.id === journalId && e.phase === "attempt"
|
|
9505
|
+
);
|
|
9492
9506
|
if (!entry) {
|
|
9493
9507
|
throw new MikroMCPError({
|
|
9494
9508
|
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
@@ -13764,13 +13778,54 @@ var log41 = createLogger("backup-tools");
|
|
|
13764
13778
|
var createBackupInputSchema = z42.object({
|
|
13765
13779
|
routerId,
|
|
13766
13780
|
name: z42.string().default("backup").describe("Backup file name (without extension)"),
|
|
13767
|
-
password: z42.string().optional().describe(
|
|
13781
|
+
password: z42.string().optional().describe(
|
|
13782
|
+
"Literal password to encrypt the backup. It stays in the conversation history; prefer passwordSecret"
|
|
13783
|
+
),
|
|
13784
|
+
passwordSecret: z42.string().regex(/^[A-Z0-9_]+$/, "a secret name is the NAME of MIKROMCP_SECRET_<NAME>").optional().describe(
|
|
13785
|
+
"Encrypt with the server's MIKROMCP_SECRET_<NAME> value, bound to this router by its secrets list in routers.yaml. The value never reaches the conversation"
|
|
13786
|
+
),
|
|
13768
13787
|
dryRun: z42.boolean().default(false).describe("Preview the backup without creating it")
|
|
13769
13788
|
}).strict();
|
|
13789
|
+
function backupError(code, message, suggestedAction) {
|
|
13790
|
+
return new MikroMCPError({
|
|
13791
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
13792
|
+
code,
|
|
13793
|
+
message,
|
|
13794
|
+
recoverability: { retryable: false, suggestedAction }
|
|
13795
|
+
});
|
|
13796
|
+
}
|
|
13797
|
+
function resolveBackupPassword(parsed, context) {
|
|
13798
|
+
const name = parsed.passwordSecret;
|
|
13799
|
+
if (name === void 0) return parsed.password;
|
|
13800
|
+
if (parsed.password !== void 0) {
|
|
13801
|
+
throw backupError(
|
|
13802
|
+
"BACKUP_PASSWORD_CONFLICT",
|
|
13803
|
+
"Pass either password or passwordSecret, not both.",
|
|
13804
|
+
"Drop password and keep passwordSecret, so the password stays out of the conversation."
|
|
13805
|
+
);
|
|
13806
|
+
}
|
|
13807
|
+
const bound = context.routerConfig.secrets ?? [];
|
|
13808
|
+
if (!bound.includes(name)) {
|
|
13809
|
+
throw backupError(
|
|
13810
|
+
"BACKUP_SECRET_NOT_BOUND",
|
|
13811
|
+
bound.length ? `Secret ${name} is not bound to router "${context.routerId}" (its routers.yaml secrets list names ${bound.join(", ")}).` : `Router "${context.routerId}" has no secrets list in routers.yaml, so it may use no secret.`,
|
|
13812
|
+
`Add ${name} to the router's \`secrets\` list in routers.yaml if it is meant for it.`
|
|
13813
|
+
);
|
|
13814
|
+
}
|
|
13815
|
+
const value = context.appConfig.configSecrets[name];
|
|
13816
|
+
if (!value) {
|
|
13817
|
+
throw backupError(
|
|
13818
|
+
"BACKUP_SECRET_MISSING",
|
|
13819
|
+
`Secret ${name} is bound to router "${context.routerId}", but MIKROMCP_SECRET_${name} is not set or empty.`,
|
|
13820
|
+
`Set MIKROMCP_SECRET_${name} in the MikroMCP server's environment and restart it.`
|
|
13821
|
+
);
|
|
13822
|
+
}
|
|
13823
|
+
return value;
|
|
13824
|
+
}
|
|
13770
13825
|
var createBackupTool = {
|
|
13771
13826
|
name: "create_backup",
|
|
13772
13827
|
title: "Create Backup",
|
|
13773
|
-
description: "Create a binary configuration backup on a MikroTik router. The backup is saved as <name>.backup on the router's filesystem.
|
|
13828
|
+
description: "Create a binary configuration backup on a MikroTik router. The backup is saved as <name>.backup on the router's filesystem. To encrypt it, prefer passwordSecret (a MIKROMCP_SECRET_<NAME> bound to the router in routers.yaml \u2014 the password never reaches the conversation) over a literal password. Dry run checks that the secret resolves.",
|
|
13774
13829
|
inputSchema: createBackupInputSchema,
|
|
13775
13830
|
annotations: {
|
|
13776
13831
|
readOnlyHint: false,
|
|
@@ -13781,25 +13836,34 @@ var createBackupTool = {
|
|
|
13781
13836
|
async handler(params, context) {
|
|
13782
13837
|
const parsed = createBackupInputSchema.parse(params);
|
|
13783
13838
|
log41.info({ routerId: context.routerId, name: parsed.name }, "Creating backup");
|
|
13839
|
+
const password = resolveBackupPassword(parsed, context);
|
|
13840
|
+
const encrypted = password !== void 0 && password !== "";
|
|
13841
|
+
const how = encrypted ? "encrypted " : "";
|
|
13784
13842
|
if (parsed.dryRun) {
|
|
13785
13843
|
return {
|
|
13786
|
-
content: `Dry run: Would create backup "${parsed.name}.backup" on ${context.routerId}.`,
|
|
13787
|
-
structuredContent: {
|
|
13844
|
+
content: `Dry run: Would create ${how}backup "${parsed.name}.backup" on ${context.routerId}.`,
|
|
13845
|
+
structuredContent: {
|
|
13846
|
+
action: "dry_run",
|
|
13847
|
+
name: parsed.name,
|
|
13848
|
+
routerId: context.routerId,
|
|
13849
|
+
encrypted
|
|
13850
|
+
}
|
|
13788
13851
|
};
|
|
13789
13852
|
}
|
|
13790
13853
|
try {
|
|
13791
13854
|
const body = { name: parsed.name };
|
|
13792
|
-
if (
|
|
13793
|
-
body.password =
|
|
13855
|
+
if (password !== void 0) {
|
|
13856
|
+
body.password = password;
|
|
13794
13857
|
}
|
|
13795
13858
|
await context.routerClient.execute("system/backup/save", body);
|
|
13796
|
-
log41.info({ routerId: context.routerId, name: parsed.name }, "Backup created");
|
|
13859
|
+
log41.info({ routerId: context.routerId, name: parsed.name, encrypted }, "Backup created");
|
|
13797
13860
|
return {
|
|
13798
|
-
content: `Backup "${parsed.name}.backup" created on ${context.routerId}.`,
|
|
13861
|
+
content: `Backup "${parsed.name}.backup" created ${how}on ${context.routerId}.`,
|
|
13799
13862
|
structuredContent: {
|
|
13800
13863
|
action: "created",
|
|
13801
13864
|
routerId: context.routerId,
|
|
13802
|
-
filePath: `${parsed.name}.backup
|
|
13865
|
+
filePath: `${parsed.name}.backup`,
|
|
13866
|
+
encrypted
|
|
13803
13867
|
}
|
|
13804
13868
|
};
|
|
13805
13869
|
} catch (err) {
|
|
@@ -16100,7 +16164,7 @@ var swosConfigTools = [getSwosConfigTool, applySwosConfigTool];
|
|
|
16100
16164
|
import { z as z49 } from "zod";
|
|
16101
16165
|
import { createHash as createHash2 } from "crypto";
|
|
16102
16166
|
import { chmod, mkdir as mkdir3, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
16103
|
-
import { join as
|
|
16167
|
+
import { join as join4, resolve, sep } from "path";
|
|
16104
16168
|
var log48 = createLogger("swos-maintenance-tools");
|
|
16105
16169
|
var DOWNLOAD_BASE = "https://download.mikrotik.com/swos";
|
|
16106
16170
|
var FIRMWARE_MIN_BYTES = 16 * 1024;
|
|
@@ -16110,7 +16174,7 @@ var RESTORE_TIMEOUT_MS = 6e4;
|
|
|
16110
16174
|
var FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
|
|
16111
16175
|
var fileName = z49.string().regex(FILE_NAME, "a plain file name inside the server's SwOS folder, no path");
|
|
16112
16176
|
function swosDir(context) {
|
|
16113
|
-
return
|
|
16177
|
+
return join4(context.appConfig.dataDir, "swos");
|
|
16114
16178
|
}
|
|
16115
16179
|
function sha256(data) {
|
|
16116
16180
|
return createHash2("sha256").update(data).digest("hex");
|
|
@@ -16702,6 +16766,47 @@ var swosMaintenanceTools = [
|
|
|
16702
16766
|
// src/domain/tools/fleet-tools.ts
|
|
16703
16767
|
import { z as z50 } from "zod";
|
|
16704
16768
|
|
|
16769
|
+
// src/middleware/authz.ts
|
|
16770
|
+
function matchesPattern(name, pattern) {
|
|
16771
|
+
return globMatch(pattern, name);
|
|
16772
|
+
}
|
|
16773
|
+
function checkAuthz(identity, toolName, routerId2) {
|
|
16774
|
+
if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
|
|
16775
|
+
throw new MikroMCPError({
|
|
16776
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
16777
|
+
code: "ROUTER_NOT_ALLOWED",
|
|
16778
|
+
message: `Identity "${identity.id}" is not allowed to access router "${routerId2}".`,
|
|
16779
|
+
details: { identityId: identity.id, routerId: routerId2, allowedRouters: identity.allowedRouters },
|
|
16780
|
+
recoverability: {
|
|
16781
|
+
retryable: false,
|
|
16782
|
+
suggestedAction: `Add "${routerId2}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
|
|
16783
|
+
}
|
|
16784
|
+
});
|
|
16785
|
+
}
|
|
16786
|
+
checkToolAllowed(identity, toolName);
|
|
16787
|
+
}
|
|
16788
|
+
function checkToolAllowed(identity, toolName) {
|
|
16789
|
+
if (identity.allowedToolPatterns.length > 0) {
|
|
16790
|
+
const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
|
|
16791
|
+
if (!allowed) {
|
|
16792
|
+
throw new MikroMCPError({
|
|
16793
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
16794
|
+
code: "TOOL_NOT_ALLOWED",
|
|
16795
|
+
message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
|
|
16796
|
+
details: {
|
|
16797
|
+
identityId: identity.id,
|
|
16798
|
+
toolName,
|
|
16799
|
+
allowedToolPatterns: identity.allowedToolPatterns
|
|
16800
|
+
},
|
|
16801
|
+
recoverability: {
|
|
16802
|
+
retryable: false,
|
|
16803
|
+
suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
|
|
16804
|
+
}
|
|
16805
|
+
});
|
|
16806
|
+
}
|
|
16807
|
+
}
|
|
16808
|
+
}
|
|
16809
|
+
|
|
16705
16810
|
// src/config/secrets.ts
|
|
16706
16811
|
function getCredentials(router) {
|
|
16707
16812
|
const { source, envPrefix, vaultPath } = router.credentials;
|
|
@@ -17517,6 +17622,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
17517
17622
|
try {
|
|
17518
17623
|
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
17519
17624
|
if (tool.skipRouterContext) {
|
|
17625
|
+
checkToolAllowed(identity, tool.name);
|
|
17520
17626
|
const fleetContext = {
|
|
17521
17627
|
routerClient: fleetUnavailable("routerClient"),
|
|
17522
17628
|
deviceClient: fleetUnavailable("deviceClient"),
|
|
@@ -17696,9 +17802,9 @@ Journal ID: ${journalId} (pass to rollback_change to undo).`;
|
|
|
17696
17802
|
}
|
|
17697
17803
|
|
|
17698
17804
|
// src/middleware/fleet-confirmation.ts
|
|
17699
|
-
import { createHmac as createHmac2, createHash as createHash5, randomBytes as
|
|
17805
|
+
import { createHmac as createHmac2, createHash as createHash5, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
17700
17806
|
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
17701
|
-
var processSecret =
|
|
17807
|
+
var processSecret = randomBytes3(32).toString("hex");
|
|
17702
17808
|
var usedTokens2 = /* @__PURE__ */ new Map();
|
|
17703
17809
|
function fingerprint(args) {
|
|
17704
17810
|
const sortedRouters = [...args.routerIds].sort();
|
|
@@ -18391,8 +18497,9 @@ var RouterConfigSchema = z52.object({
|
|
|
18391
18497
|
}).optional(),
|
|
18392
18498
|
cmdAllow: z52.array(z52.string()).optional(),
|
|
18393
18499
|
cmdDeny: z52.array(z52.string()).optional(),
|
|
18394
|
-
//
|
|
18395
|
-
//
|
|
18500
|
+
// The MIKROMCP_SECRET_<NAME> values this device may use: a switch config
|
|
18501
|
+
// applied to a SwOS switch, create_backup's passwordSecret on RouterOS.
|
|
18502
|
+
// Without the list it may use none.
|
|
18396
18503
|
secrets: z52.array(
|
|
18397
18504
|
z52.string().regex(/^[A-Z0-9_]+$/, "a secret name is the NAME of MIKROMCP_SECRET_<NAME>")
|
|
18398
18505
|
).optional(),
|
|
@@ -18420,13 +18527,6 @@ var RouterConfigSchema = z52.object({
|
|
|
18420
18527
|
message: "rosVersion is required for RouterOS devices"
|
|
18421
18528
|
});
|
|
18422
18529
|
}
|
|
18423
|
-
if (config.secrets) {
|
|
18424
|
-
ctx.addIssue({
|
|
18425
|
-
code: z52.ZodIssueCode.custom,
|
|
18426
|
-
path: ["secrets"],
|
|
18427
|
-
message: "secrets binds switch config secrets and applies to SwOS devices only"
|
|
18428
|
-
});
|
|
18429
|
-
}
|
|
18430
18530
|
return;
|
|
18431
18531
|
}
|
|
18432
18532
|
if (config.tls?.enabled) {
|
|
@@ -18599,7 +18699,7 @@ var ConnectionPool = class {
|
|
|
18599
18699
|
};
|
|
18600
18700
|
|
|
18601
18701
|
// src/version.ts
|
|
18602
|
-
var VERSION2 = "1.
|
|
18702
|
+
var VERSION2 = "1.16.0";
|
|
18603
18703
|
|
|
18604
18704
|
// src/mcp/server.ts
|
|
18605
18705
|
var SERVER_INSTRUCTIONS = [
|
|
@@ -18987,8 +19087,8 @@ ${issues}`);
|
|
|
18987
19087
|
};
|
|
18988
19088
|
|
|
18989
19089
|
// src/domain/snapshot/retention.ts
|
|
18990
|
-
import { readdir, stat, unlink } from "fs/promises";
|
|
18991
|
-
import { join as
|
|
19090
|
+
import { readdir, readFile as readFile3, rename, stat, unlink } from "fs/promises";
|
|
19091
|
+
import { join as join5 } from "path";
|
|
18992
19092
|
var log56 = createLogger("snapshot-retention");
|
|
18993
19093
|
async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
18994
19094
|
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
@@ -19000,7 +19100,7 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
19000
19100
|
return 0;
|
|
19001
19101
|
}
|
|
19002
19102
|
for (const routerDir of routerDirs) {
|
|
19003
|
-
const dirPath =
|
|
19103
|
+
const dirPath = join5(snapshotDir, routerDir);
|
|
19004
19104
|
let files;
|
|
19005
19105
|
try {
|
|
19006
19106
|
files = await readdir(dirPath);
|
|
@@ -19008,7 +19108,7 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
19008
19108
|
continue;
|
|
19009
19109
|
}
|
|
19010
19110
|
for (const file of files) {
|
|
19011
|
-
const filePath =
|
|
19111
|
+
const filePath = join5(dirPath, file);
|
|
19012
19112
|
try {
|
|
19013
19113
|
const info = await stat(filePath);
|
|
19014
19114
|
if (info.isFile() && info.mtimeMs < cutoffMs) {
|
|
@@ -19025,6 +19125,41 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
19025
19125
|
}
|
|
19026
19126
|
return removed;
|
|
19027
19127
|
}
|
|
19128
|
+
var JOURNAL_ROTATE_AFTER_MS = 864e5;
|
|
19129
|
+
async function rotateAndPruneJournal(journalPath, maxAgeDays, now = /* @__PURE__ */ new Date()) {
|
|
19130
|
+
const cutoffMs = now.getTime() - maxAgeDays * 864e5;
|
|
19131
|
+
let removed = 0;
|
|
19132
|
+
for (const segment of journalSegmentPaths(journalPath)) {
|
|
19133
|
+
try {
|
|
19134
|
+
if (journalSegmentRotatedAt(segment) < cutoffMs && (await stat(segment)).mtimeMs < cutoffMs) {
|
|
19135
|
+
await unlink(segment);
|
|
19136
|
+
removed++;
|
|
19137
|
+
}
|
|
19138
|
+
} catch (err) {
|
|
19139
|
+
if (err.code !== "ENOENT") {
|
|
19140
|
+
log56.warn({ err, segment }, "Failed to prune a write-journal segment");
|
|
19141
|
+
}
|
|
19142
|
+
}
|
|
19143
|
+
}
|
|
19144
|
+
if (removed > 0) {
|
|
19145
|
+
log56.info({ removed, maxAgeDays }, "Pruned expired write-journal segments");
|
|
19146
|
+
}
|
|
19147
|
+
let rotated = false;
|
|
19148
|
+
try {
|
|
19149
|
+
const oldest = parseJournal(await readFile3(journalPath, "utf-8")).find(
|
|
19150
|
+
(e) => typeof e.ts === "string" && Number.isFinite(Date.parse(e.ts))
|
|
19151
|
+
);
|
|
19152
|
+
if (oldest && Date.parse(oldest.ts) <= now.getTime() - JOURNAL_ROTATE_AFTER_MS) {
|
|
19153
|
+
await rename(journalPath, journalSegmentName(journalPath, now));
|
|
19154
|
+
rotated = true;
|
|
19155
|
+
}
|
|
19156
|
+
} catch (err) {
|
|
19157
|
+
if (err.code !== "ENOENT") {
|
|
19158
|
+
log56.warn({ err, journalPath }, "Failed to rotate the write journal");
|
|
19159
|
+
}
|
|
19160
|
+
}
|
|
19161
|
+
return { rotated, removed };
|
|
19162
|
+
}
|
|
19028
19163
|
|
|
19029
19164
|
// src/cli/serve.ts
|
|
19030
19165
|
var log57 = createLogger("main");
|
|
@@ -19032,6 +19167,7 @@ async function runServe() {
|
|
|
19032
19167
|
const config = loadAppConfig();
|
|
19033
19168
|
log57.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
|
|
19034
19169
|
void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
|
|
19170
|
+
void rotateAndPruneJournal(config.journalPath, config.retention.snapshotMaxAgeDays);
|
|
19035
19171
|
const identityRegistry = new IdentityRegistry(config.identitiesPath);
|
|
19036
19172
|
if (config.transport === "http") {
|
|
19037
19173
|
const hasLimitedRoles = identityRegistry.getIdentities().some((i) => i.role === "readonly" || i.role === "operator");
|
|
@@ -19077,7 +19213,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
|
|
|
19077
19213
|
import { homedir as homedir2 } from "os";
|
|
19078
19214
|
import * as net from "net";
|
|
19079
19215
|
import chalk from "chalk";
|
|
19080
|
-
var localVersion = "1.
|
|
19216
|
+
var localVersion = "1.16.0";
|
|
19081
19217
|
var results = [];
|
|
19082
19218
|
function ok(msg) {
|
|
19083
19219
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -19168,7 +19304,16 @@ function checkEnvVars(routers, transport2) {
|
|
|
19168
19304
|
}
|
|
19169
19305
|
const secretNames = Object.keys(process.env).filter((k) => k.startsWith("MIKROMCP_SECRET_"));
|
|
19170
19306
|
for (const router of routers) {
|
|
19171
|
-
if (router.deviceType !== "swos")
|
|
19307
|
+
if (router.deviceType !== "swos") {
|
|
19308
|
+
for (const name of router.secrets ?? []) {
|
|
19309
|
+
if (!process.env[`MIKROMCP_SECRET_${name}`]) {
|
|
19310
|
+
warn(
|
|
19311
|
+
`${router.id} binds secret ${name}, but MIKROMCP_SECRET_${name} is not set or empty \u2014 create_backup with passwordSecret ${name} will be refused`
|
|
19312
|
+
);
|
|
19313
|
+
}
|
|
19314
|
+
}
|
|
19315
|
+
continue;
|
|
19316
|
+
}
|
|
19172
19317
|
if (router.secrets) {
|
|
19173
19318
|
for (const name of router.secrets) {
|
|
19174
19319
|
if (!process.env[`MIKROMCP_SECRET_${name}`]) {
|
|
@@ -19474,18 +19619,18 @@ async function runDoctor() {
|
|
|
19474
19619
|
|
|
19475
19620
|
// src/cli/init.ts
|
|
19476
19621
|
import { input, confirm, select } from "@inquirer/prompts";
|
|
19477
|
-
import { randomBytes as
|
|
19622
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
19478
19623
|
import { existsSync as existsSync4, readFileSync as readFileSync8, writeFileSync, mkdirSync, copyFileSync } from "fs";
|
|
19479
19624
|
import { homedir as homedir3 } from "os";
|
|
19480
|
-
import { join as
|
|
19625
|
+
import { join as join6 } from "path";
|
|
19481
19626
|
import { parse as yamlParse, Document, visit } from "yaml";
|
|
19482
19627
|
import bcrypt2 from "bcryptjs";
|
|
19483
19628
|
import chalk2 from "chalk";
|
|
19484
19629
|
function mikromcpDir2() {
|
|
19485
|
-
return
|
|
19630
|
+
return join6(homedir3(), ".mikromcp");
|
|
19486
19631
|
}
|
|
19487
19632
|
function defaultAuditLogPath() {
|
|
19488
|
-
return
|
|
19633
|
+
return join6(mikromcpDir2(), "audit.ndjson");
|
|
19489
19634
|
}
|
|
19490
19635
|
function toYaml2(obj) {
|
|
19491
19636
|
const doc = new Document(obj);
|
|
@@ -19513,8 +19658,8 @@ function splitTags(val) {
|
|
|
19513
19658
|
}
|
|
19514
19659
|
function findClaudeDesktopConfig() {
|
|
19515
19660
|
const candidates = [
|
|
19516
|
-
|
|
19517
|
-
|
|
19661
|
+
join6(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
19662
|
+
join6(homedir3(), ".config", "Claude", "claude_desktop_config.json")
|
|
19518
19663
|
];
|
|
19519
19664
|
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
19520
19665
|
}
|
|
@@ -19634,7 +19779,7 @@ async function collectIdentityInfo() {
|
|
|
19634
19779
|
message: "Allowed tool patterns (comma-separated or * for all):",
|
|
19635
19780
|
default: "*"
|
|
19636
19781
|
});
|
|
19637
|
-
const rawToken =
|
|
19782
|
+
const rawToken = randomBytes4(32).toString("hex");
|
|
19638
19783
|
console.log(chalk2.green(`
|
|
19639
19784
|
Generated token: ${chalk2.bold(rawToken)}`));
|
|
19640
19785
|
console.log(chalk2.yellow(" \u26A0 Save this token \u2014 it will NOT be shown again.\n"));
|
|
@@ -19682,7 +19827,7 @@ async function collectClaudeDesktopPreference() {
|
|
|
19682
19827
|
return confirm({ message: "Register with Claude Desktop?", default: true });
|
|
19683
19828
|
}
|
|
19684
19829
|
function writeRoutersYaml(data) {
|
|
19685
|
-
const filePath =
|
|
19830
|
+
const filePath = join6(data.configDir, "routers.yaml");
|
|
19686
19831
|
const newEntry = {
|
|
19687
19832
|
host: data.host,
|
|
19688
19833
|
port: data.port,
|
|
@@ -19709,7 +19854,7 @@ function writeRoutersYaml(data) {
|
|
|
19709
19854
|
return true;
|
|
19710
19855
|
}
|
|
19711
19856
|
function writeIdentitiesYaml(data) {
|
|
19712
|
-
const filePath =
|
|
19857
|
+
const filePath = join6(data.configDir, "identities.yaml");
|
|
19713
19858
|
const newEntry = {
|
|
19714
19859
|
token: data.tokenHash,
|
|
19715
19860
|
role: data.role,
|
|
@@ -19731,7 +19876,7 @@ function writeDotEnv(data) {
|
|
|
19731
19876
|
const userKey = `${data.envPrefix}_USER`;
|
|
19732
19877
|
const passKey = `${data.envPrefix}_PASS`;
|
|
19733
19878
|
const auditLogPath = defaultAuditLogPath();
|
|
19734
|
-
const confirmationSecret = data.transport === "http" ?
|
|
19879
|
+
const confirmationSecret = data.transport === "http" ? randomBytes4(32).toString("hex") : "";
|
|
19735
19880
|
const lines = [
|
|
19736
19881
|
"# \u2500\u2500 Router credentials \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
19737
19882
|
`${userKey}=${data.routerUser}`,
|
|
@@ -19743,8 +19888,8 @@ function writeDotEnv(data) {
|
|
|
19743
19888
|
`MIKROMCP_TRANSPORT=${data.transport}`,
|
|
19744
19889
|
"",
|
|
19745
19890
|
"# \u2500\u2500 Config paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
19746
|
-
`MIKROMCP_CONFIG_PATH=${
|
|
19747
|
-
`MIKROMCP_IDENTITIES_PATH=${
|
|
19891
|
+
`MIKROMCP_CONFIG_PATH=${join6(data.configDir, "routers.yaml")}`,
|
|
19892
|
+
`MIKROMCP_IDENTITIES_PATH=${join6(data.configDir, "identities.yaml")}`,
|
|
19748
19893
|
"",
|
|
19749
19894
|
"# \u2500\u2500 Default router \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
19750
19895
|
"# Router used when a tool call omits routerId (handy for single-router setups).",
|
|
@@ -19798,9 +19943,9 @@ function registerClaudeDesktop() {
|
|
|
19798
19943
|
async function runInit() {
|
|
19799
19944
|
console.log(chalk2.bold.cyan("\n\u{1F527} MikroMCP \u2014 Interactive Setup Wizard\n"));
|
|
19800
19945
|
const configDir = mikromcpDir2();
|
|
19801
|
-
const envPath =
|
|
19946
|
+
const envPath = join6(configDir, ".env");
|
|
19802
19947
|
const existingFiles = [".env", "routers.yaml", "identities.yaml"].filter(
|
|
19803
|
-
(f2) => existsSync4(
|
|
19948
|
+
(f2) => existsSync4(join6(configDir, f2))
|
|
19804
19949
|
);
|
|
19805
19950
|
if (existingFiles.length > 0) {
|
|
19806
19951
|
console.log(chalk2.yellow(` \u26A0 Found existing files in ${configDir}:`));
|
|
@@ -19838,12 +19983,12 @@ async function runInit() {
|
|
|
19838
19983
|
if (data.writeRoutersYaml) {
|
|
19839
19984
|
writeRoutersYaml(data);
|
|
19840
19985
|
summary.push(`routers.yaml written to ${data.configDir}`);
|
|
19841
|
-
console.log(chalk2.green(` \u2714 ${
|
|
19986
|
+
console.log(chalk2.green(` \u2714 ${join6(data.configDir, "routers.yaml")}`));
|
|
19842
19987
|
}
|
|
19843
19988
|
if (data.writeIdentitiesYaml) {
|
|
19844
19989
|
writeIdentitiesYaml(data);
|
|
19845
19990
|
summary.push(`identities.yaml written to ${data.configDir}`);
|
|
19846
|
-
console.log(chalk2.green(` \u2714 ${
|
|
19991
|
+
console.log(chalk2.green(` \u2714 ${join6(data.configDir, "identities.yaml")}`));
|
|
19847
19992
|
}
|
|
19848
19993
|
if (data.writeEnv) {
|
|
19849
19994
|
writeDotEnv(data);
|
|
@@ -19893,7 +20038,7 @@ async function runInit() {
|
|
|
19893
20038
|
}
|
|
19894
20039
|
|
|
19895
20040
|
// src/cli/index.ts
|
|
19896
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.
|
|
20041
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.16.0");
|
|
19897
20042
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
19898
20043
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
19899
20044
|
program.command("init").description("Interactive setup wizard").action(runInit);
|
|
@@ -19903,6 +20048,6 @@ program.command("update").description("Update mikromcp to the latest version via
|
|
|
19903
20048
|
});
|
|
19904
20049
|
|
|
19905
20050
|
// src/main.ts
|
|
19906
|
-
loadDotenv({ path:
|
|
20051
|
+
loadDotenv({ path: join7(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
|
|
19907
20052
|
program.parse();
|
|
19908
20053
|
//# sourceMappingURL=main.js.map
|