mikromcp 1.7.0 → 1.8.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/dist/main.js +1591 -1219
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -12,15 +12,80 @@ import { spawnSync } from "child_process";
|
|
|
12
12
|
// src/config/app-config.ts
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { homedir } from "os";
|
|
15
|
+
|
|
16
|
+
// src/domain/errors/error-types.ts
|
|
17
|
+
var MikroMCPError = class extends Error {
|
|
18
|
+
category;
|
|
19
|
+
code;
|
|
20
|
+
details;
|
|
21
|
+
recoverability;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
super(options.message);
|
|
24
|
+
this.name = "MikroMCPError";
|
|
25
|
+
this.category = options.category;
|
|
26
|
+
this.code = options.code;
|
|
27
|
+
this.details = options.details;
|
|
28
|
+
this.recoverability = options.recoverability;
|
|
29
|
+
if (options.cause !== void 0) {
|
|
30
|
+
this.cause = options.cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Serialize to a plain JSON-safe object. */
|
|
34
|
+
toJSON() {
|
|
35
|
+
return {
|
|
36
|
+
name: this.name,
|
|
37
|
+
category: this.category,
|
|
38
|
+
code: this.code,
|
|
39
|
+
message: this.message,
|
|
40
|
+
details: this.details,
|
|
41
|
+
recoverability: this.recoverability
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Convert to the wire-format error data used in DomainResult. */
|
|
45
|
+
toMikroMCPErrorData() {
|
|
46
|
+
return {
|
|
47
|
+
category: this.category,
|
|
48
|
+
code: this.code,
|
|
49
|
+
message: this.message,
|
|
50
|
+
details: this.details,
|
|
51
|
+
recoverability: {
|
|
52
|
+
retryable: this.recoverability.retryable,
|
|
53
|
+
retryAfterMs: this.recoverability.retryAfterMs,
|
|
54
|
+
suggestedAction: this.recoverability.suggestedAction,
|
|
55
|
+
alternativeTools: this.recoverability.alternativeTools
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/config/app-config.ts
|
|
15
62
|
function mikromcpDir() {
|
|
16
63
|
return join(homedir(), ".mikromcp");
|
|
17
64
|
}
|
|
65
|
+
function intEnv(name, fallback) {
|
|
66
|
+
const raw = process.env[name];
|
|
67
|
+
if (raw === void 0 || raw === "") return fallback;
|
|
68
|
+
const value = Number(raw);
|
|
69
|
+
if (!Number.isInteger(value)) {
|
|
70
|
+
throw new MikroMCPError({
|
|
71
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
72
|
+
code: "INVALID_ENV_INT",
|
|
73
|
+
message: `Environment variable ${name} must be an integer, got "${raw}".`,
|
|
74
|
+
details: { variable: name, value: raw },
|
|
75
|
+
recoverability: {
|
|
76
|
+
retryable: false,
|
|
77
|
+
suggestedAction: `Set ${name} to an integer, or unset it to use the default (${fallback}).`
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
18
83
|
function loadAppConfig() {
|
|
19
84
|
const env = process.env;
|
|
20
85
|
const dataDir = env.MIKROMCP_DATA_DIR ?? join(mikromcpDir(), "data");
|
|
21
86
|
return {
|
|
22
87
|
transport: env.MIKROMCP_TRANSPORT === "http" ? "http" : "stdio",
|
|
23
|
-
port:
|
|
88
|
+
port: intEnv("MIKROMCP_PORT", 3e3),
|
|
24
89
|
bindHost: env.MIKROMCP_BIND_HOST ?? "127.0.0.1",
|
|
25
90
|
logLevel: env.MIKROMCP_LOG_LEVEL ?? "info",
|
|
26
91
|
configPath: env.MIKROMCP_CONFIG_PATH ?? join(mikromcpDir(), "routers.yaml"),
|
|
@@ -35,12 +100,12 @@ function loadAppConfig() {
|
|
|
35
100
|
confirmationSecret: env.MIKROMCP_CONFIRMATION_SECRET || void 0,
|
|
36
101
|
auditLogPath: env.MIKROMCP_AUDIT_LOG_PATH || void 0,
|
|
37
102
|
http: {
|
|
38
|
-
maxBodyBytes:
|
|
39
|
-
rateLimitRpm:
|
|
103
|
+
maxBodyBytes: intEnv("MIKROMCP_HTTP_MAX_BODY_BYTES", 1024 * 1024),
|
|
104
|
+
rateLimitRpm: intEnv("MIKROMCP_HTTP_RATE_LIMIT_RPM", 60)
|
|
40
105
|
},
|
|
41
106
|
ssh: {
|
|
42
|
-
commandTimeoutMs:
|
|
43
|
-
maxOutputBytes:
|
|
107
|
+
commandTimeoutMs: intEnv("MIKROMCP_SSH_COMMAND_TIMEOUT_MS", 3e4),
|
|
108
|
+
maxOutputBytes: intEnv("MIKROMCP_SSH_MAX_OUTPUT_BYTES", 512 * 1024)
|
|
44
109
|
},
|
|
45
110
|
retry: {
|
|
46
111
|
maxRetries: 3,
|
|
@@ -52,7 +117,7 @@ function loadAppConfig() {
|
|
|
52
117
|
cooldownMs: 3e4
|
|
53
118
|
},
|
|
54
119
|
retention: {
|
|
55
|
-
snapshotMaxAgeDays:
|
|
120
|
+
snapshotMaxAgeDays: intEnv("MIKROMCP_SNAPSHOT_RETENTION_DAYS", 30)
|
|
56
121
|
}
|
|
57
122
|
};
|
|
58
123
|
}
|
|
@@ -75,53 +140,6 @@ var limit = z.number().int().min(1).max(500).default(100).describe("Max results
|
|
|
75
140
|
|
|
76
141
|
// src/domain/errors/error-enricher.ts
|
|
77
142
|
import { ZodError } from "zod";
|
|
78
|
-
|
|
79
|
-
// src/domain/errors/error-types.ts
|
|
80
|
-
var MikroMCPError = class extends Error {
|
|
81
|
-
category;
|
|
82
|
-
code;
|
|
83
|
-
details;
|
|
84
|
-
recoverability;
|
|
85
|
-
constructor(options) {
|
|
86
|
-
super(options.message);
|
|
87
|
-
this.name = "MikroMCPError";
|
|
88
|
-
this.category = options.category;
|
|
89
|
-
this.code = options.code;
|
|
90
|
-
this.details = options.details;
|
|
91
|
-
this.recoverability = options.recoverability;
|
|
92
|
-
if (options.cause !== void 0) {
|
|
93
|
-
this.cause = options.cause;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/** Serialize to a plain JSON-safe object. */
|
|
97
|
-
toJSON() {
|
|
98
|
-
return {
|
|
99
|
-
name: this.name,
|
|
100
|
-
category: this.category,
|
|
101
|
-
code: this.code,
|
|
102
|
-
message: this.message,
|
|
103
|
-
details: this.details,
|
|
104
|
-
recoverability: this.recoverability
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
/** Convert to the wire-format error data used in DomainResult. */
|
|
108
|
-
toMikroMCPErrorData() {
|
|
109
|
-
return {
|
|
110
|
-
category: this.category,
|
|
111
|
-
code: this.code,
|
|
112
|
-
message: this.message,
|
|
113
|
-
details: this.details,
|
|
114
|
-
recoverability: {
|
|
115
|
-
retryable: this.recoverability.retryable,
|
|
116
|
-
retryAfterMs: this.recoverability.retryAfterMs,
|
|
117
|
-
suggestedAction: this.recoverability.suggestedAction,
|
|
118
|
-
alternativeTools: this.recoverability.alternativeTools
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
// src/domain/errors/error-enricher.ts
|
|
125
143
|
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
126
144
|
"ECONNREFUSED",
|
|
127
145
|
"ECONNRESET",
|
|
@@ -441,6 +459,41 @@ var systemTools = [getSystemStatusTool];
|
|
|
441
459
|
// src/domain/tools/interface-tools.ts
|
|
442
460
|
import { z as z3 } from "zod";
|
|
443
461
|
|
|
462
|
+
// src/adapter/response-parser.ts
|
|
463
|
+
var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
464
|
+
var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
|
|
465
|
+
function parseRouterOSValue(key2, value) {
|
|
466
|
+
if (key2 === ".id") {
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
if (value === "true") return true;
|
|
470
|
+
if (value === "false") return false;
|
|
471
|
+
if (DURATION_RE.test(value)) {
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
if (NUMERIC_RE.test(value)) {
|
|
475
|
+
const num = Number(value);
|
|
476
|
+
const isInteger = !value.includes(".");
|
|
477
|
+
if (isInteger ? Number.isSafeInteger(num) : Number.isFinite(num)) {
|
|
478
|
+
return num;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
function isTrue(v) {
|
|
484
|
+
return v === true || v === "true" || v === "yes";
|
|
485
|
+
}
|
|
486
|
+
function parseRecord(raw) {
|
|
487
|
+
const parsed = {};
|
|
488
|
+
for (const [key2, value] of Object.entries(raw)) {
|
|
489
|
+
parsed[key2] = parseRouterOSValue(key2, value);
|
|
490
|
+
}
|
|
491
|
+
return parsed;
|
|
492
|
+
}
|
|
493
|
+
function parseRecords(raw) {
|
|
494
|
+
return raw.map((r) => parseRecord(r));
|
|
495
|
+
}
|
|
496
|
+
|
|
444
497
|
// src/domain/tools/pagination.ts
|
|
445
498
|
function paginate(items, offset2, limit2) {
|
|
446
499
|
const total = items.length;
|
|
@@ -528,7 +581,7 @@ var listInterfacesTool = {
|
|
|
528
581
|
if (parsed.status !== "all") {
|
|
529
582
|
interfaces = interfaces.filter((iface) => {
|
|
530
583
|
const running = iface.running;
|
|
531
|
-
const isUp = running
|
|
584
|
+
const isUp = isTrue(running);
|
|
532
585
|
if (parsed.status === "up") return isUp;
|
|
533
586
|
if (parsed.status === "down") return !isUp;
|
|
534
587
|
return true;
|
|
@@ -537,8 +590,8 @@ var listInterfacesTool = {
|
|
|
537
590
|
const { items: paginated, total, hasMore } = paginate(interfaces, parsed.offset, parsed.limit);
|
|
538
591
|
const results2 = paginated.map((iface) => {
|
|
539
592
|
const rec = iface;
|
|
540
|
-
const isRunning2 = rec.running
|
|
541
|
-
const isDisabled = rec.disabled
|
|
593
|
+
const isRunning2 = isTrue(rec.running);
|
|
594
|
+
const isDisabled = isTrue(rec.disabled);
|
|
542
595
|
const enriched = { status: isDisabled ? "disabled" : isRunning2 ? "up" : "down" };
|
|
543
596
|
for (const [key2, value] of Object.entries(rec)) {
|
|
544
597
|
if (parsed.includeCounters || !COUNTER_PROPS.includes(key2)) {
|
|
@@ -660,7 +713,7 @@ var manageIpAddressTool = {
|
|
|
660
713
|
if (parsed.action === "add") {
|
|
661
714
|
if (existing) {
|
|
662
715
|
const rec = existing;
|
|
663
|
-
const sameDisabled = rec.disabled ===
|
|
716
|
+
const sameDisabled = isTrue(rec.disabled) === parsed.disabled;
|
|
664
717
|
const sameComment = (rec.comment ?? "") === (comment ?? "");
|
|
665
718
|
const sameNetwork = !parsed.network || rec.network === parsed.network;
|
|
666
719
|
if (sameDisabled && sameComment && sameNetwork) {
|
|
@@ -887,7 +940,7 @@ var listDhcpLeasesTool = {
|
|
|
887
940
|
if (parsed.leaseType !== "all") {
|
|
888
941
|
leases = leases.filter((lease) => {
|
|
889
942
|
const rec = lease;
|
|
890
|
-
const isDynamic = rec.dynamic
|
|
943
|
+
const isDynamic = isTrue(rec.dynamic);
|
|
891
944
|
if (parsed.leaseType === "dynamic") return isDynamic;
|
|
892
945
|
return !isDynamic;
|
|
893
946
|
});
|
|
@@ -966,7 +1019,7 @@ var manageDhcpLeaseTool = {
|
|
|
966
1019
|
});
|
|
967
1020
|
}
|
|
968
1021
|
const existingDynamic = existing.dynamic;
|
|
969
|
-
const isAlreadyStatic = existingDynamic
|
|
1022
|
+
const isAlreadyStatic = !isTrue(existingDynamic);
|
|
970
1023
|
if (isAlreadyStatic) {
|
|
971
1024
|
return {
|
|
972
1025
|
content: `Lease for ${upperMac} (${existing.address ?? "unknown"}) is already static. No changes made.`,
|
|
@@ -1059,13 +1112,13 @@ var listRoutesTool = {
|
|
|
1059
1112
|
if (parsed.activeOnly) {
|
|
1060
1113
|
routes = routes.filter((route) => {
|
|
1061
1114
|
const rec = route;
|
|
1062
|
-
return rec.active
|
|
1115
|
+
return isTrue(rec.active);
|
|
1063
1116
|
});
|
|
1064
1117
|
}
|
|
1065
1118
|
if (parsed.staticOnly) {
|
|
1066
1119
|
routes = routes.filter((route) => {
|
|
1067
1120
|
const rec = route;
|
|
1068
|
-
return rec.dynamic
|
|
1121
|
+
return !isTrue(rec.dynamic);
|
|
1069
1122
|
});
|
|
1070
1123
|
}
|
|
1071
1124
|
const { items: paginated, total, hasMore } = paginate(routes, parsed.offset, parsed.limit);
|
|
@@ -1154,7 +1207,7 @@ var manageRouteTool = {
|
|
|
1154
1207
|
if (existing) {
|
|
1155
1208
|
const rec = existing;
|
|
1156
1209
|
const existingDistance = rec.distance ?? "1";
|
|
1157
|
-
const existingDisabled = rec.disabled
|
|
1210
|
+
const existingDisabled = isTrue(rec.disabled);
|
|
1158
1211
|
const sameDistance = existingDistance === String(parsed.distance);
|
|
1159
1212
|
const sameDisabled = existingDisabled === parsed.disabled;
|
|
1160
1213
|
if (sameDistance && sameDisabled) {
|
|
@@ -1328,7 +1381,7 @@ var listFirewallRulesTool = {
|
|
|
1328
1381
|
if (parsed.disabled !== "all") {
|
|
1329
1382
|
rules = rules.filter((r) => {
|
|
1330
1383
|
const rec = r;
|
|
1331
|
-
const isDisabled = rec.disabled
|
|
1384
|
+
const isDisabled = isTrue(rec.disabled);
|
|
1332
1385
|
return isDisabled === (parsed.disabled === "true");
|
|
1333
1386
|
});
|
|
1334
1387
|
}
|
|
@@ -1572,7 +1625,7 @@ var manageFirewallRuleTool = {
|
|
|
1572
1625
|
});
|
|
1573
1626
|
}
|
|
1574
1627
|
const id = existing[".id"];
|
|
1575
|
-
const isDisabled = existing.disabled
|
|
1628
|
+
const isDisabled = isTrue(existing.disabled);
|
|
1576
1629
|
if (isDisabled === wantDisabled) {
|
|
1577
1630
|
return {
|
|
1578
1631
|
content: `Firewall ${parsed.table} rule with comment "${comment}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
|
|
@@ -1948,6 +2001,12 @@ var diagnosticTools = [pingTool, tracerouteTool, torchTool, getLogTool];
|
|
|
1948
2001
|
// src/domain/tools/system-ops-tools.ts
|
|
1949
2002
|
import { z as z10 } from "zod";
|
|
1950
2003
|
|
|
2004
|
+
// src/util/glob.ts
|
|
2005
|
+
function globMatch(pattern, value) {
|
|
2006
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
2007
|
+
return new RegExp(`^${escaped}$`, "i").test(value);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1951
2010
|
// src/domain/tools/command-guard.ts
|
|
1952
2011
|
var BUILTIN_DENY_PATTERNS = [
|
|
1953
2012
|
"/system shutdown*",
|
|
@@ -1959,14 +2018,20 @@ var BUILTIN_DENY_PATTERNS = [
|
|
|
1959
2018
|
"/ip service set*",
|
|
1960
2019
|
"/certificate*private-key*",
|
|
1961
2020
|
"/system package uninstall*",
|
|
1962
|
-
"/system routerboard upgrade*"
|
|
2021
|
+
"/system routerboard upgrade*",
|
|
2022
|
+
// Block indirection primitives that would otherwise smuggle a denied command
|
|
2023
|
+
// past a segment-wise check.
|
|
2024
|
+
"*:execute*",
|
|
2025
|
+
"*:parse*"
|
|
1963
2026
|
];
|
|
1964
2027
|
var DENIED_TOOL_SUGGESTIONS = {
|
|
1965
2028
|
"/system reboot*": "reboot"
|
|
1966
2029
|
};
|
|
1967
|
-
function
|
|
1968
|
-
|
|
1969
|
-
|
|
2030
|
+
function normalizeCommand(raw) {
|
|
2031
|
+
return raw.replace(/\//g, " ").replace(/\s+/g, " ").trim();
|
|
2032
|
+
}
|
|
2033
|
+
function segments(raw) {
|
|
2034
|
+
return raw.split(/[;\n]/).map((s) => normalizeCommand(s)).filter(Boolean);
|
|
1970
2035
|
}
|
|
1971
2036
|
function resolveCommandPolicy(routerConfig, globalAllow, globalDeny) {
|
|
1972
2037
|
const routerDeny = routerConfig.cmdDeny ?? [];
|
|
@@ -1977,8 +2042,10 @@ function resolveCommandPolicy(routerConfig, globalAllow, globalDeny) {
|
|
|
1977
2042
|
};
|
|
1978
2043
|
}
|
|
1979
2044
|
function checkCommand(command, policy) {
|
|
2045
|
+
const normSegs = segments(command);
|
|
1980
2046
|
for (const pattern of policy.deny) {
|
|
1981
|
-
|
|
2047
|
+
const normPattern = normalizeCommand(pattern);
|
|
2048
|
+
if (normSegs.some((seg) => globMatch(normPattern, seg))) {
|
|
1982
2049
|
const suggestedTool = DENIED_TOOL_SUGGESTIONS[pattern];
|
|
1983
2050
|
throw new MikroMCPError({
|
|
1984
2051
|
category: "VALIDATION" /* VALIDATION */,
|
|
@@ -1993,17 +2060,23 @@ function checkCommand(command, policy) {
|
|
|
1993
2060
|
});
|
|
1994
2061
|
}
|
|
1995
2062
|
}
|
|
1996
|
-
if (policy.allow.length > 0
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2063
|
+
if (policy.allow.length > 0) {
|
|
2064
|
+
const normAllow = policy.allow.map((p) => normalizeCommand(p));
|
|
2065
|
+
const everySegmentAllowed = normSegs.every(
|
|
2066
|
+
(seg) => normAllow.some((p) => globMatch(p, seg))
|
|
2067
|
+
);
|
|
2068
|
+
if (!everySegmentAllowed) {
|
|
2069
|
+
throw new MikroMCPError({
|
|
2070
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
2071
|
+
code: "COMMAND_NOT_ALLOWED",
|
|
2072
|
+
message: "Command not in allow list",
|
|
2073
|
+
details: { command, allowPatterns: policy.allow },
|
|
2074
|
+
recoverability: {
|
|
2075
|
+
retryable: false,
|
|
2076
|
+
suggestedAction: "The command does not match any pattern in the configured allow list."
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2007
2080
|
}
|
|
2008
2081
|
}
|
|
2009
2082
|
|
|
@@ -2599,7 +2672,7 @@ var manageWifiTool = {
|
|
|
2599
2672
|
inputSchema: manageWifiInputSchema,
|
|
2600
2673
|
annotations: {
|
|
2601
2674
|
readOnlyHint: false,
|
|
2602
|
-
destructiveHint:
|
|
2675
|
+
destructiveHint: true,
|
|
2603
2676
|
idempotentHint: true,
|
|
2604
2677
|
openWorldHint: false
|
|
2605
2678
|
},
|
|
@@ -2890,7 +2963,7 @@ var manageWgIfaceTool = {
|
|
|
2890
2963
|
inputSchema: manageWgIfaceInputSchema,
|
|
2891
2964
|
annotations: {
|
|
2892
2965
|
readOnlyHint: false,
|
|
2893
|
-
destructiveHint:
|
|
2966
|
+
destructiveHint: true,
|
|
2894
2967
|
idempotentHint: true,
|
|
2895
2968
|
openWorldHint: false
|
|
2896
2969
|
},
|
|
@@ -3267,7 +3340,7 @@ var manageDnsSettingsTool = {
|
|
|
3267
3340
|
inputSchema: manageDnsSettingsInputSchema,
|
|
3268
3341
|
annotations: {
|
|
3269
3342
|
readOnlyHint: false,
|
|
3270
|
-
destructiveHint:
|
|
3343
|
+
destructiveHint: true,
|
|
3271
3344
|
idempotentHint: true,
|
|
3272
3345
|
openWorldHint: false
|
|
3273
3346
|
},
|
|
@@ -3379,7 +3452,7 @@ var listMangleRulesTool = {
|
|
|
3379
3452
|
if (parsed.disabled !== void 0) {
|
|
3380
3453
|
rules = rules.filter((r) => {
|
|
3381
3454
|
const rec = r;
|
|
3382
|
-
const isDisabled = rec.disabled
|
|
3455
|
+
const isDisabled = isTrue(rec.disabled);
|
|
3383
3456
|
return isDisabled === parsed.disabled;
|
|
3384
3457
|
});
|
|
3385
3458
|
}
|
|
@@ -3435,7 +3508,7 @@ var manageMangleRuleTool = {
|
|
|
3435
3508
|
inputSchema: manageMangleRuleInputSchema,
|
|
3436
3509
|
annotations: {
|
|
3437
3510
|
readOnlyHint: false,
|
|
3438
|
-
destructiveHint:
|
|
3511
|
+
destructiveHint: true,
|
|
3439
3512
|
idempotentHint: true,
|
|
3440
3513
|
openWorldHint: false
|
|
3441
3514
|
},
|
|
@@ -3576,7 +3649,7 @@ var manageMangleRuleTool = {
|
|
|
3576
3649
|
});
|
|
3577
3650
|
}
|
|
3578
3651
|
const id = existing[".id"];
|
|
3579
|
-
const isDisabled = existing.disabled
|
|
3652
|
+
const isDisabled = isTrue(existing.disabled);
|
|
3580
3653
|
if (isDisabled === wantDisabled) {
|
|
3581
3654
|
return {
|
|
3582
3655
|
content: `Mangle rule with comment "${parsed.comment}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
|
|
@@ -3681,7 +3754,7 @@ var manageAddressListTool = {
|
|
|
3681
3754
|
inputSchema: manageAddressListInputSchema,
|
|
3682
3755
|
annotations: {
|
|
3683
3756
|
readOnlyHint: false,
|
|
3684
|
-
destructiveHint:
|
|
3757
|
+
destructiveHint: true,
|
|
3685
3758
|
idempotentHint: true,
|
|
3686
3759
|
openWorldHint: false
|
|
3687
3760
|
},
|
|
@@ -3806,7 +3879,7 @@ var listRoutingRulesTool = {
|
|
|
3806
3879
|
if (parsed.disabled !== void 0) {
|
|
3807
3880
|
rules = rules.filter((r) => {
|
|
3808
3881
|
const rec = r;
|
|
3809
|
-
const isDisabled = rec.disabled
|
|
3882
|
+
const isDisabled = isTrue(rec.disabled);
|
|
3810
3883
|
return isDisabled === parsed.disabled;
|
|
3811
3884
|
});
|
|
3812
3885
|
}
|
|
@@ -3851,7 +3924,7 @@ var manageRoutingRuleTool = {
|
|
|
3851
3924
|
inputSchema: manageRoutingRuleInputSchema,
|
|
3852
3925
|
annotations: {
|
|
3853
3926
|
readOnlyHint: false,
|
|
3854
|
-
destructiveHint:
|
|
3927
|
+
destructiveHint: true,
|
|
3855
3928
|
idempotentHint: true,
|
|
3856
3929
|
openWorldHint: false
|
|
3857
3930
|
},
|
|
@@ -3957,7 +4030,7 @@ var manageRoutingRuleTool = {
|
|
|
3957
4030
|
});
|
|
3958
4031
|
}
|
|
3959
4032
|
const id = existing[".id"];
|
|
3960
|
-
const isDisabled = existing.disabled
|
|
4033
|
+
const isDisabled = isTrue(existing.disabled);
|
|
3961
4034
|
if (isDisabled === wantDisabled) {
|
|
3962
4035
|
return {
|
|
3963
4036
|
content: `Routing rule is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
|
|
@@ -4048,7 +4121,7 @@ var manageRoutingTableTool = {
|
|
|
4048
4121
|
inputSchema: manageRoutingTableInputSchema,
|
|
4049
4122
|
annotations: {
|
|
4050
4123
|
readOnlyHint: false,
|
|
4051
|
-
destructiveHint:
|
|
4124
|
+
destructiveHint: true,
|
|
4052
4125
|
idempotentHint: true,
|
|
4053
4126
|
openWorldHint: false
|
|
4054
4127
|
},
|
|
@@ -4286,7 +4359,7 @@ var manageScriptTool = {
|
|
|
4286
4359
|
inputSchema: manageScriptInputSchema,
|
|
4287
4360
|
annotations: {
|
|
4288
4361
|
readOnlyHint: false,
|
|
4289
|
-
destructiveHint:
|
|
4362
|
+
destructiveHint: true,
|
|
4290
4363
|
idempotentHint: true,
|
|
4291
4364
|
openWorldHint: false
|
|
4292
4365
|
},
|
|
@@ -4431,7 +4504,7 @@ var runScriptTool = {
|
|
|
4431
4504
|
inputSchema: runScriptInputSchema,
|
|
4432
4505
|
annotations: {
|
|
4433
4506
|
readOnlyHint: false,
|
|
4434
|
-
destructiveHint:
|
|
4507
|
+
destructiveHint: true,
|
|
4435
4508
|
idempotentHint: false,
|
|
4436
4509
|
openWorldHint: false
|
|
4437
4510
|
},
|
|
@@ -4678,7 +4751,7 @@ var manageScheduledJobTool = {
|
|
|
4678
4751
|
});
|
|
4679
4752
|
}
|
|
4680
4753
|
const id = existing[".id"];
|
|
4681
|
-
const isDisabled = existing.disabled
|
|
4754
|
+
const isDisabled = isTrue(existing.disabled);
|
|
4682
4755
|
if (isDisabled === wantDisabled) {
|
|
4683
4756
|
return {
|
|
4684
4757
|
content: `Scheduled job "${parsed.name}" is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
|
|
@@ -4782,7 +4855,7 @@ var managePackageTool = {
|
|
|
4782
4855
|
inputSchema: managePackageInputSchema,
|
|
4783
4856
|
annotations: {
|
|
4784
4857
|
readOnlyHint: false,
|
|
4785
|
-
destructiveHint:
|
|
4858
|
+
destructiveHint: true,
|
|
4786
4859
|
idempotentHint: true,
|
|
4787
4860
|
openWorldHint: false
|
|
4788
4861
|
},
|
|
@@ -4813,7 +4886,7 @@ var managePackageTool = {
|
|
|
4813
4886
|
});
|
|
4814
4887
|
}
|
|
4815
4888
|
const wantDisabled = parsed.action === "disable";
|
|
4816
|
-
const isDisabled = pkg.disabled
|
|
4889
|
+
const isDisabled = isTrue(pkg.disabled);
|
|
4817
4890
|
const id = pkg[".id"];
|
|
4818
4891
|
if (isDisabled === wantDisabled) {
|
|
4819
4892
|
return {
|
|
@@ -4849,6 +4922,7 @@ var packagesTools = [listPackagesTool, managePackageTool];
|
|
|
4849
4922
|
import { z as z22 } from "zod";
|
|
4850
4923
|
var log20 = createLogger("files-tools");
|
|
4851
4924
|
var FILE_PATH = "file";
|
|
4925
|
+
var CONTENT_CAP = 65536;
|
|
4852
4926
|
var listFilesInputSchema = z22.object({
|
|
4853
4927
|
routerId,
|
|
4854
4928
|
name: z22.string().optional().describe("Filter by file name (substring match)"),
|
|
@@ -4937,11 +5011,20 @@ var getFileContentTool = {
|
|
|
4937
5011
|
}
|
|
4938
5012
|
const id = file[".id"];
|
|
4939
5013
|
const full = await context.routerClient.getOne(FILE_PATH, id);
|
|
4940
|
-
const
|
|
5014
|
+
const rawContents = full.contents ?? "";
|
|
5015
|
+
const truncated = rawContents.length > CONTENT_CAP;
|
|
5016
|
+
const contents = truncated ? `${rawContents.slice(0, CONTENT_CAP)}
|
|
5017
|
+
[TRUNCATED at ${CONTENT_CAP} chars \u2014 file is ${rawContents.length} chars]` : rawContents;
|
|
4941
5018
|
return {
|
|
4942
5019
|
content: `Contents of "${parsed.name}" on ${context.routerId}:
|
|
4943
5020
|
${contents}`,
|
|
4944
|
-
structuredContent: {
|
|
5021
|
+
structuredContent: {
|
|
5022
|
+
routerId: context.routerId,
|
|
5023
|
+
name: parsed.name,
|
|
5024
|
+
contents,
|
|
5025
|
+
truncated,
|
|
5026
|
+
totalLength: rawContents.length
|
|
5027
|
+
}
|
|
4945
5028
|
};
|
|
4946
5029
|
} catch (err) {
|
|
4947
5030
|
throw toolError(err, context, "get_file_content");
|
|
@@ -4957,7 +5040,7 @@ var uploadFileInputSchema = z22.object({
|
|
|
4957
5040
|
var uploadFileTool = {
|
|
4958
5041
|
name: "upload_file",
|
|
4959
5042
|
title: "Upload File",
|
|
4960
|
-
description: "Upload a text file to a router
|
|
5043
|
+
description: "Upload a text file to a router, overwriting any existing file of the same name. Prefers SFTP (encrypted, over SSH) and falls back to plaintext FTP if SFTP is unavailable. Requires SSH (or FTP) access for the router user. Dry-run tests connectivity only.",
|
|
4961
5044
|
inputSchema: uploadFileInputSchema,
|
|
4962
5045
|
annotations: {
|
|
4963
5046
|
readOnlyHint: false,
|
|
@@ -4969,30 +5052,50 @@ var uploadFileTool = {
|
|
|
4969
5052
|
async handler(params, context) {
|
|
4970
5053
|
const parsed = uploadFileInputSchema.parse(params);
|
|
4971
5054
|
log20.info({ routerId: context.routerId, name: parsed.name }, "Uploading file");
|
|
5055
|
+
const ftpPlaintextNote = " (via FTP, plaintext \u2014 enable SSH on the router for encrypted transfer)";
|
|
4972
5056
|
try {
|
|
4973
5057
|
if (parsed.dryRun) {
|
|
4974
|
-
|
|
5058
|
+
try {
|
|
5059
|
+
await context.sftpClient.upload(`${parsed.name}.mikromcp-probe`, "");
|
|
5060
|
+
return {
|
|
5061
|
+
content: `Dry run: SFTP connectivity to ${context.routerId} verified. Would upload "${parsed.name}".`,
|
|
5062
|
+
structuredContent: { action: "dry_run", transport: "sftp", name: parsed.name, routerId: context.routerId }
|
|
5063
|
+
};
|
|
5064
|
+
} catch (sftpErr) {
|
|
5065
|
+
log20.warn({ err: sftpErr, routerId: context.routerId }, "SFTP dry-run probe failed \u2014 falling back to FTP");
|
|
5066
|
+
await context.ftpClient.connect();
|
|
5067
|
+
return {
|
|
5068
|
+
content: `Dry run: FTP connectivity to ${context.routerId} verified${ftpPlaintextNote}. Would upload "${parsed.name}".`,
|
|
5069
|
+
structuredContent: { action: "dry_run", transport: "ftp", name: parsed.name, routerId: context.routerId }
|
|
5070
|
+
};
|
|
5071
|
+
}
|
|
5072
|
+
}
|
|
5073
|
+
try {
|
|
5074
|
+
await context.sftpClient.upload(parsed.name, parsed.content);
|
|
5075
|
+
log20.info({ name: parsed.name }, "File uploaded via SFTP");
|
|
4975
5076
|
return {
|
|
4976
|
-
content: `
|
|
4977
|
-
structuredContent: { action: "
|
|
5077
|
+
content: `Uploaded "${parsed.name}" to ${context.routerId} via SFTP.`,
|
|
5078
|
+
structuredContent: { action: "uploaded", transport: "sftp", name: parsed.name, routerId: context.routerId }
|
|
5079
|
+
};
|
|
5080
|
+
} catch (sftpErr) {
|
|
5081
|
+
log20.warn({ err: sftpErr, routerId: context.routerId }, "SFTP upload failed \u2014 falling back to FTP");
|
|
5082
|
+
await context.ftpClient.upload(parsed.name, parsed.content);
|
|
5083
|
+
log20.info({ name: parsed.name }, "File uploaded via FTP");
|
|
5084
|
+
return {
|
|
5085
|
+
content: `Uploaded "${parsed.name}" to ${context.routerId}${ftpPlaintextNote}.`,
|
|
5086
|
+
structuredContent: { action: "uploaded", transport: "ftp", name: parsed.name, routerId: context.routerId }
|
|
4978
5087
|
};
|
|
4979
5088
|
}
|
|
4980
|
-
await context.ftpClient.upload(parsed.name, parsed.content);
|
|
4981
|
-
log20.info({ name: parsed.name }, "File uploaded via FTP");
|
|
4982
|
-
return {
|
|
4983
|
-
content: `Uploaded "${parsed.name}" to ${context.routerId}.`,
|
|
4984
|
-
structuredContent: { action: "uploaded", name: parsed.name, routerId: context.routerId }
|
|
4985
|
-
};
|
|
4986
5089
|
} catch (err) {
|
|
4987
5090
|
if (err instanceof Error && err.code === "ECONNREFUSED") {
|
|
4988
5091
|
throw new MikroMCPError({
|
|
4989
5092
|
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
4990
|
-
code: "
|
|
4991
|
-
message: `FTP
|
|
4992
|
-
details: { host: context.routerConfig.host,
|
|
5093
|
+
code: "FILE_TRANSFER_UNAVAILABLE",
|
|
5094
|
+
message: `Neither SFTP (SSH) nor FTP is reachable on ${context.routerConfig.host}. Enable SSH (recommended) or FTP on the router.`,
|
|
5095
|
+
details: { host: context.routerConfig.host, routerId: context.routerId },
|
|
4993
5096
|
recoverability: {
|
|
4994
5097
|
retryable: false,
|
|
4995
|
-
suggestedAction: "
|
|
5098
|
+
suggestedAction: "Enable SSH: /ip service enable ssh and ensure the user has the 'ssh'/'ftp' policy. FTP fallback needs: /ip service enable ftp plus the 'ftp' policy."
|
|
4996
5099
|
}
|
|
4997
5100
|
});
|
|
4998
5101
|
}
|
|
@@ -5127,7 +5230,7 @@ var manageContainerTool = {
|
|
|
5127
5230
|
inputSchema: manageContainerInputSchema,
|
|
5128
5231
|
annotations: {
|
|
5129
5232
|
readOnlyHint: false,
|
|
5130
|
-
destructiveHint:
|
|
5233
|
+
destructiveHint: true,
|
|
5131
5234
|
idempotentHint: true,
|
|
5132
5235
|
openWorldHint: false
|
|
5133
5236
|
},
|
|
@@ -5301,28 +5404,49 @@ import { readFileSync } from "fs";
|
|
|
5301
5404
|
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
5302
5405
|
import { join as join2 } from "path";
|
|
5303
5406
|
import { nanoid } from "nanoid";
|
|
5304
|
-
function pathToSlug(rosPath) {
|
|
5305
|
-
return rosPath.replace(/\//g, "-");
|
|
5306
|
-
}
|
|
5307
|
-
function timestampPrefix() {
|
|
5308
|
-
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
|
|
5309
|
-
}
|
|
5310
|
-
async function takeSnapshot(client, routerId2, path, snapshotDir) {
|
|
5311
|
-
const records = await client.get(path, {});
|
|
5312
|
-
const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
|
|
5313
|
-
const dir = join2(snapshotDir, routerId2);
|
|
5314
|
-
const filePath = join2(dir, `${id}.json`);
|
|
5315
|
-
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
5316
|
-
await mkdir(dir, { recursive: true });
|
|
5317
|
-
const stored = { id, routerId: routerId2, path, ts, records };
|
|
5318
|
-
await writeFile(filePath, JSON.stringify(stored, null, 2));
|
|
5319
|
-
return { id, routerId: routerId2, path, ts, filePath, recordCount: records.length };
|
|
5320
|
-
}
|
|
5321
|
-
async function loadSnapshot(filePath) {
|
|
5322
|
-
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
5323
|
-
}
|
|
5324
5407
|
|
|
5325
5408
|
// src/domain/snapshot/diff-engine.ts
|
|
5409
|
+
var RUNTIME_FIELDS = /* @__PURE__ */ new Set([
|
|
5410
|
+
"bytes",
|
|
5411
|
+
"packets",
|
|
5412
|
+
"dynamic",
|
|
5413
|
+
"invalid",
|
|
5414
|
+
"dead",
|
|
5415
|
+
"expired",
|
|
5416
|
+
"active",
|
|
5417
|
+
"running",
|
|
5418
|
+
"about",
|
|
5419
|
+
".about",
|
|
5420
|
+
".nextid",
|
|
5421
|
+
"creation-time",
|
|
5422
|
+
"last-logged-in",
|
|
5423
|
+
"last-link-up-time",
|
|
5424
|
+
"last-link-down-time",
|
|
5425
|
+
"link-downs",
|
|
5426
|
+
"rx-byte",
|
|
5427
|
+
"tx-byte",
|
|
5428
|
+
"rx-packet",
|
|
5429
|
+
"tx-packet",
|
|
5430
|
+
"uptime"
|
|
5431
|
+
]);
|
|
5432
|
+
var ORDER_SENSITIVE_PATHS = /* @__PURE__ */ new Set([
|
|
5433
|
+
"ip/firewall/filter",
|
|
5434
|
+
"ip/firewall/nat",
|
|
5435
|
+
"ip/firewall/mangle",
|
|
5436
|
+
"routing/rule"
|
|
5437
|
+
]);
|
|
5438
|
+
function normalizeForDiff(records) {
|
|
5439
|
+
const out = [];
|
|
5440
|
+
for (const r of records) {
|
|
5441
|
+
if (isTrue(r.dynamic)) continue;
|
|
5442
|
+
const clean = {};
|
|
5443
|
+
for (const [k, v] of Object.entries(r)) {
|
|
5444
|
+
if (!RUNTIME_FIELDS.has(k)) clean[k] = v;
|
|
5445
|
+
}
|
|
5446
|
+
out.push(clean);
|
|
5447
|
+
}
|
|
5448
|
+
return out;
|
|
5449
|
+
}
|
|
5326
5450
|
var SEMANTIC_KEYS = {
|
|
5327
5451
|
"ip/route": ["dst-address", "gateway", "routing-table"],
|
|
5328
5452
|
"ip/firewall/filter": ["comment"],
|
|
@@ -5351,9 +5475,9 @@ var SEMANTIC_KEYS = {
|
|
|
5351
5475
|
"user": ["name"]
|
|
5352
5476
|
};
|
|
5353
5477
|
function normalizeValue(v) {
|
|
5354
|
-
if (v === "true" || v === "yes") return "true";
|
|
5355
|
-
if (v === "false" || v === "no") return "false";
|
|
5356
|
-
return v;
|
|
5478
|
+
if (v === true || v === "true" || v === "yes") return "true";
|
|
5479
|
+
if (v === false || v === "false" || v === "no") return "false";
|
|
5480
|
+
return String(v ?? "");
|
|
5357
5481
|
}
|
|
5358
5482
|
function semanticKey(record, keys) {
|
|
5359
5483
|
return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
|
|
@@ -5367,17 +5491,45 @@ function recordsAreEqual(a, b) {
|
|
|
5367
5491
|
if (keysA.length !== keysB.length) return false;
|
|
5368
5492
|
return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
|
|
5369
5493
|
}
|
|
5370
|
-
function
|
|
5494
|
+
function hasDuplicateKeys(records, keys) {
|
|
5495
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5496
|
+
for (const r of records) {
|
|
5497
|
+
const k = semanticKey(r, keys);
|
|
5498
|
+
if (seen.has(k)) return true;
|
|
5499
|
+
seen.add(k);
|
|
5500
|
+
}
|
|
5501
|
+
return false;
|
|
5502
|
+
}
|
|
5503
|
+
function applyUserRestriction(path, plan) {
|
|
5504
|
+
if (path === "user" && plan.toCreate.length > 0) {
|
|
5505
|
+
plan.warnings.push(
|
|
5506
|
+
`Refusing to recreate ${plan.toCreate.length} deleted user(s): passwords are not stored in snapshots and recreation would produce password-less logins. Recreate manually with manage_user.`
|
|
5507
|
+
);
|
|
5508
|
+
plan.toCreate = [];
|
|
5509
|
+
}
|
|
5510
|
+
return plan;
|
|
5511
|
+
}
|
|
5512
|
+
function computeRestorePlan(path, beforeRaw, currentRaw) {
|
|
5513
|
+
const before = normalizeForDiff(beforeRaw);
|
|
5514
|
+
const current = normalizeForDiff(currentRaw);
|
|
5515
|
+
const warnings = [];
|
|
5516
|
+
if (ORDER_SENSITIVE_PATHS.has(path)) {
|
|
5517
|
+
warnings.push(
|
|
5518
|
+
`Restored entries for ${path} are re-created at the end of the list \u2014 rule ORDER is not restored. Review ordering manually after rollback.`
|
|
5519
|
+
);
|
|
5520
|
+
}
|
|
5371
5521
|
const keys = SEMANTIC_KEYS[path];
|
|
5372
|
-
|
|
5522
|
+
const canUseKeys = keys !== void 0 && !hasDuplicateKeys(before, keys) && !hasDuplicateKeys(current, keys);
|
|
5523
|
+
if (!canUseKeys) {
|
|
5373
5524
|
const beforeSigs = new Set(before.map(recordSignature));
|
|
5374
5525
|
const currentSigs = new Set(current.map(recordSignature));
|
|
5375
|
-
return {
|
|
5526
|
+
return applyUserRestriction(path, {
|
|
5376
5527
|
path,
|
|
5377
5528
|
toCreate: before.filter((r) => !currentSigs.has(recordSignature(r))),
|
|
5378
5529
|
toRemove: current.filter((r) => !beforeSigs.has(recordSignature(r))).map((r) => r[".id"]),
|
|
5379
|
-
toUpdate: []
|
|
5380
|
-
|
|
5530
|
+
toUpdate: [],
|
|
5531
|
+
warnings
|
|
5532
|
+
});
|
|
5381
5533
|
}
|
|
5382
5534
|
const currentByKey = new Map(current.map((r) => [semanticKey(r, keys), r]));
|
|
5383
5535
|
const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
|
|
@@ -5393,7 +5545,7 @@ function computeRestorePlan(path, before, current) {
|
|
|
5393
5545
|
}
|
|
5394
5546
|
}
|
|
5395
5547
|
const toRemove = current.filter((r) => !beforeByKey.has(semanticKey(r, keys))).map((r) => r[".id"]);
|
|
5396
|
-
return { path, toCreate, toRemove, toUpdate };
|
|
5548
|
+
return applyUserRestriction(path, { path, toCreate, toRemove, toUpdate, warnings });
|
|
5397
5549
|
}
|
|
5398
5550
|
async function applyRestorePlan(plan, client) {
|
|
5399
5551
|
for (const id of plan.toRemove) {
|
|
@@ -5408,6 +5560,28 @@ async function applyRestorePlan(plan, client) {
|
|
|
5408
5560
|
}
|
|
5409
5561
|
}
|
|
5410
5562
|
|
|
5563
|
+
// src/domain/snapshot/snapshot-engine.ts
|
|
5564
|
+
function pathToSlug(rosPath) {
|
|
5565
|
+
return rosPath.replace(/\//g, "-");
|
|
5566
|
+
}
|
|
5567
|
+
function timestampPrefix() {
|
|
5568
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
|
|
5569
|
+
}
|
|
5570
|
+
async function takeSnapshot(client, routerId2, path, snapshotDir) {
|
|
5571
|
+
const records = normalizeForDiff(await client.get(path, {}));
|
|
5572
|
+
const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
|
|
5573
|
+
const dir = join2(snapshotDir, routerId2);
|
|
5574
|
+
const filePath = join2(dir, `${id}.json`);
|
|
5575
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
5576
|
+
await mkdir(dir, { recursive: true });
|
|
5577
|
+
const stored = { id, routerId: routerId2, path, ts, records };
|
|
5578
|
+
await writeFile(filePath, JSON.stringify(stored, null, 2));
|
|
5579
|
+
return { id, routerId: routerId2, path, ts, filePath, recordCount: records.length };
|
|
5580
|
+
}
|
|
5581
|
+
async function loadSnapshot(filePath) {
|
|
5582
|
+
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
5583
|
+
}
|
|
5584
|
+
|
|
5411
5585
|
// src/domain/snapshot/write-journal.ts
|
|
5412
5586
|
import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
|
|
5413
5587
|
import { dirname } from "path";
|
|
@@ -5715,18 +5889,22 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5715
5889
|
(sum, p) => sum + p.toCreate.length + p.toRemove.length + p.toUpdate.length,
|
|
5716
5890
|
0
|
|
5717
5891
|
);
|
|
5892
|
+
const warnings = restorePlans.flatMap((p) => p.warnings);
|
|
5893
|
+
const warningSuffix = warnings.length > 0 ? `
|
|
5894
|
+
Warnings:
|
|
5895
|
+
${warnings.map((w) => ` - ${w}`).join("\n")}` : "";
|
|
5718
5896
|
if (parsed.dryRun) {
|
|
5719
5897
|
return {
|
|
5720
|
-
content: `Rollback dry run for journal entry "${parsed.journalId}": ${totalOps} operation(s) would be applied across ${restorePlans.length} path(s)
|
|
5721
|
-
structuredContent: { action: "dry_run", journalId: parsed.journalId, restorePlans }
|
|
5898
|
+
content: `Rollback dry run for journal entry "${parsed.journalId}": ${totalOps} operation(s) would be applied across ${restorePlans.length} path(s).${warningSuffix}`,
|
|
5899
|
+
structuredContent: { action: "dry_run", journalId: parsed.journalId, restorePlans, warnings }
|
|
5722
5900
|
};
|
|
5723
5901
|
}
|
|
5724
5902
|
for (const plan of restorePlans) {
|
|
5725
5903
|
await applyRestorePlan(plan, context.routerClient);
|
|
5726
5904
|
}
|
|
5727
5905
|
return {
|
|
5728
|
-
content: `Rolled back journal entry "${parsed.journalId}": ${totalOps} operation(s) applied across ${restorePlans.length} path(s)
|
|
5729
|
-
structuredContent: { action: "rolled_back", journalId: parsed.journalId, restorePlans }
|
|
5906
|
+
content: `Rolled back journal entry "${parsed.journalId}": ${totalOps} operation(s) applied across ${restorePlans.length} path(s).${warningSuffix}`,
|
|
5907
|
+
structuredContent: { action: "rolled_back", journalId: parsed.journalId, restorePlans, warnings }
|
|
5730
5908
|
};
|
|
5731
5909
|
}
|
|
5732
5910
|
};
|
|
@@ -6019,7 +6197,7 @@ var manageIpsecPolicyTool = {
|
|
|
6019
6197
|
inputSchema: manageIpsecPolicyInputSchema,
|
|
6020
6198
|
annotations: {
|
|
6021
6199
|
readOnlyHint: false,
|
|
6022
|
-
destructiveHint:
|
|
6200
|
+
destructiveHint: true,
|
|
6023
6201
|
idempotentHint: true,
|
|
6024
6202
|
openWorldHint: false
|
|
6025
6203
|
},
|
|
@@ -6401,7 +6579,7 @@ var manageUserTool = {
|
|
|
6401
6579
|
inputSchema: manageUserInputSchema,
|
|
6402
6580
|
annotations: {
|
|
6403
6581
|
readOnlyHint: false,
|
|
6404
|
-
destructiveHint:
|
|
6582
|
+
destructiveHint: true,
|
|
6405
6583
|
idempotentHint: true,
|
|
6406
6584
|
openWorldHint: false
|
|
6407
6585
|
},
|
|
@@ -6681,7 +6859,7 @@ var manageUserGroupTool = {
|
|
|
6681
6859
|
inputSchema: manageUserGroupInputSchema,
|
|
6682
6860
|
annotations: {
|
|
6683
6861
|
readOnlyHint: false,
|
|
6684
|
-
destructiveHint:
|
|
6862
|
+
destructiveHint: true,
|
|
6685
6863
|
idempotentHint: true,
|
|
6686
6864
|
openWorldHint: false
|
|
6687
6865
|
},
|
|
@@ -6904,7 +7082,7 @@ var manageDhcpServerTool = {
|
|
|
6904
7082
|
inputSchema: manageDhcpServerInputSchema,
|
|
6905
7083
|
annotations: {
|
|
6906
7084
|
readOnlyHint: false,
|
|
6907
|
-
destructiveHint:
|
|
7085
|
+
destructiveHint: true,
|
|
6908
7086
|
idempotentHint: true,
|
|
6909
7087
|
openWorldHint: false
|
|
6910
7088
|
},
|
|
@@ -7516,7 +7694,7 @@ var manageVrrpInstanceTool = {
|
|
|
7516
7694
|
inputSchema: manageVrrpInstanceInputSchema,
|
|
7517
7695
|
annotations: {
|
|
7518
7696
|
readOnlyHint: false,
|
|
7519
|
-
destructiveHint:
|
|
7697
|
+
destructiveHint: true,
|
|
7520
7698
|
idempotentHint: true,
|
|
7521
7699
|
openWorldHint: false
|
|
7522
7700
|
},
|
|
@@ -8551,7 +8729,7 @@ var listIpServicesTool = {
|
|
|
8551
8729
|
}
|
|
8552
8730
|
if (parsed.enabled !== void 0) {
|
|
8553
8731
|
filtered = filtered.filter((s) => {
|
|
8554
|
-
const isDisabled = s.disabled
|
|
8732
|
+
const isDisabled = isTrue(s.disabled);
|
|
8555
8733
|
return parsed.enabled ? !isDisabled : isDisabled;
|
|
8556
8734
|
});
|
|
8557
8735
|
}
|
|
@@ -8616,7 +8794,7 @@ var manageIpServiceTool = {
|
|
|
8616
8794
|
}
|
|
8617
8795
|
});
|
|
8618
8796
|
}
|
|
8619
|
-
const isCurrentlyDisabled = existing.disabled
|
|
8797
|
+
const isCurrentlyDisabled = isTrue(existing.disabled);
|
|
8620
8798
|
const wouldBeDisabled = parsed.action === "disable";
|
|
8621
8799
|
if (isCurrentlyDisabled === wouldBeDisabled) {
|
|
8622
8800
|
const alreadyState = parsed.action === "disable" ? "disabled" : "enabled";
|
|
@@ -8667,7 +8845,7 @@ var listPppoeClientsInputSchema = z37.object({
|
|
|
8667
8845
|
offset
|
|
8668
8846
|
}).strict();
|
|
8669
8847
|
function isRunning(record) {
|
|
8670
|
-
return record.running
|
|
8848
|
+
return isTrue(record.running);
|
|
8671
8849
|
}
|
|
8672
8850
|
var listPppoeClientsTool = {
|
|
8673
8851
|
name: "list_pppoe_clients",
|
|
@@ -9689,8 +9867,9 @@ var exportConfigTool = {
|
|
|
9689
9867
|
title: "Export Config",
|
|
9690
9868
|
description: "Export the router configuration as a RouterOS script. When no file is specified, returns the script text inline. When a file is specified, saves it as <file>.rsc on the router's filesystem. Supports compact mode to show only non-default values.",
|
|
9691
9869
|
inputSchema: exportConfigInputSchema,
|
|
9870
|
+
// Not read-only: with a `file` argument it writes a file on the router.
|
|
9692
9871
|
annotations: {
|
|
9693
|
-
readOnlyHint:
|
|
9872
|
+
readOnlyHint: false,
|
|
9694
9873
|
destructiveHint: false,
|
|
9695
9874
|
idempotentHint: true,
|
|
9696
9875
|
openWorldHint: false
|
|
@@ -10131,14 +10310,17 @@ var bandwidthTestInputSchema = z43.object({
|
|
|
10131
10310
|
address: z43.string().describe("Remote host running RouterOS btest server"),
|
|
10132
10311
|
protocol: z43.enum(["tcp", "udp"]).default("tcp").describe("Test protocol"),
|
|
10133
10312
|
direction: z43.enum(["send", "receive", "both"]).default("both").describe("Test direction"),
|
|
10134
|
-
duration: z43.number().int().min(1).max(
|
|
10313
|
+
duration: z43.number().int().min(1).max(20).default(5).describe("Test duration in seconds (max 20)")
|
|
10135
10314
|
}).strict();
|
|
10136
10315
|
var bandwidthTestTool = {
|
|
10137
10316
|
name: "bandwidth_test",
|
|
10138
10317
|
title: "Bandwidth Test",
|
|
10139
|
-
description: "Run a RouterOS bandwidth test from the router to a remote host running a RouterOS btest server. Returns TX and RX throughput in Mbps. Duration capped at
|
|
10318
|
+
description: "Run a RouterOS bandwidth test from the router to a remote host running a RouterOS btest server. Returns TX and RX throughput in Mbps. Duration capped at 20 seconds. Saturates the link \u2014 not auto-retried.",
|
|
10140
10319
|
inputSchema: bandwidthTestInputSchema,
|
|
10141
|
-
|
|
10320
|
+
// Reaches an external host and saturates the link; a silent retry would
|
|
10321
|
+
// double the load, so opt out of auto-retry.
|
|
10322
|
+
retryable: false,
|
|
10323
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
10142
10324
|
async handler(params, context) {
|
|
10143
10325
|
const parsed = bandwidthTestInputSchema.parse(params);
|
|
10144
10326
|
log43.info({ routerId: context.routerId, address: parsed.address }, "Running bandwidth test");
|
|
@@ -10180,9 +10362,11 @@ var fetchUrlInputSchema = z43.object({
|
|
|
10180
10362
|
var fetchUrlTool = {
|
|
10181
10363
|
name: "fetch_url",
|
|
10182
10364
|
title: "Fetch URL",
|
|
10183
|
-
description: "Send an HTTP/HTTPS request from the router using /tool/fetch. Response body is returned inline (capped at 64 KB with [TRUNCATED] marker). Use outputFile to save to router filesystem instead.",
|
|
10365
|
+
description: "Send an HTTP/HTTPS request from the router using /tool/fetch. Response body is returned inline (capped at 64 KB with [TRUNCATED] marker). Use outputFile to save to router filesystem instead. Not read-only: POSTs have side effects and outputFile writes to the router.",
|
|
10184
10366
|
inputSchema: fetchUrlInputSchema,
|
|
10185
|
-
|
|
10367
|
+
// Not read-only: a POST has external side effects and outputFile writes a
|
|
10368
|
+
// file on the router. openWorld: reaches arbitrary external hosts.
|
|
10369
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
10186
10370
|
async handler(params, context) {
|
|
10187
10371
|
const parsed = fetchUrlInputSchema.parse(params);
|
|
10188
10372
|
log43.info({ routerId: context.routerId, url: parsed.url, method: parsed.method }, "Fetching URL");
|
|
@@ -10235,6 +10419,14 @@ var listConnectionsTool = {
|
|
|
10235
10419
|
log43.info({ routerId: context.routerId }, "Listing connections");
|
|
10236
10420
|
try {
|
|
10237
10421
|
const all = await context.routerClient.get("ip/firewall/connection", {
|
|
10422
|
+
proplist: [
|
|
10423
|
+
"protocol",
|
|
10424
|
+
"src-address",
|
|
10425
|
+
"dst-address",
|
|
10426
|
+
"tcp-state",
|
|
10427
|
+
"connection-mark",
|
|
10428
|
+
"timeout"
|
|
10429
|
+
],
|
|
10238
10430
|
limit: void 0,
|
|
10239
10431
|
offset: void 0
|
|
10240
10432
|
});
|
|
@@ -10695,7 +10887,7 @@ var manageInterfaceListTool = {
|
|
|
10695
10887
|
title: "Manage Interface List",
|
|
10696
10888
|
description: "Add or remove an interface list. Idempotent by name. Removing a list that has members is blocked by RouterOS \u2014 the error is surfaced as-is.",
|
|
10697
10889
|
inputSchema: manageInterfaceListInputSchema,
|
|
10698
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
10890
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
10699
10891
|
async handler(params, context) {
|
|
10700
10892
|
const parsed = manageInterfaceListInputSchema.parse(params);
|
|
10701
10893
|
log45.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing interface list");
|
|
@@ -10769,7 +10961,7 @@ var manageInterfaceListMemberTool = {
|
|
|
10769
10961
|
title: "Manage Interface List Member",
|
|
10770
10962
|
description: "Add or remove an interface from an interface list. Idempotent by list+interface composite key. add returns already_exists if the membership exists. remove returns not_found gracefully.",
|
|
10771
10963
|
inputSchema: manageInterfaceListMemberInputSchema,
|
|
10772
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
10964
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
10773
10965
|
async handler(params, context) {
|
|
10774
10966
|
const parsed = manageInterfaceListMemberInputSchema.parse(params);
|
|
10775
10967
|
log45.info(
|
|
@@ -10851,9 +11043,7 @@ import { z as z46 } from "zod";
|
|
|
10851
11043
|
|
|
10852
11044
|
// src/middleware/authz.ts
|
|
10853
11045
|
function matchesPattern(name, pattern) {
|
|
10854
|
-
|
|
10855
|
-
const prefix = pattern.slice(0, pattern.indexOf("*"));
|
|
10856
|
-
return name.startsWith(prefix);
|
|
11046
|
+
return globMatch(pattern, name);
|
|
10857
11047
|
}
|
|
10858
11048
|
function checkAuthz(identity, toolName, routerId2) {
|
|
10859
11049
|
if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId2)) {
|
|
@@ -10949,11 +11139,16 @@ var SshClient = class {
|
|
|
10949
11139
|
const maxOutputBytes = this.maxOutputBytes;
|
|
10950
11140
|
return new Promise((resolve, reject) => {
|
|
10951
11141
|
const conn = new Client();
|
|
10952
|
-
|
|
11142
|
+
const chunks = [];
|
|
10953
11143
|
let outputSize = 0;
|
|
10954
11144
|
let truncated = false;
|
|
11145
|
+
let timedOut = false;
|
|
10955
11146
|
let settled = false;
|
|
10956
11147
|
let timer;
|
|
11148
|
+
const decodeOutput = () => {
|
|
11149
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
11150
|
+
return truncated ? text + "\n[OUTPUT TRUNCATED]" : text;
|
|
11151
|
+
};
|
|
10957
11152
|
const cleanup = (err) => {
|
|
10958
11153
|
if (settled) return;
|
|
10959
11154
|
settled = true;
|
|
@@ -10962,7 +11157,7 @@ var SshClient = class {
|
|
|
10962
11157
|
if (err) {
|
|
10963
11158
|
reject(err);
|
|
10964
11159
|
} else {
|
|
10965
|
-
resolve(
|
|
11160
|
+
resolve(decodeOutput());
|
|
10966
11161
|
}
|
|
10967
11162
|
};
|
|
10968
11163
|
conn.on("ready", () => {
|
|
@@ -10972,24 +11167,35 @@ var SshClient = class {
|
|
|
10972
11167
|
return;
|
|
10973
11168
|
}
|
|
10974
11169
|
timer = setTimeout(() => {
|
|
11170
|
+
timedOut = true;
|
|
10975
11171
|
stream.close();
|
|
10976
11172
|
}, timeoutMs);
|
|
10977
11173
|
const appendOutput = (data) => {
|
|
10978
11174
|
if (truncated) return;
|
|
10979
11175
|
const remaining = maxOutputBytes - outputSize;
|
|
10980
11176
|
if (data.length >= remaining) {
|
|
10981
|
-
|
|
11177
|
+
chunks.push(data.subarray(0, remaining));
|
|
10982
11178
|
outputSize = maxOutputBytes;
|
|
10983
11179
|
truncated = true;
|
|
10984
11180
|
stream.close();
|
|
10985
11181
|
return;
|
|
10986
11182
|
}
|
|
10987
|
-
|
|
11183
|
+
chunks.push(data);
|
|
10988
11184
|
outputSize += data.length;
|
|
10989
11185
|
};
|
|
10990
11186
|
stream.on("data", appendOutput);
|
|
10991
11187
|
stream.stderr.on("data", appendOutput);
|
|
10992
|
-
stream.on("close", () =>
|
|
11188
|
+
stream.on("close", () => {
|
|
11189
|
+
if (timedOut) {
|
|
11190
|
+
const err2 = new Error(
|
|
11191
|
+
`SSH command timed out after ${timeoutMs}ms (${outputSize} bytes of partial output discarded)`
|
|
11192
|
+
);
|
|
11193
|
+
err2.code = "ETIMEDOUT";
|
|
11194
|
+
cleanup(err2);
|
|
11195
|
+
} else {
|
|
11196
|
+
cleanup();
|
|
11197
|
+
}
|
|
11198
|
+
});
|
|
10993
11199
|
});
|
|
10994
11200
|
});
|
|
10995
11201
|
conn.on("error", (err) => cleanup(err));
|
|
@@ -11059,6 +11265,58 @@ var FtpClient = class {
|
|
|
11059
11265
|
}
|
|
11060
11266
|
};
|
|
11061
11267
|
|
|
11268
|
+
// src/adapter/sftp-client.ts
|
|
11269
|
+
import { createHash as createHash2 } from "crypto";
|
|
11270
|
+
import { Client as Client3 } from "ssh2";
|
|
11271
|
+
var SftpClient = class {
|
|
11272
|
+
constructor(config, credentials) {
|
|
11273
|
+
this.config = config;
|
|
11274
|
+
this.credentials = credentials;
|
|
11275
|
+
}
|
|
11276
|
+
config;
|
|
11277
|
+
credentials;
|
|
11278
|
+
async upload(remoteName, content) {
|
|
11279
|
+
return new Promise((resolve, reject) => {
|
|
11280
|
+
const conn = new Client3();
|
|
11281
|
+
let settled = false;
|
|
11282
|
+
const cleanup = (err) => {
|
|
11283
|
+
if (settled) return;
|
|
11284
|
+
settled = true;
|
|
11285
|
+
conn.end();
|
|
11286
|
+
if (err) reject(err);
|
|
11287
|
+
else resolve();
|
|
11288
|
+
};
|
|
11289
|
+
conn.on("ready", () => {
|
|
11290
|
+
conn.sftp((err, sftp) => {
|
|
11291
|
+
if (err) {
|
|
11292
|
+
cleanup(err);
|
|
11293
|
+
return;
|
|
11294
|
+
}
|
|
11295
|
+
sftp.writeFile(remoteName, Buffer.from(content, "utf-8"), (writeErr) => {
|
|
11296
|
+
cleanup(writeErr ?? void 0);
|
|
11297
|
+
});
|
|
11298
|
+
});
|
|
11299
|
+
});
|
|
11300
|
+
conn.on("error", (err) => cleanup(err));
|
|
11301
|
+
const connectOptions = {
|
|
11302
|
+
host: this.config.host,
|
|
11303
|
+
port: this.config.sshPort ?? 22,
|
|
11304
|
+
username: this.credentials.username,
|
|
11305
|
+
password: this.credentials.password,
|
|
11306
|
+
readyTimeout: 1e4
|
|
11307
|
+
};
|
|
11308
|
+
if (this.config.sshFingerprint) {
|
|
11309
|
+
const expected = this.config.sshFingerprint.toLowerCase();
|
|
11310
|
+
connectOptions.hostVerifier = (key2) => {
|
|
11311
|
+
const actual = createHash2("sha256").update(key2).digest("hex");
|
|
11312
|
+
return actual === expected;
|
|
11313
|
+
};
|
|
11314
|
+
}
|
|
11315
|
+
conn.connect(connectOptions);
|
|
11316
|
+
});
|
|
11317
|
+
}
|
|
11318
|
+
};
|
|
11319
|
+
|
|
11062
11320
|
// src/adapter/adapter-factory.ts
|
|
11063
11321
|
function createSshClient(routerConfig, options) {
|
|
11064
11322
|
const creds = getCredentials(routerConfig);
|
|
@@ -11068,6 +11326,10 @@ function createFtpClient(routerConfig) {
|
|
|
11068
11326
|
const creds = getCredentials(routerConfig);
|
|
11069
11327
|
return new FtpClient(routerConfig.host, 21, creds.username, creds.password);
|
|
11070
11328
|
}
|
|
11329
|
+
function createSftpClient(routerConfig) {
|
|
11330
|
+
const creds = getCredentials(routerConfig);
|
|
11331
|
+
return new SftpClient(routerConfig, creds);
|
|
11332
|
+
}
|
|
11071
11333
|
|
|
11072
11334
|
// src/mcp/tool-context.ts
|
|
11073
11335
|
function buildRouterToolContext(args) {
|
|
@@ -11080,6 +11342,7 @@ function buildRouterToolContext(args) {
|
|
|
11080
11342
|
routerConfig,
|
|
11081
11343
|
sshClient: createSshClient(routerConfig, config.ssh),
|
|
11082
11344
|
ftpClient: createFtpClient(routerConfig),
|
|
11345
|
+
sftpClient: createSftpClient(routerConfig),
|
|
11083
11346
|
identity,
|
|
11084
11347
|
routerRegistry: registry,
|
|
11085
11348
|
connectionPool: pool,
|
|
@@ -11087,1082 +11350,1136 @@ function buildRouterToolContext(args) {
|
|
|
11087
11350
|
};
|
|
11088
11351
|
}
|
|
11089
11352
|
|
|
11090
|
-
// src/
|
|
11091
|
-
import {
|
|
11353
|
+
// src/mcp/tool-executor.ts
|
|
11354
|
+
import { nanoid as nanoid4 } from "nanoid";
|
|
11355
|
+
|
|
11356
|
+
// src/observability/correlation.ts
|
|
11357
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
11358
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
11359
|
+
var requestContext = new AsyncLocalStorage();
|
|
11360
|
+
function withContext(ctx, fn) {
|
|
11361
|
+
const fullContext = {
|
|
11362
|
+
correlationId: ctx.correlationId ?? nanoid3(),
|
|
11363
|
+
routerId: ctx.routerId,
|
|
11364
|
+
tool: ctx.tool,
|
|
11365
|
+
identity: ctx.identity
|
|
11366
|
+
};
|
|
11367
|
+
return requestContext.run(fullContext, fn);
|
|
11368
|
+
}
|
|
11369
|
+
|
|
11370
|
+
// src/middleware/auth.ts
|
|
11371
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
11372
|
+
var identityStorage = new AsyncLocalStorage2();
|
|
11373
|
+
function getCurrentIdentity() {
|
|
11374
|
+
return identityStorage.getStore();
|
|
11375
|
+
}
|
|
11376
|
+
function withIdentity(identity, fn) {
|
|
11377
|
+
return identityStorage.run(identity, fn);
|
|
11378
|
+
}
|
|
11379
|
+
function extractBearerToken(authHeader) {
|
|
11380
|
+
if (!authHeader) return null;
|
|
11381
|
+
const match = /^Bearer (.+)$/i.exec(authHeader);
|
|
11382
|
+
return match ? match[1] : null;
|
|
11383
|
+
}
|
|
11384
|
+
async function authenticateHttp(req, registry) {
|
|
11385
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
11386
|
+
if (!token) {
|
|
11387
|
+
throw new MikroMCPError({
|
|
11388
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
11389
|
+
code: "INVALID_TOKEN",
|
|
11390
|
+
message: "Missing or malformed Authorization header. Use: Authorization: Bearer <token>",
|
|
11391
|
+
recoverability: {
|
|
11392
|
+
retryable: false,
|
|
11393
|
+
suggestedAction: "Provide a valid bearer token in the Authorization header."
|
|
11394
|
+
}
|
|
11395
|
+
});
|
|
11396
|
+
}
|
|
11397
|
+
const identity = await registry.findIdentityByToken(token);
|
|
11398
|
+
if (!identity) {
|
|
11399
|
+
throw new MikroMCPError({
|
|
11400
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
11401
|
+
code: "INVALID_TOKEN",
|
|
11402
|
+
message: "Token does not match any configured identity.",
|
|
11403
|
+
recoverability: {
|
|
11404
|
+
retryable: false,
|
|
11405
|
+
suggestedAction: "Verify the bearer token matches a configured identity in identities.yaml."
|
|
11406
|
+
}
|
|
11407
|
+
});
|
|
11408
|
+
}
|
|
11409
|
+
return identity;
|
|
11410
|
+
}
|
|
11411
|
+
var BUILTIN_SUPERADMIN = {
|
|
11412
|
+
id: "superadmin-builtin",
|
|
11413
|
+
role: "superadmin",
|
|
11414
|
+
allowedRouters: [],
|
|
11415
|
+
allowedToolPatterns: []
|
|
11416
|
+
};
|
|
11417
|
+
function getStdioIdentity(stdioIdentityName, registry) {
|
|
11418
|
+
if (!stdioIdentityName) {
|
|
11419
|
+
return BUILTIN_SUPERADMIN;
|
|
11420
|
+
}
|
|
11421
|
+
const found = registry.getIdentities().find((i) => i.id === stdioIdentityName);
|
|
11422
|
+
if (!found) {
|
|
11423
|
+
throw new MikroMCPError({
|
|
11424
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
11425
|
+
code: "STDIO_IDENTITY_NOT_FOUND",
|
|
11426
|
+
message: `MIKROMCP_STDIO_IDENTITY="${stdioIdentityName}" was not found in identities.yaml`,
|
|
11427
|
+
details: { stdioIdentityName },
|
|
11428
|
+
recoverability: {
|
|
11429
|
+
retryable: false,
|
|
11430
|
+
suggestedAction: `Add an identity named "${stdioIdentityName}" to ~/.mikromcp/identities.yaml or unset MIKROMCP_STDIO_IDENTITY.`
|
|
11431
|
+
}
|
|
11432
|
+
});
|
|
11433
|
+
}
|
|
11434
|
+
return found;
|
|
11435
|
+
}
|
|
11436
|
+
|
|
11437
|
+
// src/middleware/confirmation.ts
|
|
11438
|
+
import { createHmac, createHash as createHash3, timingSafeEqual } from "crypto";
|
|
11092
11439
|
var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
|
|
11093
|
-
var
|
|
11094
|
-
|
|
11095
|
-
|
|
11096
|
-
const
|
|
11097
|
-
|
|
11098
|
-
|
|
11099
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
11102
|
-
return
|
|
11440
|
+
var BYPASS_ROLES = ["admin", "superadmin"];
|
|
11441
|
+
var usedTokens = /* @__PURE__ */ new Map();
|
|
11442
|
+
function computeParamHash(params) {
|
|
11443
|
+
const { confirmationToken: _, ...rest } = params;
|
|
11444
|
+
return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
11445
|
+
}
|
|
11446
|
+
function sign(tool, routerId2, identityId, paramHash, expiresAtMs, secret) {
|
|
11447
|
+
const payload = `${tool}|${routerId2}|${identityId}|${paramHash}|${expiresAtMs}`;
|
|
11448
|
+
const mac = createHmac("sha256", secret).update(payload).digest("hex");
|
|
11449
|
+
return `${expiresAtMs}.${mac}`;
|
|
11103
11450
|
}
|
|
11104
|
-
function
|
|
11105
|
-
const
|
|
11106
|
-
|
|
11107
|
-
|
|
11451
|
+
function tokensEqual(a, b) {
|
|
11452
|
+
const bufA = Buffer.from(a, "utf-8");
|
|
11453
|
+
const bufB = Buffer.from(b, "utf-8");
|
|
11454
|
+
if (bufA.length !== bufB.length) return false;
|
|
11455
|
+
return timingSafeEqual(bufA, bufB);
|
|
11456
|
+
}
|
|
11457
|
+
function sweepUsed(now) {
|
|
11458
|
+
for (const [token, expiresAtMs] of usedTokens) {
|
|
11459
|
+
if (expiresAtMs <= now) usedTokens.delete(token);
|
|
11108
11460
|
}
|
|
11109
11461
|
}
|
|
11110
|
-
function
|
|
11111
|
-
|
|
11112
|
-
const
|
|
11113
|
-
|
|
11114
|
-
|
|
11115
|
-
|
|
11462
|
+
async function checkConfirmation(toolName, routerId2, params, identity, secret) {
|
|
11463
|
+
if (BYPASS_ROLES.includes(identity.role)) return;
|
|
11464
|
+
const now = Date.now();
|
|
11465
|
+
sweepUsed(now);
|
|
11466
|
+
const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
|
|
11467
|
+
const paramHash = computeParamHash(params);
|
|
11468
|
+
if (submittedToken) {
|
|
11469
|
+
const dot = submittedToken.indexOf(".");
|
|
11470
|
+
const expiresAtMs2 = dot > 0 ? Number(submittedToken.slice(0, dot)) : NaN;
|
|
11471
|
+
if (!Number.isFinite(expiresAtMs2) || expiresAtMs2 <= now || usedTokens.has(submittedToken)) {
|
|
11116
11472
|
throw new MikroMCPError({
|
|
11117
11473
|
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
11118
|
-
code: "
|
|
11119
|
-
message: "
|
|
11474
|
+
code: "CONFIRMATION_EXPIRED",
|
|
11475
|
+
message: "Confirmation token has expired or was already used. Re-submit without confirmationToken to get a fresh token.",
|
|
11120
11476
|
recoverability: {
|
|
11121
11477
|
retryable: true,
|
|
11122
|
-
suggestedAction: "Re-submit
|
|
11478
|
+
suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
|
|
11479
|
+
}
|
|
11480
|
+
});
|
|
11481
|
+
}
|
|
11482
|
+
const expected = sign(toolName, routerId2, identity.id, paramHash, expiresAtMs2, secret);
|
|
11483
|
+
if (!tokensEqual(submittedToken, expected)) {
|
|
11484
|
+
throw new MikroMCPError({
|
|
11485
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
11486
|
+
code: "CONFIRMATION_MISMATCH",
|
|
11487
|
+
message: "Confirmation token does not match the current call parameters.",
|
|
11488
|
+
recoverability: {
|
|
11489
|
+
retryable: true,
|
|
11490
|
+
suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
|
|
11123
11491
|
}
|
|
11124
11492
|
});
|
|
11125
11493
|
}
|
|
11126
|
-
|
|
11494
|
+
usedTokens.set(submittedToken, expiresAtMs2);
|
|
11127
11495
|
return;
|
|
11128
11496
|
}
|
|
11129
|
-
const
|
|
11130
|
-
const token =
|
|
11131
|
-
pending.set(token, { expiresAt, fingerprint: fp });
|
|
11497
|
+
const expiresAtMs = now + CONFIRMATION_TTL_MS;
|
|
11498
|
+
const token = sign(toolName, routerId2, identity.id, paramHash, expiresAtMs, secret);
|
|
11132
11499
|
throw new MikroMCPError({
|
|
11133
11500
|
category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
|
|
11134
|
-
code: "
|
|
11135
|
-
message:
|
|
11501
|
+
code: "CONFIRMATION_REQUIRED",
|
|
11502
|
+
message: "This action is destructive. Re-submit with confirmationToken to proceed.",
|
|
11136
11503
|
details: {
|
|
11137
11504
|
confirmationToken: token,
|
|
11138
|
-
expiresAt:
|
|
11139
|
-
|
|
11505
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
11506
|
+
tool: toolName,
|
|
11507
|
+
routerId: routerId2
|
|
11140
11508
|
},
|
|
11141
11509
|
recoverability: {
|
|
11142
11510
|
retryable: true,
|
|
11143
|
-
suggestedAction: "Re-submit the
|
|
11511
|
+
suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
|
|
11144
11512
|
}
|
|
11145
11513
|
});
|
|
11146
11514
|
}
|
|
11147
11515
|
|
|
11148
|
-
// src/
|
|
11149
|
-
var
|
|
11150
|
-
|
|
11151
|
-
|
|
11152
|
-
|
|
11153
|
-
|
|
11154
|
-
|
|
11155
|
-
|
|
11156
|
-
|
|
11157
|
-
|
|
11158
|
-
|
|
11159
|
-
|
|
11160
|
-
|
|
11161
|
-
|
|
11162
|
-
|
|
11163
|
-
var listRoutersInputSchema = z46.object({
|
|
11164
|
-
tags: z46.array(z46.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
|
|
11165
|
-
}).strict();
|
|
11166
|
-
var listRoutersTool = {
|
|
11167
|
-
name: "list_routers",
|
|
11168
|
-
title: "List Routers",
|
|
11169
|
-
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_execute).",
|
|
11170
|
-
inputSchema: listRoutersInputSchema,
|
|
11171
|
-
annotations: {
|
|
11172
|
-
readOnlyHint: true,
|
|
11173
|
-
destructiveHint: false,
|
|
11174
|
-
idempotentHint: true,
|
|
11175
|
-
openWorldHint: false
|
|
11176
|
-
},
|
|
11177
|
-
skipRouterContext: true,
|
|
11178
|
-
async handler(params, context) {
|
|
11179
|
-
const parsed = listRoutersInputSchema.parse(params);
|
|
11180
|
-
const { identity, appConfig } = context;
|
|
11181
|
-
const defaultRouter = appConfig.defaultRouter;
|
|
11182
|
-
log47.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
|
|
11183
|
-
let routers = context.routerRegistry.listRouters(parsed.tags);
|
|
11184
|
-
if (identity.allowedRouters.length > 0) {
|
|
11185
|
-
routers = routers.filter((r) => identity.allowedRouters.includes(r.id));
|
|
11516
|
+
// src/adapter/retry-engine.ts
|
|
11517
|
+
var DEFAULTS = {
|
|
11518
|
+
maxRetries: 3,
|
|
11519
|
+
baseDelayMs: 200,
|
|
11520
|
+
maxDelayMs: 5e3
|
|
11521
|
+
};
|
|
11522
|
+
var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
|
|
11523
|
+
function isRetryable(error) {
|
|
11524
|
+
if (error instanceof MikroMCPError) {
|
|
11525
|
+
return error.recoverability.retryable;
|
|
11526
|
+
}
|
|
11527
|
+
if (error instanceof Error) {
|
|
11528
|
+
const code = error.code;
|
|
11529
|
+
if (code && RETRYABLE_CODES.has(code)) {
|
|
11530
|
+
return true;
|
|
11186
11531
|
}
|
|
11187
|
-
const
|
|
11188
|
-
|
|
11189
|
-
|
|
11190
|
-
host: r.host,
|
|
11191
|
-
port: r.port,
|
|
11192
|
-
tlsEnabled: r.tls.enabled,
|
|
11193
|
-
tags: r.tags,
|
|
11194
|
-
rosVersion: r.rosVersion,
|
|
11195
|
-
isDefault: defaultRouter !== void 0 ? r.id === defaultRouter : r.id === soleDefaultId
|
|
11196
|
-
}));
|
|
11197
|
-
const total = rows.length;
|
|
11198
|
-
const header = `Routers: ${total === 0 ? "none" : `1-${total} of ${total}`}.`;
|
|
11199
|
-
const lines = rows.map(
|
|
11200
|
-
(r) => ` ${compactFields(
|
|
11201
|
-
{ ...r, tags: r.tags.join(",") },
|
|
11202
|
-
["id", "host", "port", "tlsEnabled", "tags", "rosVersion", "isDefault"]
|
|
11203
|
-
)}`
|
|
11204
|
-
);
|
|
11205
|
-
return {
|
|
11206
|
-
content: total === 0 ? header : [header, ...lines].join("\n"),
|
|
11207
|
-
structuredContent: { routers: rows, total, returned: total }
|
|
11208
|
-
};
|
|
11209
|
-
}
|
|
11210
|
-
};
|
|
11211
|
-
function createFleetTools(baseTools2) {
|
|
11212
|
-
const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
|
|
11213
|
-
const checkRouterHealthTool = {
|
|
11214
|
-
name: "check_router_health",
|
|
11215
|
-
title: "Check Router Health",
|
|
11216
|
-
description: "Probe a router by fetching system/resource. Returns health status, ROS version, uptime, CPU load, and memory info. Unlike other tools, this never throws \u2014 unreachable routers are reported as healthy=false.",
|
|
11217
|
-
inputSchema: checkHealthInputSchema,
|
|
11218
|
-
annotations: {
|
|
11219
|
-
readOnlyHint: true,
|
|
11220
|
-
destructiveHint: false,
|
|
11221
|
-
idempotentHint: true,
|
|
11222
|
-
openWorldHint: false
|
|
11223
|
-
},
|
|
11224
|
-
async handler(params, context) {
|
|
11225
|
-
checkHealthInputSchema.parse(params);
|
|
11226
|
-
log47.info({ routerId: context.routerId }, "Checking router health");
|
|
11227
|
-
const startMs = Date.now();
|
|
11228
|
-
try {
|
|
11229
|
-
const resources = await context.routerClient.get("system/resource");
|
|
11230
|
-
const resource = resources[0];
|
|
11231
|
-
const latencyMs = Date.now() - startMs;
|
|
11232
|
-
const result = {
|
|
11233
|
-
routerId: context.routerId,
|
|
11234
|
-
healthy: true,
|
|
11235
|
-
rosVersion: resource?.["version"],
|
|
11236
|
-
uptime: resource?.["uptime"],
|
|
11237
|
-
cpuLoad: resource?.["cpu-load"],
|
|
11238
|
-
freeMemory: resource?.["free-memory"],
|
|
11239
|
-
totalMemory: resource?.["total-memory"],
|
|
11240
|
-
latencyMs
|
|
11241
|
-
};
|
|
11242
|
-
return {
|
|
11243
|
-
content: `Router ${context.routerId} is healthy (${latencyMs}ms)`,
|
|
11244
|
-
structuredContent: result
|
|
11245
|
-
};
|
|
11246
|
-
} catch (err) {
|
|
11247
|
-
const latencyMs = Date.now() - startMs;
|
|
11248
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
11249
|
-
return {
|
|
11250
|
-
content: `Router ${context.routerId} is unhealthy: ${message}`,
|
|
11251
|
-
structuredContent: {
|
|
11252
|
-
routerId: context.routerId,
|
|
11253
|
-
healthy: false,
|
|
11254
|
-
latencyMs,
|
|
11255
|
-
error: message
|
|
11256
|
-
}
|
|
11257
|
-
};
|
|
11258
|
-
}
|
|
11532
|
+
const statusCode = error.statusCode;
|
|
11533
|
+
if (typeof statusCode === "number") {
|
|
11534
|
+
return statusCode >= 500;
|
|
11259
11535
|
}
|
|
11260
|
-
}
|
|
11261
|
-
|
|
11262
|
-
|
|
11263
|
-
|
|
11264
|
-
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
|
|
11268
|
-
|
|
11269
|
-
|
|
11270
|
-
|
|
11271
|
-
|
|
11272
|
-
|
|
11273
|
-
async handler(params, context) {
|
|
11274
|
-
const parsed = bulkExecuteInputSchema.parse(params);
|
|
11275
|
-
const hasRouterIds = Array.isArray(parsed.routerIds) && parsed.routerIds.length > 0;
|
|
11276
|
-
const hasTags = Array.isArray(parsed.tags) && parsed.tags.length > 0;
|
|
11277
|
-
if (hasRouterIds === hasTags) {
|
|
11278
|
-
throw new MikroMCPError({
|
|
11279
|
-
category: "VALIDATION" /* VALIDATION */,
|
|
11280
|
-
code: "BULK_TARGET_REQUIRED",
|
|
11281
|
-
message: "Provide exactly one of routerIds or tags, not both and not neither.",
|
|
11282
|
-
recoverability: {
|
|
11283
|
-
retryable: false,
|
|
11284
|
-
suggestedAction: "Supply either routerIds (array of IDs) or tags (array of tag strings)."
|
|
11285
|
-
}
|
|
11286
|
-
});
|
|
11287
|
-
}
|
|
11288
|
-
log47.info(
|
|
11289
|
-
{ toolName: parsed.toolName, concurrency: parsed.concurrency },
|
|
11290
|
-
"bulk_execute invoked"
|
|
11291
|
-
);
|
|
11292
|
-
auditLog({
|
|
11293
|
-
type: "audit",
|
|
11294
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11295
|
-
correlationId: context.correlationId,
|
|
11296
|
-
identityId: context.identity.id,
|
|
11297
|
-
role: context.identity.role,
|
|
11298
|
-
tool: "bulk_execute",
|
|
11299
|
-
routerId: "(fleet)",
|
|
11300
|
-
phase: "attempt",
|
|
11301
|
-
params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
|
|
11302
|
-
}, context.appConfig.auditLogPath);
|
|
11303
|
-
if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
|
|
11304
|
-
throw new MikroMCPError({
|
|
11305
|
-
category: "VALIDATION" /* VALIDATION */,
|
|
11306
|
-
code: "BULK_SELF_REFERENCE",
|
|
11307
|
-
message: `Cannot use bulk_execute to fan out fleet tools ("${parsed.toolName}").`,
|
|
11308
|
-
recoverability: {
|
|
11309
|
-
retryable: false,
|
|
11310
|
-
suggestedAction: "Choose a single-router tool as the toolName."
|
|
11311
|
-
}
|
|
11312
|
-
});
|
|
11313
|
-
}
|
|
11314
|
-
const foundTool = toolMap.get(parsed.toolName);
|
|
11315
|
-
if (!foundTool) {
|
|
11316
|
-
throw new MikroMCPError({
|
|
11317
|
-
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
11318
|
-
code: "TOOL_NOT_FOUND",
|
|
11319
|
-
message: `Tool "${parsed.toolName}" not found. Available tools: ${[...toolMap.keys()].join(", ")}`,
|
|
11320
|
-
recoverability: {
|
|
11321
|
-
retryable: false,
|
|
11322
|
-
suggestedAction: "Check the tool name and try again."
|
|
11323
|
-
}
|
|
11324
|
-
});
|
|
11325
|
-
}
|
|
11326
|
-
const targetTool = foundTool;
|
|
11327
|
-
const preErrors = [];
|
|
11328
|
-
let routers;
|
|
11329
|
-
if (hasRouterIds) {
|
|
11330
|
-
const resolved = [];
|
|
11331
|
-
for (const id of parsed.routerIds) {
|
|
11332
|
-
try {
|
|
11333
|
-
resolved.push(context.routerRegistry.getRouter(id));
|
|
11334
|
-
} catch {
|
|
11335
|
-
log47.warn({ routerId: id }, "bulk_execute: router not found");
|
|
11336
|
-
preErrors.push({
|
|
11337
|
-
routerId: id,
|
|
11338
|
-
status: "error",
|
|
11339
|
-
error: `Router "${id}" not found in registry`,
|
|
11340
|
-
durationMs: 0
|
|
11341
|
-
});
|
|
11342
|
-
}
|
|
11343
|
-
}
|
|
11344
|
-
routers = resolved;
|
|
11345
|
-
} else {
|
|
11346
|
-
routers = context.routerRegistry.listRouters(parsed.tags);
|
|
11347
|
-
}
|
|
11348
|
-
if (targetTool.annotations.destructiveHint) {
|
|
11349
|
-
const secret = context.appConfig.confirmationSecret;
|
|
11350
|
-
if (!secret) {
|
|
11351
|
-
throw new MikroMCPError({
|
|
11352
|
-
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
11353
|
-
code: "FLEET_CONFIRMATION_UNAVAILABLE",
|
|
11354
|
-
message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
|
|
11355
|
-
recoverability: {
|
|
11356
|
-
retryable: false,
|
|
11357
|
-
suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
|
|
11358
|
-
}
|
|
11359
|
-
});
|
|
11360
|
-
}
|
|
11361
|
-
const routerIdList = routers.map((r) => r.id);
|
|
11362
|
-
checkFleetConfirmation(
|
|
11363
|
-
{
|
|
11364
|
-
toolName: parsed.toolName,
|
|
11365
|
-
routerIds: routerIdList,
|
|
11366
|
-
params: parsed.params,
|
|
11367
|
-
identityId: context.identity.id,
|
|
11368
|
-
submittedToken: parsed.confirmationToken
|
|
11369
|
-
},
|
|
11370
|
-
secret
|
|
11371
|
-
);
|
|
11372
|
-
}
|
|
11373
|
-
if (routers.length === 0 && preErrors.length === 0) {
|
|
11374
|
-
auditLog({
|
|
11375
|
-
type: "audit",
|
|
11376
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11377
|
-
correlationId: context.correlationId,
|
|
11378
|
-
identityId: context.identity.id,
|
|
11379
|
-
role: context.identity.role,
|
|
11380
|
-
tool: "bulk_execute",
|
|
11381
|
-
routerId: "(fleet)",
|
|
11382
|
-
phase: "success",
|
|
11383
|
-
params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
|
|
11384
|
-
}, context.appConfig.auditLogPath);
|
|
11385
|
-
return {
|
|
11386
|
-
content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
|
|
11387
|
-
structuredContent: {
|
|
11388
|
-
toolName: parsed.toolName,
|
|
11389
|
-
totalRouters: 0,
|
|
11390
|
-
succeeded: 0,
|
|
11391
|
-
failed: 0,
|
|
11392
|
-
results: []
|
|
11393
|
-
}
|
|
11394
|
-
};
|
|
11395
|
-
}
|
|
11396
|
-
const isWrite = !targetTool.annotations.readOnlyHint;
|
|
11397
|
-
const snapshotDir = context.appConfig.snapshotDir;
|
|
11398
|
-
const journalPath = context.appConfig.journalPath;
|
|
11399
|
-
async function runForRouter(router) {
|
|
11400
|
-
const start = Date.now();
|
|
11401
|
-
let journalId;
|
|
11402
|
-
try {
|
|
11403
|
-
checkAuthz(context.identity, parsed.toolName, router.id);
|
|
11404
|
-
const routerContext = buildRouterToolContext({
|
|
11405
|
-
routerConfig: router,
|
|
11406
|
-
correlationId: context.correlationId,
|
|
11407
|
-
identity: context.identity,
|
|
11408
|
-
pool: context.connectionPool,
|
|
11409
|
-
config: context.appConfig,
|
|
11410
|
-
registry: context.routerRegistry
|
|
11411
|
-
});
|
|
11412
|
-
const toolParams = { ...parsed.params, routerId: router.id };
|
|
11413
|
-
const snapshotIds = [];
|
|
11414
|
-
if (isWrite && targetTool.snapshotPaths && targetTool.snapshotPaths.length > 0) {
|
|
11415
|
-
for (const path of targetTool.snapshotPaths) {
|
|
11416
|
-
try {
|
|
11417
|
-
const meta = await takeSnapshot(
|
|
11418
|
-
routerContext.routerClient,
|
|
11419
|
-
router.id,
|
|
11420
|
-
path,
|
|
11421
|
-
snapshotDir
|
|
11422
|
-
);
|
|
11423
|
-
snapshotIds.push(meta.id);
|
|
11424
|
-
} catch (err) {
|
|
11425
|
-
log47.warn(
|
|
11426
|
-
{ err, path, routerId: router.id },
|
|
11427
|
-
"bulk_execute snapshot failed \u2014 proceeding without snapshot"
|
|
11428
|
-
);
|
|
11429
|
-
}
|
|
11430
|
-
}
|
|
11431
|
-
}
|
|
11432
|
-
if (isWrite && journalPath) {
|
|
11433
|
-
journalId = recordAttempt({
|
|
11434
|
-
journalPath,
|
|
11435
|
-
identityId: context.identity.id,
|
|
11436
|
-
role: context.identity.role,
|
|
11437
|
-
tool: parsed.toolName,
|
|
11438
|
-
routerId: router.id,
|
|
11439
|
-
params: toolParams,
|
|
11440
|
-
snapshotIds
|
|
11441
|
-
});
|
|
11442
|
-
}
|
|
11443
|
-
const result = await targetTool.handler(
|
|
11444
|
-
toolParams,
|
|
11445
|
-
routerContext
|
|
11446
|
-
);
|
|
11447
|
-
const elapsed = Date.now() - start;
|
|
11448
|
-
if (journalId) {
|
|
11449
|
-
recordOutcome({ journalPath, journalId, phase: "success", durationMs: elapsed });
|
|
11450
|
-
}
|
|
11451
|
-
auditLog({
|
|
11452
|
-
type: "audit",
|
|
11453
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11454
|
-
correlationId: context.correlationId,
|
|
11455
|
-
identityId: context.identity.id,
|
|
11456
|
-
role: context.identity.role,
|
|
11457
|
-
tool: parsed.toolName,
|
|
11458
|
-
routerId: router.id,
|
|
11459
|
-
phase: "success",
|
|
11460
|
-
params: parsed.params,
|
|
11461
|
-
durationMs: elapsed
|
|
11462
|
-
}, context.appConfig.auditLogPath);
|
|
11463
|
-
return { routerId: router.id, status: "ok", result, durationMs: elapsed };
|
|
11464
|
-
} catch (err) {
|
|
11465
|
-
const elapsed = Date.now() - start;
|
|
11466
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
11467
|
-
if (journalId) {
|
|
11468
|
-
recordOutcome({ journalPath, journalId, phase: "failure", outcome: message, durationMs: elapsed });
|
|
11469
|
-
}
|
|
11470
|
-
auditLog({
|
|
11471
|
-
type: "audit",
|
|
11472
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11473
|
-
correlationId: context.correlationId,
|
|
11474
|
-
identityId: context.identity.id,
|
|
11475
|
-
role: context.identity.role,
|
|
11476
|
-
tool: parsed.toolName,
|
|
11477
|
-
routerId: router.id,
|
|
11478
|
-
phase: "failure",
|
|
11479
|
-
params: parsed.params,
|
|
11480
|
-
outcome: message,
|
|
11481
|
-
durationMs: elapsed
|
|
11482
|
-
}, context.appConfig.auditLogPath);
|
|
11483
|
-
return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
|
|
11484
|
-
}
|
|
11485
|
-
}
|
|
11486
|
-
const results2 = [...preErrors];
|
|
11487
|
-
for (let i = 0; i < routers.length; i += parsed.concurrency) {
|
|
11488
|
-
const batch = routers.slice(i, i + parsed.concurrency);
|
|
11489
|
-
const batchResults = await Promise.all(batch.map(runForRouter));
|
|
11490
|
-
results2.push(...batchResults);
|
|
11536
|
+
}
|
|
11537
|
+
return false;
|
|
11538
|
+
}
|
|
11539
|
+
async function withRetry(fn, options) {
|
|
11540
|
+
const opts = { ...DEFAULTS, ...options };
|
|
11541
|
+
let lastError;
|
|
11542
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
11543
|
+
try {
|
|
11544
|
+
return await fn();
|
|
11545
|
+
} catch (error) {
|
|
11546
|
+
lastError = error;
|
|
11547
|
+
if (attempt >= opts.maxRetries || !isRetryable(error)) {
|
|
11548
|
+
throw error;
|
|
11491
11549
|
}
|
|
11492
|
-
const
|
|
11493
|
-
const
|
|
11494
|
-
|
|
11495
|
-
|
|
11496
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11497
|
-
correlationId: context.correlationId,
|
|
11498
|
-
identityId: context.identity.id,
|
|
11499
|
-
role: context.identity.role,
|
|
11500
|
-
tool: "bulk_execute",
|
|
11501
|
-
routerId: "(fleet)",
|
|
11502
|
-
phase: "success",
|
|
11503
|
-
params: { toolName: parsed.toolName, succeeded, failed }
|
|
11504
|
-
}, context.appConfig.auditLogPath);
|
|
11505
|
-
return {
|
|
11506
|
-
content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
|
|
11507
|
-
structuredContent: {
|
|
11508
|
-
toolName: parsed.toolName,
|
|
11509
|
-
totalRouters: results2.length,
|
|
11510
|
-
succeeded,
|
|
11511
|
-
failed,
|
|
11512
|
-
results: results2
|
|
11513
|
-
}
|
|
11514
|
-
};
|
|
11550
|
+
const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
|
|
11551
|
+
const jitter = Math.random() * opts.baseDelayMs;
|
|
11552
|
+
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
|
|
11553
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
11515
11554
|
}
|
|
11516
|
-
}
|
|
11517
|
-
|
|
11555
|
+
}
|
|
11556
|
+
throw lastError;
|
|
11518
11557
|
}
|
|
11519
11558
|
|
|
11520
|
-
// src/
|
|
11521
|
-
var
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11534
|
-
|
|
11535
|
-
|
|
11536
|
-
|
|
11537
|
-
|
|
11538
|
-
|
|
11539
|
-
|
|
11540
|
-
|
|
11541
|
-
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
...pppoeTools,
|
|
11556
|
-
...ovpnTools,
|
|
11557
|
-
...pppTools,
|
|
11558
|
-
...upgradeTools,
|
|
11559
|
-
...backupTools,
|
|
11560
|
-
...logTools,
|
|
11561
|
-
...networkTestTools,
|
|
11562
|
-
...containerConfigTools,
|
|
11563
|
-
...interfaceListTools
|
|
11564
|
-
];
|
|
11565
|
-
var allTools = [
|
|
11566
|
-
...baseTools,
|
|
11567
|
-
...createChangeManagementTools(baseTools),
|
|
11568
|
-
...createFleetTools(baseTools)
|
|
11569
|
-
];
|
|
11570
|
-
|
|
11571
|
-
// src/mcp/tool-executor.ts
|
|
11572
|
-
import { nanoid as nanoid4 } from "nanoid";
|
|
11559
|
+
// src/config/maintenance-window.ts
|
|
11560
|
+
var DAY_ABBREVS = {
|
|
11561
|
+
Sun: "Sun",
|
|
11562
|
+
Mon: "Mon",
|
|
11563
|
+
Tue: "Tue",
|
|
11564
|
+
Wed: "Wed",
|
|
11565
|
+
Thu: "Thu",
|
|
11566
|
+
Fri: "Fri",
|
|
11567
|
+
Sat: "Sat"
|
|
11568
|
+
};
|
|
11569
|
+
var DAY_ORDER = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
11570
|
+
function isWithinMaintenanceWindow(windows, now) {
|
|
11571
|
+
if (windows.length === 0) return true;
|
|
11572
|
+
return windows.some((w) => {
|
|
11573
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
11574
|
+
timeZone: w.timezone,
|
|
11575
|
+
weekday: "short",
|
|
11576
|
+
hour: "2-digit",
|
|
11577
|
+
minute: "2-digit",
|
|
11578
|
+
hour12: false
|
|
11579
|
+
}).formatToParts(now);
|
|
11580
|
+
const rawDay = parts.find((p) => p.type === "weekday")?.value ?? "";
|
|
11581
|
+
const day = DAY_ABBREVS[rawDay];
|
|
11582
|
+
if (!day) return false;
|
|
11583
|
+
const hour = parts.find((p) => p.type === "hour")?.value?.padStart(2, "0") ?? "00";
|
|
11584
|
+
const minute = parts.find((p) => p.type === "minute")?.value?.padStart(2, "0") ?? "00";
|
|
11585
|
+
const currentTime = `${hour}:${minute}`;
|
|
11586
|
+
if (w.startTime === w.endTime) return false;
|
|
11587
|
+
if (w.startTime < w.endTime) {
|
|
11588
|
+
return w.days.includes(day) && currentTime >= w.startTime && currentTime <= w.endTime;
|
|
11589
|
+
}
|
|
11590
|
+
const prevDay = DAY_ORDER[(DAY_ORDER.indexOf(day) + 6) % 7];
|
|
11591
|
+
return w.days.includes(day) && currentTime >= w.startTime || w.days.includes(prevDay) && currentTime <= w.endTime;
|
|
11592
|
+
});
|
|
11593
|
+
}
|
|
11573
11594
|
|
|
11574
|
-
// src/
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
11578
|
-
|
|
11579
|
-
|
|
11580
|
-
correlationId: ctx.correlationId ?? nanoid3(),
|
|
11581
|
-
routerId: ctx.routerId,
|
|
11582
|
-
tool: ctx.tool,
|
|
11583
|
-
identity: ctx.identity
|
|
11595
|
+
// src/mcp/response-formatter.ts
|
|
11596
|
+
function formatToolResult(result) {
|
|
11597
|
+
return {
|
|
11598
|
+
content: [{ type: "text", text: result.content }],
|
|
11599
|
+
structuredContent: result.structuredContent,
|
|
11600
|
+
...result.isError ? { isError: true } : {}
|
|
11584
11601
|
};
|
|
11585
|
-
return requestContext.run(fullContext, fn);
|
|
11586
11602
|
}
|
|
11587
|
-
|
|
11588
|
-
|
|
11589
|
-
|
|
11590
|
-
|
|
11591
|
-
|
|
11592
|
-
|
|
11603
|
+
function formatError(error) {
|
|
11604
|
+
const hint = error.recoverability.suggestedAction;
|
|
11605
|
+
const text = `Error [${error.category}]: ${error.message}${hint ? `
|
|
11606
|
+
Suggested action: ${hint}` : ""}`;
|
|
11607
|
+
return {
|
|
11608
|
+
content: [{ type: "text", text }],
|
|
11609
|
+
structuredContent: error.toJSON(),
|
|
11610
|
+
isError: true
|
|
11611
|
+
};
|
|
11593
11612
|
}
|
|
11594
|
-
|
|
11595
|
-
|
|
11613
|
+
|
|
11614
|
+
// src/observability/metrics.ts
|
|
11615
|
+
var toolCalls = /* @__PURE__ */ new Map();
|
|
11616
|
+
function key(tool, status) {
|
|
11617
|
+
return `${tool}\0${status}`;
|
|
11596
11618
|
}
|
|
11597
|
-
function
|
|
11598
|
-
|
|
11599
|
-
|
|
11600
|
-
return match ? match[1] : null;
|
|
11619
|
+
function recordToolCall(tool, status) {
|
|
11620
|
+
const k = key(tool, status);
|
|
11621
|
+
toolCalls.set(k, (toolCalls.get(k) ?? 0) + 1);
|
|
11601
11622
|
}
|
|
11602
|
-
|
|
11603
|
-
const
|
|
11604
|
-
|
|
11605
|
-
|
|
11606
|
-
|
|
11607
|
-
|
|
11608
|
-
|
|
11609
|
-
|
|
11610
|
-
retryable: false,
|
|
11611
|
-
suggestedAction: "Provide a valid bearer token in the Authorization header."
|
|
11612
|
-
}
|
|
11613
|
-
});
|
|
11623
|
+
function renderPrometheus() {
|
|
11624
|
+
const lines = [
|
|
11625
|
+
"# HELP mikromcp_tool_calls_total Total MCP tool calls by tool and status.",
|
|
11626
|
+
"# TYPE mikromcp_tool_calls_total counter"
|
|
11627
|
+
];
|
|
11628
|
+
for (const [k, count] of toolCalls) {
|
|
11629
|
+
const [tool, status] = k.split("\0");
|
|
11630
|
+
lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
|
|
11614
11631
|
}
|
|
11615
|
-
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
11624
|
-
|
|
11625
|
-
|
|
11632
|
+
return lines.join("\n") + "\n";
|
|
11633
|
+
}
|
|
11634
|
+
|
|
11635
|
+
// src/adapter/circuit-breaker.ts
|
|
11636
|
+
var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
|
|
11637
|
+
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
|
|
11638
|
+
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
11639
|
+
"ROUTER_ERROR" /* ROUTER_ERROR */,
|
|
11640
|
+
"ROUTER_BUSY" /* ROUTER_BUSY */
|
|
11641
|
+
]);
|
|
11642
|
+
function isTransientFailure(error) {
|
|
11643
|
+
if (!(error instanceof MikroMCPError)) {
|
|
11644
|
+
return false;
|
|
11626
11645
|
}
|
|
11627
|
-
return
|
|
11646
|
+
return TRANSIENT_CATEGORIES.has(error.category);
|
|
11628
11647
|
}
|
|
11629
|
-
var
|
|
11630
|
-
|
|
11631
|
-
|
|
11632
|
-
allowedRouters: [],
|
|
11633
|
-
allowedToolPatterns: []
|
|
11648
|
+
var DEFAULTS2 = {
|
|
11649
|
+
failureThreshold: 5,
|
|
11650
|
+
cooldownMs: 3e4
|
|
11634
11651
|
};
|
|
11635
|
-
|
|
11636
|
-
|
|
11637
|
-
|
|
11652
|
+
var CircuitBreaker = class {
|
|
11653
|
+
constructor(routerId2, options) {
|
|
11654
|
+
this.routerId = routerId2;
|
|
11655
|
+
this.options = { ...DEFAULTS2, ...options };
|
|
11638
11656
|
}
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
11642
|
-
|
|
11643
|
-
|
|
11644
|
-
|
|
11645
|
-
|
|
11646
|
-
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11657
|
+
routerId;
|
|
11658
|
+
options;
|
|
11659
|
+
currentState = "closed";
|
|
11660
|
+
consecutiveFailures = 0;
|
|
11661
|
+
lastFailureTime = 0;
|
|
11662
|
+
probeInFlight = false;
|
|
11663
|
+
/** Effective state, accounting for an elapsed cooldown. Pure — does not mutate. */
|
|
11664
|
+
get state() {
|
|
11665
|
+
if (this.currentState === "open" && this.cooldownElapsed()) {
|
|
11666
|
+
return "half-open";
|
|
11667
|
+
}
|
|
11668
|
+
return this.currentState;
|
|
11651
11669
|
}
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
// src/middleware/confirmation.ts
|
|
11656
|
-
import { createHmac as createHmac2, createHash as createHash3 } from "crypto";
|
|
11657
|
-
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
11658
|
-
var BYPASS_ROLES = ["admin", "superadmin"];
|
|
11659
|
-
var pendingConfirmations = /* @__PURE__ */ new Map();
|
|
11660
|
-
function computeParamHash(params) {
|
|
11661
|
-
const { confirmationToken: _, ...rest } = params;
|
|
11662
|
-
return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
11663
|
-
}
|
|
11664
|
-
function computeToken(tool, routerId2, identityId, paramHash, expiresAt, secret) {
|
|
11665
|
-
const payload = JSON.stringify({ tool, routerId: routerId2, paramHash, identityId, expiresAt });
|
|
11666
|
-
return createHmac2("sha256", secret).update(payload).digest("hex");
|
|
11667
|
-
}
|
|
11668
|
-
function evictExpired2() {
|
|
11669
|
-
const now = /* @__PURE__ */ new Date();
|
|
11670
|
-
for (const [token, entry] of pendingConfirmations) {
|
|
11671
|
-
if (entry.expiresAt <= now) pendingConfirmations.delete(token);
|
|
11670
|
+
cooldownElapsed() {
|
|
11671
|
+
return Date.now() - this.lastFailureTime >= this.options.cooldownMs;
|
|
11672
11672
|
}
|
|
11673
|
-
|
|
11674
|
-
|
|
11675
|
-
|
|
11676
|
-
|
|
11677
|
-
const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
|
|
11678
|
-
if (submittedToken) {
|
|
11679
|
-
const entry = pendingConfirmations.get(submittedToken);
|
|
11680
|
-
if (!entry || entry.expiresAt <= /* @__PURE__ */ new Date()) {
|
|
11681
|
-
throw new MikroMCPError({
|
|
11682
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
11683
|
-
code: "CONFIRMATION_EXPIRED",
|
|
11684
|
-
message: "Confirmation token has expired or was already used. Re-submit without confirmationToken to get a fresh token.",
|
|
11685
|
-
recoverability: {
|
|
11686
|
-
retryable: true,
|
|
11687
|
-
suggestedAction: "Re-submit the tool call without confirmationToken to receive a new token."
|
|
11688
|
-
}
|
|
11689
|
-
});
|
|
11673
|
+
/** Apply the time-based open→half-open transition. Call before acting on state. */
|
|
11674
|
+
transitionIfCooldownElapsed() {
|
|
11675
|
+
if (this.currentState === "open" && this.cooldownElapsed()) {
|
|
11676
|
+
this.currentState = "half-open";
|
|
11690
11677
|
}
|
|
11691
|
-
|
|
11692
|
-
|
|
11678
|
+
}
|
|
11679
|
+
/**
|
|
11680
|
+
* Execute `fn` through the circuit breaker.
|
|
11681
|
+
*
|
|
11682
|
+
* - **closed**: execute normally; track consecutive failures.
|
|
11683
|
+
* - **open**: reject immediately with `ROUTER_BUSY`.
|
|
11684
|
+
* - **half-open**: execute as a probe; success closes, failure reopens.
|
|
11685
|
+
*/
|
|
11686
|
+
async execute(fn) {
|
|
11687
|
+
this.transitionIfCooldownElapsed();
|
|
11688
|
+
const currentState = this.currentState;
|
|
11689
|
+
if (currentState === "open") {
|
|
11693
11690
|
throw new MikroMCPError({
|
|
11694
|
-
category: "
|
|
11695
|
-
code: "
|
|
11696
|
-
message:
|
|
11691
|
+
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
11692
|
+
code: "CIRCUIT_OPEN",
|
|
11693
|
+
message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
|
|
11694
|
+
details: {
|
|
11695
|
+
routerId: this.routerId,
|
|
11696
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
11697
|
+
cooldownMs: this.options.cooldownMs
|
|
11698
|
+
},
|
|
11697
11699
|
recoverability: {
|
|
11698
11700
|
retryable: true,
|
|
11699
|
-
|
|
11701
|
+
retryAfterMs: this.options.cooldownMs,
|
|
11702
|
+
suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
|
|
11700
11703
|
}
|
|
11701
11704
|
});
|
|
11702
11705
|
}
|
|
11703
|
-
|
|
11704
|
-
|
|
11706
|
+
if (currentState === "half-open") {
|
|
11707
|
+
if (this.probeInFlight) {
|
|
11708
|
+
throw new MikroMCPError({
|
|
11709
|
+
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
11710
|
+
code: "CIRCUIT_PROBE_IN_FLIGHT",
|
|
11711
|
+
message: `Circuit breaker for router "${this.routerId}" is half-open and a recovery probe is already in flight.`,
|
|
11712
|
+
details: { routerId: this.routerId },
|
|
11713
|
+
recoverability: {
|
|
11714
|
+
retryable: true,
|
|
11715
|
+
retryAfterMs: 1e3,
|
|
11716
|
+
suggestedAction: "Wait for the in-flight probe to complete, then retry."
|
|
11717
|
+
}
|
|
11718
|
+
});
|
|
11719
|
+
}
|
|
11720
|
+
this.probeInFlight = true;
|
|
11721
|
+
}
|
|
11722
|
+
try {
|
|
11723
|
+
const result = await fn();
|
|
11724
|
+
this.onSuccess();
|
|
11725
|
+
return result;
|
|
11726
|
+
} catch (error) {
|
|
11727
|
+
if (isTransientFailure(error)) {
|
|
11728
|
+
this.onFailure();
|
|
11729
|
+
}
|
|
11730
|
+
throw error;
|
|
11731
|
+
} finally {
|
|
11732
|
+
this.probeInFlight = false;
|
|
11733
|
+
}
|
|
11705
11734
|
}
|
|
11706
|
-
|
|
11707
|
-
|
|
11708
|
-
|
|
11709
|
-
|
|
11710
|
-
|
|
11711
|
-
|
|
11712
|
-
|
|
11713
|
-
|
|
11714
|
-
|
|
11715
|
-
|
|
11735
|
+
/** Force the circuit back to the closed state. */
|
|
11736
|
+
reset() {
|
|
11737
|
+
this.currentState = "closed";
|
|
11738
|
+
this.consecutiveFailures = 0;
|
|
11739
|
+
this.lastFailureTime = 0;
|
|
11740
|
+
this.probeInFlight = false;
|
|
11741
|
+
}
|
|
11742
|
+
// ---------- internal helpers ----------
|
|
11743
|
+
onSuccess() {
|
|
11744
|
+
this.consecutiveFailures = 0;
|
|
11745
|
+
this.currentState = "closed";
|
|
11746
|
+
}
|
|
11747
|
+
onFailure() {
|
|
11748
|
+
this.consecutiveFailures++;
|
|
11749
|
+
this.lastFailureTime = Date.now();
|
|
11750
|
+
if (this.currentState === "half-open") {
|
|
11751
|
+
this.currentState = "open";
|
|
11752
|
+
} else if (this.consecutiveFailures >= this.options.failureThreshold) {
|
|
11753
|
+
this.currentState = "open";
|
|
11754
|
+
}
|
|
11755
|
+
}
|
|
11756
|
+
};
|
|
11757
|
+
|
|
11758
|
+
// src/mcp/tool-executor.ts
|
|
11759
|
+
var log47 = createLogger("tool-executor");
|
|
11760
|
+
var AMBIGUOUS_WRITE_CATEGORIES = /* @__PURE__ */ new Set([
|
|
11761
|
+
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
11762
|
+
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */
|
|
11763
|
+
]);
|
|
11764
|
+
var VERIFY_STATE_PREFIX = "The write may already have been applied \u2014 verify router state before retrying. ";
|
|
11765
|
+
function resolveRouterId(explicit, registry, config) {
|
|
11766
|
+
if (explicit) {
|
|
11767
|
+
return explicit;
|
|
11768
|
+
}
|
|
11769
|
+
const fallback = config.defaultRouter ?? registry.soleRouterId();
|
|
11770
|
+
if (fallback) {
|
|
11771
|
+
return fallback;
|
|
11772
|
+
}
|
|
11773
|
+
const available = registry.routerIds();
|
|
11716
11774
|
throw new MikroMCPError({
|
|
11717
|
-
category: "
|
|
11718
|
-
code: "
|
|
11719
|
-
message:
|
|
11720
|
-
details: {
|
|
11721
|
-
confirmationToken: token,
|
|
11722
|
-
expiresAt,
|
|
11723
|
-
tool: toolName,
|
|
11724
|
-
routerId: routerId2
|
|
11725
|
-
},
|
|
11775
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
11776
|
+
code: "MISSING_ROUTER_ID",
|
|
11777
|
+
message: available.length > 0 ? `routerId is required: more than one router is configured. Available: ${available.join(", ")}.` : "routerId is required, but no routers are configured.",
|
|
11778
|
+
details: { availableRouters: available },
|
|
11726
11779
|
recoverability: {
|
|
11727
|
-
retryable:
|
|
11728
|
-
suggestedAction: "
|
|
11780
|
+
retryable: false,
|
|
11781
|
+
suggestedAction: available.length > 0 ? "Provide a routerId, or set MIKROMCP_DEFAULT_ROUTER to choose a default." : "Configure at least one router in routers.yaml."
|
|
11729
11782
|
}
|
|
11730
11783
|
});
|
|
11731
11784
|
}
|
|
11732
|
-
|
|
11733
|
-
|
|
11734
|
-
|
|
11735
|
-
|
|
11736
|
-
|
|
11737
|
-
|
|
11738
|
-
};
|
|
11739
|
-
|
|
11740
|
-
function isRetryable(error) {
|
|
11741
|
-
if (error instanceof MikroMCPError) {
|
|
11742
|
-
return error.recoverability.retryable;
|
|
11743
|
-
}
|
|
11744
|
-
if (error instanceof Error) {
|
|
11745
|
-
const code = error.code;
|
|
11746
|
-
if (code && RETRYABLE_CODES.has(code)) {
|
|
11747
|
-
return true;
|
|
11748
|
-
}
|
|
11749
|
-
const statusCode = error.statusCode;
|
|
11750
|
-
if (typeof statusCode === "number") {
|
|
11751
|
-
return statusCode >= 500;
|
|
11752
|
-
}
|
|
11785
|
+
function getOrCreateBreaker(circuitBreakers, routerId2, config) {
|
|
11786
|
+
let cb = circuitBreakers.get(routerId2);
|
|
11787
|
+
if (!cb) {
|
|
11788
|
+
cb = new CircuitBreaker(routerId2, {
|
|
11789
|
+
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
11790
|
+
cooldownMs: config.circuitBreaker.cooldownMs
|
|
11791
|
+
});
|
|
11792
|
+
circuitBreakers.set(routerId2, cb);
|
|
11753
11793
|
}
|
|
11754
|
-
return
|
|
11794
|
+
return cb;
|
|
11755
11795
|
}
|
|
11756
|
-
|
|
11757
|
-
|
|
11758
|
-
|
|
11759
|
-
|
|
11760
|
-
|
|
11761
|
-
|
|
11762
|
-
|
|
11763
|
-
|
|
11764
|
-
|
|
11765
|
-
|
|
11796
|
+
function assertMaintenanceWindow(isDestructive, routerConfig, routerId2) {
|
|
11797
|
+
if (!isDestructive) return;
|
|
11798
|
+
const windows = routerConfig.maintenanceWindows;
|
|
11799
|
+
if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
|
|
11800
|
+
throw new MikroMCPError({
|
|
11801
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
11802
|
+
code: "OUTSIDE_MAINTENANCE_WINDOW",
|
|
11803
|
+
message: `Router "${routerId2}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
|
|
11804
|
+
details: { maintenanceWindows: windows },
|
|
11805
|
+
recoverability: {
|
|
11806
|
+
retryable: true,
|
|
11807
|
+
suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
|
|
11766
11808
|
}
|
|
11767
|
-
|
|
11768
|
-
const jitter = Math.random() * opts.baseDelayMs;
|
|
11769
|
-
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
|
|
11770
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
11771
|
-
}
|
|
11809
|
+
});
|
|
11772
11810
|
}
|
|
11773
|
-
throw lastError;
|
|
11774
11811
|
}
|
|
11775
|
-
|
|
11776
|
-
|
|
11777
|
-
|
|
11778
|
-
|
|
11779
|
-
|
|
11780
|
-
|
|
11781
|
-
|
|
11782
|
-
|
|
11783
|
-
|
|
11784
|
-
|
|
11785
|
-
}
|
|
11786
|
-
|
|
11787
|
-
|
|
11788
|
-
return windows.some((w) => {
|
|
11789
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
11790
|
-
timeZone: w.timezone,
|
|
11791
|
-
weekday: "short",
|
|
11792
|
-
hour: "2-digit",
|
|
11793
|
-
minute: "2-digit",
|
|
11794
|
-
hour12: false
|
|
11795
|
-
}).formatToParts(now);
|
|
11796
|
-
const rawDay = parts.find((p) => p.type === "weekday")?.value ?? "";
|
|
11797
|
-
const day = DAY_ABBREVS[rawDay];
|
|
11798
|
-
if (!day || !w.days.includes(day)) return false;
|
|
11799
|
-
const hour = parts.find((p) => p.type === "hour")?.value?.padStart(2, "0") ?? "00";
|
|
11800
|
-
const minute = parts.find((p) => p.type === "minute")?.value?.padStart(2, "0") ?? "00";
|
|
11801
|
-
const currentTime = `${hour}:${minute}`;
|
|
11802
|
-
return w.startTime !== w.endTime && currentTime >= w.startTime && currentTime <= w.endTime;
|
|
11812
|
+
function fleetUnavailable(what) {
|
|
11813
|
+
return new Proxy({}, {
|
|
11814
|
+
get() {
|
|
11815
|
+
throw new MikroMCPError({
|
|
11816
|
+
category: "INTERNAL" /* INTERNAL */,
|
|
11817
|
+
code: "FLEET_CONTEXT_UNAVAILABLE",
|
|
11818
|
+
message: `${what} is not available in a fleet-tool context (skipRouterContext). Target a specific router instead.`,
|
|
11819
|
+
recoverability: {
|
|
11820
|
+
retryable: false,
|
|
11821
|
+
suggestedAction: "Use a router-scoped tool, or pass a routerId to operate on one router."
|
|
11822
|
+
}
|
|
11823
|
+
});
|
|
11824
|
+
}
|
|
11803
11825
|
});
|
|
11804
11826
|
}
|
|
11805
|
-
|
|
11806
|
-
|
|
11807
|
-
|
|
11808
|
-
return {
|
|
11809
|
-
|
|
11810
|
-
|
|
11811
|
-
|
|
11812
|
-
|
|
11813
|
-
|
|
11814
|
-
|
|
11815
|
-
|
|
11816
|
-
|
|
11817
|
-
|
|
11818
|
-
|
|
11819
|
-
|
|
11820
|
-
|
|
11821
|
-
|
|
11822
|
-
|
|
11827
|
+
async function executeToolCall(tool, args, deps) {
|
|
11828
|
+
const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
|
|
11829
|
+
const correlationId = nanoid4();
|
|
11830
|
+
return withContext({ correlationId, tool: tool.name }, async () => {
|
|
11831
|
+
const startMs = Date.now();
|
|
11832
|
+
let journalId;
|
|
11833
|
+
try {
|
|
11834
|
+
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
11835
|
+
if (tool.skipRouterContext) {
|
|
11836
|
+
const fleetContext = {
|
|
11837
|
+
routerClient: fleetUnavailable("routerClient"),
|
|
11838
|
+
routerId: "",
|
|
11839
|
+
correlationId,
|
|
11840
|
+
routerConfig: fleetUnavailable("routerConfig"),
|
|
11841
|
+
sshClient: fleetUnavailable("sshClient"),
|
|
11842
|
+
ftpClient: fleetUnavailable("ftpClient"),
|
|
11843
|
+
sftpClient: fleetUnavailable("sftpClient"),
|
|
11844
|
+
identity,
|
|
11845
|
+
routerRegistry: registry,
|
|
11846
|
+
connectionPool: pool,
|
|
11847
|
+
circuitBreakers,
|
|
11848
|
+
appConfig: config
|
|
11849
|
+
};
|
|
11850
|
+
const fleetResult = await tool.handler(args, fleetContext);
|
|
11851
|
+
recordToolCall(tool.name, "success");
|
|
11852
|
+
return formatToolResult(fleetResult);
|
|
11853
|
+
}
|
|
11854
|
+
const routerId2 = resolveRouterId(args.routerId, registry, config);
|
|
11855
|
+
args.routerId = routerId2;
|
|
11856
|
+
checkAuthz(identity, tool.name, routerId2);
|
|
11857
|
+
const routerConfig = registry.getRouter(routerId2);
|
|
11858
|
+
assertMaintenanceWindow(tool.annotations.destructiveHint, routerConfig, routerId2);
|
|
11859
|
+
if (tool.annotations.destructiveHint && config.confirmationSecret) {
|
|
11860
|
+
await checkConfirmation(tool.name, routerId2, args, identity, config.confirmationSecret);
|
|
11861
|
+
}
|
|
11862
|
+
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
11863
|
+
if (shouldAudit) {
|
|
11864
|
+
auditLog({
|
|
11865
|
+
type: "audit",
|
|
11866
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11867
|
+
correlationId,
|
|
11868
|
+
identityId: identity.id,
|
|
11869
|
+
role: identity.role,
|
|
11870
|
+
tool: tool.name,
|
|
11871
|
+
routerId: routerId2,
|
|
11872
|
+
phase: "attempt",
|
|
11873
|
+
params: args
|
|
11874
|
+
}, config.auditLogPath);
|
|
11875
|
+
}
|
|
11876
|
+
const toolContext = buildRouterToolContext({
|
|
11877
|
+
routerConfig,
|
|
11878
|
+
correlationId,
|
|
11879
|
+
identity,
|
|
11880
|
+
pool,
|
|
11881
|
+
config,
|
|
11882
|
+
registry
|
|
11883
|
+
});
|
|
11884
|
+
const cb = getOrCreateBreaker(circuitBreakers, routerId2, config);
|
|
11885
|
+
toolContext.circuitBreaker = cb;
|
|
11886
|
+
const { confirmationToken: _ct, ...handlerArgs } = args;
|
|
11887
|
+
const snapshotIds = [];
|
|
11888
|
+
if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
|
|
11889
|
+
for (const path of tool.snapshotPaths) {
|
|
11890
|
+
try {
|
|
11891
|
+
const meta = await takeSnapshot(toolContext.routerClient, routerId2, path, config.snapshotDir);
|
|
11892
|
+
snapshotIds.push(meta.id);
|
|
11893
|
+
log47.debug({ snapshotId: meta.id, path }, "Snapshot taken");
|
|
11894
|
+
} catch (err) {
|
|
11895
|
+
log47.warn({ err, path, routerId: routerId2 }, "Snapshot failed \u2014 proceeding without snapshot");
|
|
11896
|
+
}
|
|
11897
|
+
}
|
|
11898
|
+
}
|
|
11899
|
+
if (!tool.annotations.readOnlyHint && config.journalPath) {
|
|
11900
|
+
journalId = recordAttempt({
|
|
11901
|
+
journalPath: config.journalPath,
|
|
11902
|
+
identityId: identity.id,
|
|
11903
|
+
role: identity.role,
|
|
11904
|
+
tool: tool.name,
|
|
11905
|
+
routerId: routerId2,
|
|
11906
|
+
params: handlerArgs,
|
|
11907
|
+
snapshotIds
|
|
11908
|
+
});
|
|
11909
|
+
}
|
|
11910
|
+
const runHandler = () => tool.handler(handlerArgs, toolContext);
|
|
11911
|
+
const shouldRetry = tool.annotations.readOnlyHint && tool.retryable !== false;
|
|
11912
|
+
const executeHandler = () => cb.execute(shouldRetry ? () => withRetry(runHandler, config.retry) : runHandler);
|
|
11913
|
+
const result = await executeHandler();
|
|
11914
|
+
if (journalId) {
|
|
11915
|
+
recordOutcome({
|
|
11916
|
+
journalPath: config.journalPath,
|
|
11917
|
+
journalId,
|
|
11918
|
+
phase: "success",
|
|
11919
|
+
durationMs: Date.now() - startMs
|
|
11920
|
+
});
|
|
11921
|
+
}
|
|
11922
|
+
if (shouldAudit) {
|
|
11923
|
+
auditLog({
|
|
11924
|
+
type: "audit",
|
|
11925
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11926
|
+
correlationId,
|
|
11927
|
+
identityId: identity.id,
|
|
11928
|
+
role: identity.role,
|
|
11929
|
+
tool: tool.name,
|
|
11930
|
+
routerId: routerId2,
|
|
11931
|
+
phase: "success",
|
|
11932
|
+
params: args,
|
|
11933
|
+
durationMs: Date.now() - startMs
|
|
11934
|
+
}, config.auditLogPath);
|
|
11935
|
+
}
|
|
11936
|
+
log47.info({ tool: tool.name, routerId: routerId2, correlationId }, "Tool executed successfully");
|
|
11937
|
+
recordToolCall(tool.name, "success");
|
|
11938
|
+
return formatToolResult(result);
|
|
11939
|
+
} catch (err) {
|
|
11940
|
+
const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
|
|
11941
|
+
if (!tool.annotations.readOnlyHint && AMBIGUOUS_WRITE_CATEGORIES.has(error.category) && !error.recoverability.suggestedAction.startsWith(VERIFY_STATE_PREFIX)) {
|
|
11942
|
+
error.recoverability.suggestedAction = `${VERIFY_STATE_PREFIX}${error.recoverability.suggestedAction}`;
|
|
11943
|
+
}
|
|
11944
|
+
if (error.category === "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */) {
|
|
11945
|
+
const failedRouterId = args.routerId;
|
|
11946
|
+
if (failedRouterId) {
|
|
11947
|
+
pool.removeClient(failedRouterId);
|
|
11948
|
+
log47.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
|
|
11949
|
+
}
|
|
11950
|
+
}
|
|
11951
|
+
if (journalId) {
|
|
11952
|
+
recordOutcome({
|
|
11953
|
+
journalPath: config.journalPath,
|
|
11954
|
+
journalId,
|
|
11955
|
+
phase: "failure",
|
|
11956
|
+
outcome: error.code,
|
|
11957
|
+
durationMs: Date.now() - startMs
|
|
11958
|
+
});
|
|
11959
|
+
}
|
|
11960
|
+
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
11961
|
+
if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
|
|
11962
|
+
const routerId2 = args.routerId ?? "unknown";
|
|
11963
|
+
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
11964
|
+
auditLog({
|
|
11965
|
+
type: "audit",
|
|
11966
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11967
|
+
correlationId,
|
|
11968
|
+
identityId: identity.id,
|
|
11969
|
+
role: identity.role,
|
|
11970
|
+
tool: tool.name,
|
|
11971
|
+
routerId: routerId2,
|
|
11972
|
+
phase: "failure",
|
|
11973
|
+
params: args,
|
|
11974
|
+
outcome: error.code,
|
|
11975
|
+
durationMs: Date.now() - startMs
|
|
11976
|
+
}, config.auditLogPath);
|
|
11977
|
+
}
|
|
11978
|
+
log47.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
|
|
11979
|
+
recordToolCall(tool.name, "error");
|
|
11980
|
+
return formatError(error);
|
|
11981
|
+
}
|
|
11982
|
+
});
|
|
11823
11983
|
}
|
|
11824
11984
|
|
|
11825
|
-
// src/
|
|
11826
|
-
|
|
11827
|
-
|
|
11828
|
-
|
|
11985
|
+
// src/middleware/fleet-confirmation.ts
|
|
11986
|
+
import { createHmac as createHmac2, createHash as createHash4, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
11987
|
+
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
11988
|
+
var usedTokens2 = /* @__PURE__ */ new Map();
|
|
11989
|
+
function fingerprint(args) {
|
|
11990
|
+
const sortedRouters = [...args.routerIds].sort();
|
|
11991
|
+
const payload = JSON.stringify({
|
|
11992
|
+
toolName: args.toolName,
|
|
11993
|
+
routerIds: sortedRouters,
|
|
11994
|
+
params: args.params,
|
|
11995
|
+
identityId: args.identityId
|
|
11996
|
+
});
|
|
11997
|
+
return createHash4("sha256").update(payload).digest("hex");
|
|
11829
11998
|
}
|
|
11830
|
-
function
|
|
11831
|
-
const
|
|
11832
|
-
|
|
11999
|
+
function sign2(fp, expiresAtMs, secret) {
|
|
12000
|
+
const mac = createHmac2("sha256", secret).update(`${fp}|${expiresAtMs}`).digest("hex");
|
|
12001
|
+
return `${expiresAtMs}.${mac}`;
|
|
11833
12002
|
}
|
|
11834
|
-
function
|
|
11835
|
-
const
|
|
11836
|
-
|
|
11837
|
-
|
|
11838
|
-
|
|
11839
|
-
for (const [k, count] of toolCalls) {
|
|
11840
|
-
const [tool, status] = k.split("\0");
|
|
11841
|
-
lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
|
|
11842
|
-
}
|
|
11843
|
-
return lines.join("\n") + "\n";
|
|
12003
|
+
function tokensEqual2(a, b) {
|
|
12004
|
+
const bufA = Buffer.from(a, "utf-8");
|
|
12005
|
+
const bufB = Buffer.from(b, "utf-8");
|
|
12006
|
+
if (bufA.length !== bufB.length) return false;
|
|
12007
|
+
return timingSafeEqual2(bufA, bufB);
|
|
11844
12008
|
}
|
|
11845
|
-
|
|
11846
|
-
|
|
11847
|
-
|
|
11848
|
-
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
|
|
11849
|
-
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
11850
|
-
"ROUTER_ERROR" /* ROUTER_ERROR */,
|
|
11851
|
-
"ROUTER_BUSY" /* ROUTER_BUSY */
|
|
11852
|
-
]);
|
|
11853
|
-
function isTransientFailure(error) {
|
|
11854
|
-
if (!(error instanceof MikroMCPError)) {
|
|
11855
|
-
return false;
|
|
12009
|
+
function sweepUsed2(now) {
|
|
12010
|
+
for (const [token, expiresAtMs] of usedTokens2) {
|
|
12011
|
+
if (expiresAtMs <= now) usedTokens2.delete(token);
|
|
11856
12012
|
}
|
|
11857
|
-
return TRANSIENT_CATEGORIES.has(error.category);
|
|
11858
12013
|
}
|
|
11859
|
-
|
|
11860
|
-
|
|
11861
|
-
|
|
11862
|
-
|
|
11863
|
-
|
|
11864
|
-
|
|
11865
|
-
|
|
11866
|
-
|
|
11867
|
-
|
|
11868
|
-
|
|
11869
|
-
options;
|
|
11870
|
-
currentState = "closed";
|
|
11871
|
-
consecutiveFailures = 0;
|
|
11872
|
-
lastFailureTime = 0;
|
|
11873
|
-
probeInFlight = false;
|
|
11874
|
-
/** Effective state, accounting for an elapsed cooldown. Pure — does not mutate. */
|
|
11875
|
-
get state() {
|
|
11876
|
-
if (this.currentState === "open" && this.cooldownElapsed()) {
|
|
11877
|
-
return "half-open";
|
|
11878
|
-
}
|
|
11879
|
-
return this.currentState;
|
|
11880
|
-
}
|
|
11881
|
-
cooldownElapsed() {
|
|
11882
|
-
return Date.now() - this.lastFailureTime >= this.options.cooldownMs;
|
|
11883
|
-
}
|
|
11884
|
-
/** Apply the time-based open→half-open transition. Call before acting on state. */
|
|
11885
|
-
transitionIfCooldownElapsed() {
|
|
11886
|
-
if (this.currentState === "open" && this.cooldownElapsed()) {
|
|
11887
|
-
this.currentState = "half-open";
|
|
11888
|
-
}
|
|
11889
|
-
}
|
|
11890
|
-
/**
|
|
11891
|
-
* Execute `fn` through the circuit breaker.
|
|
11892
|
-
*
|
|
11893
|
-
* - **closed**: execute normally; track consecutive failures.
|
|
11894
|
-
* - **open**: reject immediately with `ROUTER_BUSY`.
|
|
11895
|
-
* - **half-open**: execute as a probe; success closes, failure reopens.
|
|
11896
|
-
*/
|
|
11897
|
-
async execute(fn) {
|
|
11898
|
-
this.transitionIfCooldownElapsed();
|
|
11899
|
-
const currentState = this.currentState;
|
|
11900
|
-
if (currentState === "open") {
|
|
12014
|
+
function checkFleetConfirmation(args, secret) {
|
|
12015
|
+
const now = Date.now();
|
|
12016
|
+
sweepUsed2(now);
|
|
12017
|
+
const fp = fingerprint(args);
|
|
12018
|
+
if (args.submittedToken) {
|
|
12019
|
+
const token2 = args.submittedToken;
|
|
12020
|
+
const dot = token2.indexOf(".");
|
|
12021
|
+
const expiresAtMs2 = dot > 0 ? Number(token2.slice(0, dot)) : NaN;
|
|
12022
|
+
const expected = Number.isFinite(expiresAtMs2) ? sign2(fp, expiresAtMs2, secret) : "";
|
|
12023
|
+
if (!Number.isFinite(expiresAtMs2) || expiresAtMs2 <= now || usedTokens2.has(token2) || !tokensEqual2(token2, expected)) {
|
|
11901
12024
|
throw new MikroMCPError({
|
|
11902
|
-
category: "
|
|
11903
|
-
code: "
|
|
11904
|
-
message:
|
|
11905
|
-
details: {
|
|
11906
|
-
routerId: this.routerId,
|
|
11907
|
-
consecutiveFailures: this.consecutiveFailures,
|
|
11908
|
-
cooldownMs: this.options.cooldownMs
|
|
11909
|
-
},
|
|
12025
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
12026
|
+
code: "FLEET_CONFIRMATION_MISMATCH",
|
|
12027
|
+
message: "Fleet confirmation token is expired, already used, or does not match the current toolName/routerIds/params.",
|
|
11910
12028
|
recoverability: {
|
|
11911
12029
|
retryable: true,
|
|
11912
|
-
|
|
11913
|
-
suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
|
|
12030
|
+
suggestedAction: "Re-submit bulk_execute without confirmationToken to get a fresh token."
|
|
11914
12031
|
}
|
|
11915
12032
|
});
|
|
11916
12033
|
}
|
|
11917
|
-
|
|
11918
|
-
|
|
12034
|
+
usedTokens2.set(token2, expiresAtMs2);
|
|
12035
|
+
return;
|
|
12036
|
+
}
|
|
12037
|
+
const expiresAtMs = now + CONFIRMATION_TTL_MS2;
|
|
12038
|
+
const token = sign2(fp, expiresAtMs, secret);
|
|
12039
|
+
throw new MikroMCPError({
|
|
12040
|
+
category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
|
|
12041
|
+
code: "FLEET_CONFIRMATION_REQUIRED",
|
|
12042
|
+
message: `This will run destructive tool "${args.toolName}" across ${args.routerIds.length} router(s). Re-submit with confirmationToken to proceed.`,
|
|
12043
|
+
details: {
|
|
12044
|
+
confirmationToken: token,
|
|
12045
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
12046
|
+
routerCount: args.routerIds.length
|
|
12047
|
+
},
|
|
12048
|
+
recoverability: {
|
|
12049
|
+
retryable: true,
|
|
12050
|
+
suggestedAction: "Re-submit the identical bulk_execute call with details.confirmationToken."
|
|
12051
|
+
}
|
|
12052
|
+
});
|
|
12053
|
+
}
|
|
12054
|
+
|
|
12055
|
+
// src/domain/tools/fleet-tools.ts
|
|
12056
|
+
var log48 = createLogger("fleet-tools");
|
|
12057
|
+
var checkHealthInputSchema = z46.object({
|
|
12058
|
+
routerId
|
|
12059
|
+
}).strict();
|
|
12060
|
+
var bulkExecuteInputSchema = z46.object({
|
|
12061
|
+
toolName: z46.string().describe("Name of the tool to fan out (must be a single-router tool)"),
|
|
12062
|
+
routerIds: z46.array(z46.string()).optional().describe("Explicit list of router IDs to target"),
|
|
12063
|
+
tags: z46.array(z46.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
|
|
12064
|
+
params: z46.record(z46.string(), z46.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
|
|
12065
|
+
concurrency: z46.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
|
|
12066
|
+
confirmationToken: z46.string().optional().describe(
|
|
12067
|
+
"Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
|
|
12068
|
+
)
|
|
12069
|
+
}).strict();
|
|
12070
|
+
var listRoutersInputSchema = z46.object({
|
|
12071
|
+
tags: z46.array(z46.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
|
|
12072
|
+
}).strict();
|
|
12073
|
+
var listRoutersTool = {
|
|
12074
|
+
name: "list_routers",
|
|
12075
|
+
title: "List Routers",
|
|
12076
|
+
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_execute).",
|
|
12077
|
+
inputSchema: listRoutersInputSchema,
|
|
12078
|
+
annotations: {
|
|
12079
|
+
readOnlyHint: true,
|
|
12080
|
+
destructiveHint: false,
|
|
12081
|
+
idempotentHint: true,
|
|
12082
|
+
openWorldHint: false
|
|
12083
|
+
},
|
|
12084
|
+
skipRouterContext: true,
|
|
12085
|
+
async handler(params, context) {
|
|
12086
|
+
const parsed = listRoutersInputSchema.parse(params);
|
|
12087
|
+
const { identity, appConfig } = context;
|
|
12088
|
+
const defaultRouter = appConfig.defaultRouter;
|
|
12089
|
+
log48.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
|
|
12090
|
+
let routers = context.routerRegistry.listRouters(parsed.tags);
|
|
12091
|
+
if (identity.allowedRouters.length > 0) {
|
|
12092
|
+
routers = routers.filter((r) => identity.allowedRouters.includes(r.id));
|
|
12093
|
+
}
|
|
12094
|
+
const soleDefaultId = defaultRouter === void 0 && routers.length === 1 ? routers[0].id : void 0;
|
|
12095
|
+
const rows = routers.map((r) => ({
|
|
12096
|
+
id: r.id,
|
|
12097
|
+
host: r.host,
|
|
12098
|
+
port: r.port,
|
|
12099
|
+
tlsEnabled: r.tls.enabled,
|
|
12100
|
+
tags: r.tags,
|
|
12101
|
+
rosVersion: r.rosVersion,
|
|
12102
|
+
isDefault: defaultRouter !== void 0 ? r.id === defaultRouter : r.id === soleDefaultId
|
|
12103
|
+
}));
|
|
12104
|
+
const total = rows.length;
|
|
12105
|
+
const header = `Routers: ${total === 0 ? "none" : `1-${total} of ${total}`}.`;
|
|
12106
|
+
const lines = rows.map(
|
|
12107
|
+
(r) => ` ${compactFields(
|
|
12108
|
+
{ ...r, tags: r.tags.join(",") },
|
|
12109
|
+
["id", "host", "port", "tlsEnabled", "tags", "rosVersion", "isDefault"]
|
|
12110
|
+
)}`
|
|
12111
|
+
);
|
|
12112
|
+
return {
|
|
12113
|
+
content: total === 0 ? header : [header, ...lines].join("\n"),
|
|
12114
|
+
structuredContent: { routers: rows, total, returned: total }
|
|
12115
|
+
};
|
|
12116
|
+
}
|
|
12117
|
+
};
|
|
12118
|
+
function createFleetTools(baseTools2) {
|
|
12119
|
+
const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
|
|
12120
|
+
const checkRouterHealthTool = {
|
|
12121
|
+
name: "check_router_health",
|
|
12122
|
+
title: "Check Router Health",
|
|
12123
|
+
description: "Probe a router by fetching system/resource. Returns health status, ROS version, uptime, CPU load, and memory info. Unlike other tools, this never throws \u2014 unreachable routers are reported as healthy=false.",
|
|
12124
|
+
inputSchema: checkHealthInputSchema,
|
|
12125
|
+
annotations: {
|
|
12126
|
+
readOnlyHint: true,
|
|
12127
|
+
destructiveHint: false,
|
|
12128
|
+
idempotentHint: true,
|
|
12129
|
+
openWorldHint: false
|
|
12130
|
+
},
|
|
12131
|
+
async handler(params, context) {
|
|
12132
|
+
checkHealthInputSchema.parse(params);
|
|
12133
|
+
log48.info({ routerId: context.routerId }, "Checking router health");
|
|
12134
|
+
const startMs = Date.now();
|
|
12135
|
+
try {
|
|
12136
|
+
const resources = await context.routerClient.get("system/resource");
|
|
12137
|
+
const resource = resources[0];
|
|
12138
|
+
const latencyMs = Date.now() - startMs;
|
|
12139
|
+
const result = {
|
|
12140
|
+
routerId: context.routerId,
|
|
12141
|
+
healthy: true,
|
|
12142
|
+
rosVersion: resource?.["version"],
|
|
12143
|
+
uptime: resource?.["uptime"],
|
|
12144
|
+
cpuLoad: resource?.["cpu-load"],
|
|
12145
|
+
freeMemory: resource?.["free-memory"],
|
|
12146
|
+
totalMemory: resource?.["total-memory"],
|
|
12147
|
+
latencyMs
|
|
12148
|
+
};
|
|
12149
|
+
return {
|
|
12150
|
+
content: `Router ${context.routerId} is healthy (${latencyMs}ms)`,
|
|
12151
|
+
structuredContent: result
|
|
12152
|
+
};
|
|
12153
|
+
} catch (err) {
|
|
12154
|
+
const latencyMs = Date.now() - startMs;
|
|
12155
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12156
|
+
return {
|
|
12157
|
+
content: `Router ${context.routerId} is unhealthy: ${message}`,
|
|
12158
|
+
structuredContent: {
|
|
12159
|
+
routerId: context.routerId,
|
|
12160
|
+
healthy: false,
|
|
12161
|
+
latencyMs,
|
|
12162
|
+
error: message
|
|
12163
|
+
}
|
|
12164
|
+
};
|
|
12165
|
+
}
|
|
12166
|
+
}
|
|
12167
|
+
};
|
|
12168
|
+
const bulkExecuteTool = {
|
|
12169
|
+
name: "bulk_execute",
|
|
12170
|
+
title: "Bulk Execute",
|
|
12171
|
+
description: "Fan out a single-router tool to many routers in parallel (up to `concurrency`), targeted by routerIds or tag. Destructive tools need two-step confirmation: call without `confirmationToken` to get a fleet token (needs MIKROMCP_CONFIRMATION_SECRET), then re-call with it. Writes snapshot+journal each router for rollback. Returns per-router results with succeeded/failed counts.",
|
|
12172
|
+
inputSchema: bulkExecuteInputSchema,
|
|
12173
|
+
annotations: {
|
|
12174
|
+
readOnlyHint: false,
|
|
12175
|
+
destructiveHint: false,
|
|
12176
|
+
idempotentHint: false,
|
|
12177
|
+
openWorldHint: false
|
|
12178
|
+
},
|
|
12179
|
+
skipRouterContext: true,
|
|
12180
|
+
async handler(params, context) {
|
|
12181
|
+
const parsed = bulkExecuteInputSchema.parse(params);
|
|
12182
|
+
const hasRouterIds = Array.isArray(parsed.routerIds) && parsed.routerIds.length > 0;
|
|
12183
|
+
const hasTags = Array.isArray(parsed.tags) && parsed.tags.length > 0;
|
|
12184
|
+
if (hasRouterIds === hasTags) {
|
|
12185
|
+
throw new MikroMCPError({
|
|
12186
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
12187
|
+
code: "BULK_TARGET_REQUIRED",
|
|
12188
|
+
message: "Provide exactly one of routerIds or tags, not both and not neither.",
|
|
12189
|
+
recoverability: {
|
|
12190
|
+
retryable: false,
|
|
12191
|
+
suggestedAction: "Supply either routerIds (array of IDs) or tags (array of tag strings)."
|
|
12192
|
+
}
|
|
12193
|
+
});
|
|
12194
|
+
}
|
|
12195
|
+
log48.info(
|
|
12196
|
+
{ toolName: parsed.toolName, concurrency: parsed.concurrency },
|
|
12197
|
+
"bulk_execute invoked"
|
|
12198
|
+
);
|
|
12199
|
+
auditLog({
|
|
12200
|
+
type: "audit",
|
|
12201
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12202
|
+
correlationId: context.correlationId,
|
|
12203
|
+
identityId: context.identity.id,
|
|
12204
|
+
role: context.identity.role,
|
|
12205
|
+
tool: "bulk_execute",
|
|
12206
|
+
routerId: "(fleet)",
|
|
12207
|
+
phase: "attempt",
|
|
12208
|
+
params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
|
|
12209
|
+
}, context.appConfig.auditLogPath);
|
|
12210
|
+
if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
|
|
11919
12211
|
throw new MikroMCPError({
|
|
11920
|
-
category: "
|
|
11921
|
-
code: "
|
|
11922
|
-
message: `
|
|
11923
|
-
details: { routerId: this.routerId },
|
|
12212
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
12213
|
+
code: "BULK_SELF_REFERENCE",
|
|
12214
|
+
message: `Cannot use bulk_execute to fan out fleet tools ("${parsed.toolName}").`,
|
|
11924
12215
|
recoverability: {
|
|
11925
|
-
retryable:
|
|
11926
|
-
|
|
11927
|
-
suggestedAction: "Wait for the in-flight probe to complete, then retry."
|
|
12216
|
+
retryable: false,
|
|
12217
|
+
suggestedAction: "Choose a single-router tool as the toolName."
|
|
11928
12218
|
}
|
|
11929
12219
|
});
|
|
11930
12220
|
}
|
|
11931
|
-
|
|
11932
|
-
|
|
11933
|
-
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
12221
|
+
const foundTool = toolMap.get(parsed.toolName);
|
|
12222
|
+
if (!foundTool) {
|
|
12223
|
+
throw new MikroMCPError({
|
|
12224
|
+
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
12225
|
+
code: "TOOL_NOT_FOUND",
|
|
12226
|
+
message: `Tool "${parsed.toolName}" not found. Available tools: ${[...toolMap.keys()].join(", ")}`,
|
|
12227
|
+
recoverability: {
|
|
12228
|
+
retryable: false,
|
|
12229
|
+
suggestedAction: "Check the tool name and try again."
|
|
12230
|
+
}
|
|
12231
|
+
});
|
|
11940
12232
|
}
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11944
|
-
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
|
|
11952
|
-
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
11960
|
-
|
|
11961
|
-
|
|
11962
|
-
|
|
11963
|
-
} else if (this.consecutiveFailures >= this.options.failureThreshold) {
|
|
11964
|
-
this.currentState = "open";
|
|
11965
|
-
}
|
|
11966
|
-
}
|
|
11967
|
-
};
|
|
11968
|
-
|
|
11969
|
-
// src/mcp/tool-executor.ts
|
|
11970
|
-
var log48 = createLogger("tool-executor");
|
|
11971
|
-
function resolveRouterId(explicit, registry, config) {
|
|
11972
|
-
if (explicit) {
|
|
11973
|
-
return explicit;
|
|
11974
|
-
}
|
|
11975
|
-
const fallback = config.defaultRouter ?? registry.soleRouterId();
|
|
11976
|
-
if (fallback) {
|
|
11977
|
-
return fallback;
|
|
11978
|
-
}
|
|
11979
|
-
const available = registry.routerIds();
|
|
11980
|
-
throw new MikroMCPError({
|
|
11981
|
-
category: "VALIDATION" /* VALIDATION */,
|
|
11982
|
-
code: "MISSING_ROUTER_ID",
|
|
11983
|
-
message: available.length > 0 ? `routerId is required: more than one router is configured. Available: ${available.join(", ")}.` : "routerId is required, but no routers are configured.",
|
|
11984
|
-
details: { availableRouters: available },
|
|
11985
|
-
recoverability: {
|
|
11986
|
-
retryable: false,
|
|
11987
|
-
suggestedAction: available.length > 0 ? "Provide a routerId, or set MIKROMCP_DEFAULT_ROUTER to choose a default." : "Configure at least one router in routers.yaml."
|
|
11988
|
-
}
|
|
11989
|
-
});
|
|
11990
|
-
}
|
|
11991
|
-
async function executeToolCall(tool, args, deps) {
|
|
11992
|
-
const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
|
|
11993
|
-
const correlationId = nanoid4();
|
|
11994
|
-
return withContext({ correlationId, tool: tool.name }, async () => {
|
|
11995
|
-
const startMs = Date.now();
|
|
11996
|
-
let journalId;
|
|
11997
|
-
try {
|
|
11998
|
-
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
11999
|
-
if (tool.skipRouterContext) {
|
|
12000
|
-
const fleetContext = {
|
|
12001
|
-
routerClient: null,
|
|
12002
|
-
routerId: "",
|
|
12003
|
-
correlationId,
|
|
12004
|
-
routerConfig: null,
|
|
12005
|
-
sshClient: null,
|
|
12006
|
-
ftpClient: null,
|
|
12007
|
-
identity,
|
|
12008
|
-
routerRegistry: registry,
|
|
12009
|
-
connectionPool: pool,
|
|
12010
|
-
appConfig: config
|
|
12011
|
-
};
|
|
12012
|
-
const fleetResult = await tool.handler(args, fleetContext);
|
|
12013
|
-
recordToolCall(tool.name, "success");
|
|
12014
|
-
return formatToolResult(fleetResult);
|
|
12233
|
+
const targetTool = foundTool;
|
|
12234
|
+
const preErrors = [];
|
|
12235
|
+
let routers;
|
|
12236
|
+
if (hasRouterIds) {
|
|
12237
|
+
const resolved = [];
|
|
12238
|
+
for (const id of parsed.routerIds) {
|
|
12239
|
+
try {
|
|
12240
|
+
resolved.push(context.routerRegistry.getRouter(id));
|
|
12241
|
+
} catch {
|
|
12242
|
+
log48.warn({ routerId: id }, "bulk_execute: router not found");
|
|
12243
|
+
preErrors.push({
|
|
12244
|
+
routerId: id,
|
|
12245
|
+
status: "error",
|
|
12246
|
+
error: `Router "${id}" not found in registry`,
|
|
12247
|
+
durationMs: 0
|
|
12248
|
+
});
|
|
12249
|
+
}
|
|
12250
|
+
}
|
|
12251
|
+
routers = resolved;
|
|
12252
|
+
} else {
|
|
12253
|
+
const tags = parsed.tags;
|
|
12254
|
+
routers = context.routerRegistry.listRouters().filter((r) => tags.every((t) => r.tags.includes(t)));
|
|
12015
12255
|
}
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
|
|
12019
|
-
const routerConfig = registry.getRouter(routerId2);
|
|
12020
|
-
if (tool.annotations.destructiveHint) {
|
|
12021
|
-
const windows = routerConfig.maintenanceWindows;
|
|
12022
|
-
if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
|
|
12256
|
+
if (targetTool.annotations.destructiveHint) {
|
|
12257
|
+
const secret = context.appConfig.confirmationSecret;
|
|
12258
|
+
if (!secret) {
|
|
12023
12259
|
throw new MikroMCPError({
|
|
12024
|
-
category: "
|
|
12025
|
-
code: "
|
|
12026
|
-
message:
|
|
12027
|
-
details: { maintenanceWindows: windows },
|
|
12260
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
12261
|
+
code: "FLEET_CONFIRMATION_UNAVAILABLE",
|
|
12262
|
+
message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
|
|
12028
12263
|
recoverability: {
|
|
12029
|
-
retryable:
|
|
12030
|
-
suggestedAction: "
|
|
12264
|
+
retryable: false,
|
|
12265
|
+
suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
|
|
12031
12266
|
}
|
|
12032
12267
|
});
|
|
12033
12268
|
}
|
|
12269
|
+
const routerIdList = routers.map((r) => r.id);
|
|
12270
|
+
checkFleetConfirmation(
|
|
12271
|
+
{
|
|
12272
|
+
toolName: parsed.toolName,
|
|
12273
|
+
routerIds: routerIdList,
|
|
12274
|
+
params: parsed.params,
|
|
12275
|
+
identityId: context.identity.id,
|
|
12276
|
+
submittedToken: parsed.confirmationToken
|
|
12277
|
+
},
|
|
12278
|
+
secret
|
|
12279
|
+
);
|
|
12034
12280
|
}
|
|
12035
|
-
if (
|
|
12036
|
-
await checkConfirmation(tool.name, routerId2, args, identity, config.confirmationSecret);
|
|
12037
|
-
}
|
|
12038
|
-
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
12039
|
-
if (shouldAudit) {
|
|
12281
|
+
if (routers.length === 0 && preErrors.length === 0) {
|
|
12040
12282
|
auditLog({
|
|
12041
12283
|
type: "audit",
|
|
12042
12284
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12043
|
-
correlationId,
|
|
12044
|
-
identityId: identity.id,
|
|
12045
|
-
role: identity.role,
|
|
12046
|
-
tool:
|
|
12047
|
-
routerId:
|
|
12048
|
-
phase: "
|
|
12049
|
-
params:
|
|
12050
|
-
},
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
12057
|
-
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
if (!cb) {
|
|
12062
|
-
cb = new CircuitBreaker(routerId2, {
|
|
12063
|
-
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
12064
|
-
cooldownMs: config.circuitBreaker.cooldownMs
|
|
12065
|
-
});
|
|
12066
|
-
circuitBreakers.set(routerId2, cb);
|
|
12285
|
+
correlationId: context.correlationId,
|
|
12286
|
+
identityId: context.identity.id,
|
|
12287
|
+
role: context.identity.role,
|
|
12288
|
+
tool: "bulk_execute",
|
|
12289
|
+
routerId: "(fleet)",
|
|
12290
|
+
phase: "success",
|
|
12291
|
+
params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
|
|
12292
|
+
}, context.appConfig.auditLogPath);
|
|
12293
|
+
return {
|
|
12294
|
+
content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
|
|
12295
|
+
structuredContent: {
|
|
12296
|
+
toolName: parsed.toolName,
|
|
12297
|
+
totalRouters: 0,
|
|
12298
|
+
succeeded: 0,
|
|
12299
|
+
failed: 0,
|
|
12300
|
+
results: []
|
|
12301
|
+
}
|
|
12302
|
+
};
|
|
12067
12303
|
}
|
|
12068
|
-
|
|
12069
|
-
const
|
|
12070
|
-
const
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
12076
|
-
|
|
12077
|
-
|
|
12078
|
-
|
|
12304
|
+
const isWrite = !targetTool.annotations.readOnlyHint;
|
|
12305
|
+
const snapshotDir = context.appConfig.snapshotDir;
|
|
12306
|
+
const journalPath = context.appConfig.journalPath;
|
|
12307
|
+
async function runForRouter(router) {
|
|
12308
|
+
const start = Date.now();
|
|
12309
|
+
let journalId;
|
|
12310
|
+
try {
|
|
12311
|
+
checkAuthz(context.identity, parsed.toolName, router.id);
|
|
12312
|
+
assertMaintenanceWindow(targetTool.annotations.destructiveHint, router, router.id);
|
|
12313
|
+
const routerContext = buildRouterToolContext({
|
|
12314
|
+
routerConfig: router,
|
|
12315
|
+
correlationId: context.correlationId,
|
|
12316
|
+
identity: context.identity,
|
|
12317
|
+
pool: context.connectionPool,
|
|
12318
|
+
config: context.appConfig,
|
|
12319
|
+
registry: context.routerRegistry
|
|
12320
|
+
});
|
|
12321
|
+
const toolParams = { ...parsed.params, routerId: router.id };
|
|
12322
|
+
const snapshotIds = [];
|
|
12323
|
+
if (isWrite && targetTool.snapshotPaths && targetTool.snapshotPaths.length > 0) {
|
|
12324
|
+
for (const path of targetTool.snapshotPaths) {
|
|
12325
|
+
try {
|
|
12326
|
+
const meta = await takeSnapshot(
|
|
12327
|
+
routerContext.routerClient,
|
|
12328
|
+
router.id,
|
|
12329
|
+
path,
|
|
12330
|
+
snapshotDir
|
|
12331
|
+
);
|
|
12332
|
+
snapshotIds.push(meta.id);
|
|
12333
|
+
} catch (err) {
|
|
12334
|
+
log48.warn(
|
|
12335
|
+
{ err, path, routerId: router.id },
|
|
12336
|
+
"bulk_execute snapshot failed \u2014 proceeding without snapshot"
|
|
12337
|
+
);
|
|
12338
|
+
}
|
|
12339
|
+
}
|
|
12340
|
+
}
|
|
12341
|
+
if (isWrite && journalPath) {
|
|
12342
|
+
journalId = recordAttempt({
|
|
12343
|
+
journalPath,
|
|
12344
|
+
identityId: context.identity.id,
|
|
12345
|
+
role: context.identity.role,
|
|
12346
|
+
tool: parsed.toolName,
|
|
12347
|
+
routerId: router.id,
|
|
12348
|
+
params: toolParams,
|
|
12349
|
+
snapshotIds
|
|
12350
|
+
});
|
|
12351
|
+
}
|
|
12352
|
+
const cb = getOrCreateBreaker(context.circuitBreakers, router.id, context.appConfig);
|
|
12353
|
+
routerContext.circuitBreaker = cb;
|
|
12354
|
+
const runOnce = () => targetTool.handler(toolParams, routerContext);
|
|
12355
|
+
const shouldRetry = targetTool.annotations.readOnlyHint && targetTool.retryable !== false;
|
|
12356
|
+
const result = await cb.execute(
|
|
12357
|
+
shouldRetry ? () => withRetry(runOnce, context.appConfig.retry) : runOnce
|
|
12358
|
+
);
|
|
12359
|
+
const elapsed = Date.now() - start;
|
|
12360
|
+
if (journalId) {
|
|
12361
|
+
recordOutcome({ journalPath, journalId, phase: "success", durationMs: elapsed });
|
|
12362
|
+
}
|
|
12363
|
+
auditLog({
|
|
12364
|
+
type: "audit",
|
|
12365
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12366
|
+
correlationId: context.correlationId,
|
|
12367
|
+
identityId: context.identity.id,
|
|
12368
|
+
role: context.identity.role,
|
|
12369
|
+
tool: parsed.toolName,
|
|
12370
|
+
routerId: router.id,
|
|
12371
|
+
phase: "success",
|
|
12372
|
+
params: parsed.params,
|
|
12373
|
+
durationMs: elapsed
|
|
12374
|
+
}, context.appConfig.auditLogPath);
|
|
12375
|
+
return { routerId: router.id, status: "ok", result, durationMs: elapsed };
|
|
12376
|
+
} catch (err) {
|
|
12377
|
+
const elapsed = Date.now() - start;
|
|
12378
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12379
|
+
if (journalId) {
|
|
12380
|
+
recordOutcome({ journalPath, journalId, phase: "failure", outcome: message, durationMs: elapsed });
|
|
12079
12381
|
}
|
|
12382
|
+
auditLog({
|
|
12383
|
+
type: "audit",
|
|
12384
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12385
|
+
correlationId: context.correlationId,
|
|
12386
|
+
identityId: context.identity.id,
|
|
12387
|
+
role: context.identity.role,
|
|
12388
|
+
tool: parsed.toolName,
|
|
12389
|
+
routerId: router.id,
|
|
12390
|
+
phase: "failure",
|
|
12391
|
+
params: parsed.params,
|
|
12392
|
+
outcome: message,
|
|
12393
|
+
durationMs: elapsed
|
|
12394
|
+
}, context.appConfig.auditLogPath);
|
|
12395
|
+
return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
|
|
12080
12396
|
}
|
|
12081
12397
|
}
|
|
12082
|
-
|
|
12083
|
-
|
|
12084
|
-
|
|
12085
|
-
|
|
12086
|
-
|
|
12087
|
-
tool: tool.name,
|
|
12088
|
-
routerId: routerId2,
|
|
12089
|
-
params: handlerArgs,
|
|
12090
|
-
snapshotIds
|
|
12091
|
-
});
|
|
12092
|
-
}
|
|
12093
|
-
const runHandler = () => tool.handler(handlerArgs, toolContext);
|
|
12094
|
-
const executeHandler = () => cb.execute(
|
|
12095
|
-
tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
|
|
12096
|
-
);
|
|
12097
|
-
const result = await executeHandler();
|
|
12098
|
-
if (journalId) {
|
|
12099
|
-
recordOutcome({
|
|
12100
|
-
journalPath: config.journalPath,
|
|
12101
|
-
journalId,
|
|
12102
|
-
phase: "success",
|
|
12103
|
-
durationMs: Date.now() - startMs
|
|
12104
|
-
});
|
|
12105
|
-
}
|
|
12106
|
-
if (shouldAudit) {
|
|
12107
|
-
auditLog({
|
|
12108
|
-
type: "audit",
|
|
12109
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12110
|
-
correlationId,
|
|
12111
|
-
identityId: identity.id,
|
|
12112
|
-
role: identity.role,
|
|
12113
|
-
tool: tool.name,
|
|
12114
|
-
routerId: routerId2,
|
|
12115
|
-
phase: "success",
|
|
12116
|
-
params: args,
|
|
12117
|
-
durationMs: Date.now() - startMs
|
|
12118
|
-
}, config.auditLogPath);
|
|
12398
|
+
const results2 = [...preErrors];
|
|
12399
|
+
for (let i = 0; i < routers.length; i += parsed.concurrency) {
|
|
12400
|
+
const batch = routers.slice(i, i + parsed.concurrency);
|
|
12401
|
+
const batchResults = await Promise.all(batch.map(runForRouter));
|
|
12402
|
+
results2.push(...batchResults);
|
|
12119
12403
|
}
|
|
12120
|
-
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
12125
|
-
|
|
12126
|
-
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
12404
|
+
const succeeded = results2.filter((r) => r.status === "ok").length;
|
|
12405
|
+
const failed = results2.filter((r) => r.status === "error").length;
|
|
12406
|
+
auditLog({
|
|
12407
|
+
type: "audit",
|
|
12408
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12409
|
+
correlationId: context.correlationId,
|
|
12410
|
+
identityId: context.identity.id,
|
|
12411
|
+
role: context.identity.role,
|
|
12412
|
+
tool: "bulk_execute",
|
|
12413
|
+
routerId: "(fleet)",
|
|
12414
|
+
phase: "success",
|
|
12415
|
+
params: { toolName: parsed.toolName, succeeded, failed }
|
|
12416
|
+
}, context.appConfig.auditLogPath);
|
|
12417
|
+
return {
|
|
12418
|
+
content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
|
|
12419
|
+
structuredContent: {
|
|
12420
|
+
toolName: parsed.toolName,
|
|
12421
|
+
totalRouters: results2.length,
|
|
12422
|
+
succeeded,
|
|
12423
|
+
failed,
|
|
12424
|
+
results: results2
|
|
12130
12425
|
}
|
|
12131
|
-
}
|
|
12132
|
-
if (journalId) {
|
|
12133
|
-
recordOutcome({
|
|
12134
|
-
journalPath: config.journalPath,
|
|
12135
|
-
journalId,
|
|
12136
|
-
phase: "failure",
|
|
12137
|
-
outcome: error.code,
|
|
12138
|
-
durationMs: Date.now() - startMs
|
|
12139
|
-
});
|
|
12140
|
-
}
|
|
12141
|
-
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
12142
|
-
if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
|
|
12143
|
-
const routerId2 = args.routerId ?? "unknown";
|
|
12144
|
-
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
12145
|
-
auditLog({
|
|
12146
|
-
type: "audit",
|
|
12147
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12148
|
-
correlationId,
|
|
12149
|
-
identityId: identity.id,
|
|
12150
|
-
role: identity.role,
|
|
12151
|
-
tool: tool.name,
|
|
12152
|
-
routerId: routerId2,
|
|
12153
|
-
phase: "failure",
|
|
12154
|
-
params: args,
|
|
12155
|
-
outcome: error.code,
|
|
12156
|
-
durationMs: Date.now() - startMs
|
|
12157
|
-
}, config.auditLogPath);
|
|
12158
|
-
}
|
|
12159
|
-
log48.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
|
|
12160
|
-
recordToolCall(tool.name, "error");
|
|
12161
|
-
return formatError(error);
|
|
12426
|
+
};
|
|
12162
12427
|
}
|
|
12163
|
-
}
|
|
12428
|
+
};
|
|
12429
|
+
return [checkRouterHealthTool, bulkExecuteTool, listRoutersTool];
|
|
12164
12430
|
}
|
|
12165
12431
|
|
|
12432
|
+
// src/domain/tools/index.ts
|
|
12433
|
+
var baseTools = [
|
|
12434
|
+
...systemTools,
|
|
12435
|
+
...interfaceTools,
|
|
12436
|
+
...ipTools,
|
|
12437
|
+
...dhcpTools,
|
|
12438
|
+
...routeTools,
|
|
12439
|
+
...firewallTools,
|
|
12440
|
+
...diagnosticTools,
|
|
12441
|
+
...systemOpsTools,
|
|
12442
|
+
...bridgeTools,
|
|
12443
|
+
...wifiTools,
|
|
12444
|
+
...wireguardTools,
|
|
12445
|
+
...dnsTools,
|
|
12446
|
+
...mangleTools,
|
|
12447
|
+
...addressListTools,
|
|
12448
|
+
...policyRoutingTools,
|
|
12449
|
+
...routingProtocolTools,
|
|
12450
|
+
...scriptsTools,
|
|
12451
|
+
...schedulerTools,
|
|
12452
|
+
...packagesTools,
|
|
12453
|
+
...filesTools,
|
|
12454
|
+
...containerTools,
|
|
12455
|
+
...ipsecTools,
|
|
12456
|
+
...certificateTools,
|
|
12457
|
+
...userTools,
|
|
12458
|
+
...userGroupTools,
|
|
12459
|
+
...dhcpServerTools,
|
|
12460
|
+
...ipPoolTools,
|
|
12461
|
+
...queueTools,
|
|
12462
|
+
...vrrpTools,
|
|
12463
|
+
...networkServicesTools,
|
|
12464
|
+
...vlanTools,
|
|
12465
|
+
...dhcpClientTools,
|
|
12466
|
+
...ipServiceTools,
|
|
12467
|
+
...pppoeTools,
|
|
12468
|
+
...ovpnTools,
|
|
12469
|
+
...pppTools,
|
|
12470
|
+
...upgradeTools,
|
|
12471
|
+
...backupTools,
|
|
12472
|
+
...logTools,
|
|
12473
|
+
...networkTestTools,
|
|
12474
|
+
...containerConfigTools,
|
|
12475
|
+
...interfaceListTools
|
|
12476
|
+
];
|
|
12477
|
+
var allTools = [
|
|
12478
|
+
...baseTools,
|
|
12479
|
+
...createChangeManagementTools(baseTools),
|
|
12480
|
+
...createFleetTools(baseTools)
|
|
12481
|
+
];
|
|
12482
|
+
|
|
12166
12483
|
// src/mcp/tool-registry.ts
|
|
12167
12484
|
var log49 = createLogger("tool-registry");
|
|
12168
12485
|
function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
|
|
@@ -12280,11 +12597,40 @@ ${issues}`);
|
|
|
12280
12597
|
}
|
|
12281
12598
|
};
|
|
12282
12599
|
|
|
12600
|
+
// src/adapter/connection-pool.ts
|
|
12601
|
+
import { createHash as createHash5 } from "crypto";
|
|
12602
|
+
|
|
12283
12603
|
// src/adapter/rest-client.ts
|
|
12284
|
-
import { request, Agent } from "undici";
|
|
12604
|
+
import { request, Agent as Agent2 } from "undici";
|
|
12285
12605
|
|
|
12286
12606
|
// src/adapter/tls-manager.ts
|
|
12287
12607
|
import { readFileSync as readFileSync3 } from "fs";
|
|
12608
|
+
import { buildConnector } from "undici";
|
|
12609
|
+
function makePinnedConnector(inner, expectedFingerprint) {
|
|
12610
|
+
const expected = expectedFingerprint.replace(/:/g, "").toLowerCase();
|
|
12611
|
+
return (opts, cb) => {
|
|
12612
|
+
inner(opts, (err, socket) => {
|
|
12613
|
+
if (err || !socket) {
|
|
12614
|
+
cb(err ?? new Error("TLS connection failed"), null);
|
|
12615
|
+
return;
|
|
12616
|
+
}
|
|
12617
|
+
const peer = socket;
|
|
12618
|
+
const cert = typeof peer.getPeerCertificate === "function" ? peer.getPeerCertificate() : void 0;
|
|
12619
|
+
const actual = (cert?.fingerprint256 ?? "").replace(/:/g, "").toLowerCase();
|
|
12620
|
+
if (actual !== expected) {
|
|
12621
|
+
socket.destroy();
|
|
12622
|
+
cb(
|
|
12623
|
+
new Error(
|
|
12624
|
+
`TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual || "none"}`
|
|
12625
|
+
),
|
|
12626
|
+
null
|
|
12627
|
+
);
|
|
12628
|
+
return;
|
|
12629
|
+
}
|
|
12630
|
+
cb(null, socket);
|
|
12631
|
+
});
|
|
12632
|
+
};
|
|
12633
|
+
}
|
|
12288
12634
|
function buildAgentOptions(tls) {
|
|
12289
12635
|
if (!tls.enabled) {
|
|
12290
12636
|
return {};
|
|
@@ -12296,53 +12642,14 @@ function buildAgentOptions(tls) {
|
|
|
12296
12642
|
connectOptions.ca = readFileSync3(tls.ca, "utf-8");
|
|
12297
12643
|
}
|
|
12298
12644
|
if (tls.fingerprint) {
|
|
12299
|
-
const
|
|
12300
|
-
|
|
12301
|
-
const actual = cert.fingerprint256.replace(/:/g, "").toLowerCase();
|
|
12302
|
-
if (actual !== expected) {
|
|
12303
|
-
return new Error(
|
|
12304
|
-
`TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual}`
|
|
12305
|
-
);
|
|
12306
|
-
}
|
|
12307
|
-
return void 0;
|
|
12308
|
-
};
|
|
12645
|
+
const inner = buildConnector(connectOptions);
|
|
12646
|
+
return { connect: makePinnedConnector(inner, tls.fingerprint) };
|
|
12309
12647
|
}
|
|
12310
12648
|
return {
|
|
12311
12649
|
connect: connectOptions
|
|
12312
12650
|
};
|
|
12313
12651
|
}
|
|
12314
12652
|
|
|
12315
|
-
// src/adapter/response-parser.ts
|
|
12316
|
-
var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
12317
|
-
var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
|
|
12318
|
-
function parseRouterOSValue(key2, value) {
|
|
12319
|
-
if (key2 === ".id") {
|
|
12320
|
-
return value;
|
|
12321
|
-
}
|
|
12322
|
-
if (value === "true") return true;
|
|
12323
|
-
if (value === "false") return false;
|
|
12324
|
-
if (DURATION_RE.test(value)) {
|
|
12325
|
-
return value;
|
|
12326
|
-
}
|
|
12327
|
-
if (NUMERIC_RE.test(value)) {
|
|
12328
|
-
const num = Number(value);
|
|
12329
|
-
if (Number.isFinite(num)) {
|
|
12330
|
-
return num;
|
|
12331
|
-
}
|
|
12332
|
-
}
|
|
12333
|
-
return value;
|
|
12334
|
-
}
|
|
12335
|
-
function parseRecord(raw) {
|
|
12336
|
-
const parsed = {};
|
|
12337
|
-
for (const [key2, value] of Object.entries(raw)) {
|
|
12338
|
-
parsed[key2] = parseRouterOSValue(key2, value);
|
|
12339
|
-
}
|
|
12340
|
-
return parsed;
|
|
12341
|
-
}
|
|
12342
|
-
function parseRecords(raw) {
|
|
12343
|
-
return raw.map((r) => parseRecord(r));
|
|
12344
|
-
}
|
|
12345
|
-
|
|
12346
12653
|
// src/adapter/query-builder.ts
|
|
12347
12654
|
function buildListQuery(options) {
|
|
12348
12655
|
if (!options) {
|
|
@@ -12404,7 +12711,7 @@ var RouterOSRestClient = class {
|
|
|
12404
12711
|
const scheme = config.tls.enabled ? "https" : "http";
|
|
12405
12712
|
this.baseUrl = `${scheme}://${config.host}:${config.port}/rest`;
|
|
12406
12713
|
this.authHeader = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64");
|
|
12407
|
-
this.agent = new
|
|
12714
|
+
this.agent = new Agent2(buildAgentOptions(config.tls));
|
|
12408
12715
|
}
|
|
12409
12716
|
// ---------- public CRUD methods ----------
|
|
12410
12717
|
/**
|
|
@@ -12497,19 +12804,27 @@ var RouterOSRestClient = class {
|
|
|
12497
12804
|
};
|
|
12498
12805
|
|
|
12499
12806
|
// src/adapter/connection-pool.ts
|
|
12807
|
+
function credentialHash(credentials) {
|
|
12808
|
+
return createHash5("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
|
|
12809
|
+
}
|
|
12500
12810
|
var ConnectionPool = class {
|
|
12501
12811
|
clients = /* @__PURE__ */ new Map();
|
|
12502
12812
|
/**
|
|
12503
|
-
* Get an existing client for the given router or create a new one.
|
|
12504
|
-
*
|
|
12813
|
+
* Get an existing client for the given router or create a new one. Clients are
|
|
12814
|
+
* keyed by `config.id`, but a client whose credentials no longer match the
|
|
12815
|
+
* request is closed and rebuilt so rotated credentials take effect.
|
|
12505
12816
|
*/
|
|
12506
12817
|
getClient(config, credentials) {
|
|
12818
|
+
const credHash = credentialHash(credentials);
|
|
12507
12819
|
const existing = this.clients.get(config.id);
|
|
12508
12820
|
if (existing) {
|
|
12509
|
-
|
|
12821
|
+
if (existing.credHash === credHash) {
|
|
12822
|
+
return existing.client;
|
|
12823
|
+
}
|
|
12824
|
+
existing.client.close();
|
|
12510
12825
|
}
|
|
12511
12826
|
const client = new RouterOSRestClient(config, credentials);
|
|
12512
|
-
this.clients.set(config.id, client);
|
|
12827
|
+
this.clients.set(config.id, { client, credHash });
|
|
12513
12828
|
return client;
|
|
12514
12829
|
}
|
|
12515
12830
|
/**
|
|
@@ -12517,12 +12832,12 @@ var ConnectionPool = class {
|
|
|
12517
12832
|
* `/rest/system/resource`. Returns `true` if the request succeeds.
|
|
12518
12833
|
*/
|
|
12519
12834
|
async healthCheck(routerId2) {
|
|
12520
|
-
const
|
|
12521
|
-
if (!
|
|
12835
|
+
const entry = this.clients.get(routerId2);
|
|
12836
|
+
if (!entry) {
|
|
12522
12837
|
return false;
|
|
12523
12838
|
}
|
|
12524
12839
|
try {
|
|
12525
|
-
await client.get("system/resource");
|
|
12840
|
+
await entry.client.get("system/resource");
|
|
12526
12841
|
return true;
|
|
12527
12842
|
} catch {
|
|
12528
12843
|
return false;
|
|
@@ -12530,23 +12845,23 @@ var ConnectionPool = class {
|
|
|
12530
12845
|
}
|
|
12531
12846
|
/** Remove and close a single client by router ID. */
|
|
12532
12847
|
removeClient(routerId2) {
|
|
12533
|
-
const
|
|
12534
|
-
if (
|
|
12535
|
-
client.close();
|
|
12848
|
+
const entry = this.clients.get(routerId2);
|
|
12849
|
+
if (entry) {
|
|
12850
|
+
entry.client.close();
|
|
12536
12851
|
this.clients.delete(routerId2);
|
|
12537
12852
|
}
|
|
12538
12853
|
}
|
|
12539
12854
|
/** Close and remove all clients. */
|
|
12540
12855
|
closeAll() {
|
|
12541
|
-
for (const [id,
|
|
12542
|
-
client.close();
|
|
12856
|
+
for (const [id, entry] of this.clients) {
|
|
12857
|
+
entry.client.close();
|
|
12543
12858
|
this.clients.delete(id);
|
|
12544
12859
|
}
|
|
12545
12860
|
}
|
|
12546
12861
|
};
|
|
12547
12862
|
|
|
12548
12863
|
// src/version.ts
|
|
12549
|
-
var VERSION = "1.
|
|
12864
|
+
var VERSION = "1.8.0";
|
|
12550
12865
|
|
|
12551
12866
|
// src/mcp/server.ts
|
|
12552
12867
|
var SERVER_INSTRUCTIONS = [
|
|
@@ -12589,6 +12904,7 @@ import { createServer } from "http";
|
|
|
12589
12904
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
12590
12905
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
12591
12906
|
var log51 = createLogger("transport-http");
|
|
12907
|
+
var SESSION_TTL_MS = 30 * 6e4;
|
|
12592
12908
|
var BodyTooLargeError = class extends Error {
|
|
12593
12909
|
constructor() {
|
|
12594
12910
|
super("Request body exceeds maximum allowed size");
|
|
@@ -12625,7 +12941,7 @@ function createRateLimiter(rpm) {
|
|
|
12625
12941
|
}
|
|
12626
12942
|
async function readBody(req, maxBytes) {
|
|
12627
12943
|
return new Promise((resolve, reject) => {
|
|
12628
|
-
|
|
12944
|
+
const chunks = [];
|
|
12629
12945
|
let size = 0;
|
|
12630
12946
|
let settled = false;
|
|
12631
12947
|
const fail2 = (err) => {
|
|
@@ -12640,11 +12956,12 @@ async function readBody(req, maxBytes) {
|
|
|
12640
12956
|
fail2(new BodyTooLargeError());
|
|
12641
12957
|
return;
|
|
12642
12958
|
}
|
|
12643
|
-
|
|
12959
|
+
chunks.push(chunk);
|
|
12644
12960
|
});
|
|
12645
12961
|
req.on("end", () => {
|
|
12646
12962
|
if (settled) return;
|
|
12647
12963
|
settled = true;
|
|
12964
|
+
const data = Buffer.concat(chunks).toString("utf-8");
|
|
12648
12965
|
if (!data) {
|
|
12649
12966
|
resolve(void 0);
|
|
12650
12967
|
return;
|
|
@@ -12665,6 +12982,17 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
12665
12982
|
sweepTimer.unref();
|
|
12666
12983
|
const streamableSessions = /* @__PURE__ */ new Map();
|
|
12667
12984
|
const sseTransports = /* @__PURE__ */ new Map();
|
|
12985
|
+
const sessionSweep = setInterval(() => {
|
|
12986
|
+
const now = Date.now();
|
|
12987
|
+
for (const [id, session] of streamableSessions) {
|
|
12988
|
+
if (now - session.lastSeenMs > SESSION_TTL_MS) {
|
|
12989
|
+
streamableSessions.delete(id);
|
|
12990
|
+
session.transport.close();
|
|
12991
|
+
log51.info({ sessionId: id }, "Streamable HTTP session expired (idle TTL)");
|
|
12992
|
+
}
|
|
12993
|
+
}
|
|
12994
|
+
}, 6e4);
|
|
12995
|
+
sessionSweep.unref();
|
|
12668
12996
|
const httpServer = createServer(async (req, res) => {
|
|
12669
12997
|
const ip = req.socket.remoteAddress ?? "unknown";
|
|
12670
12998
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
@@ -12675,6 +13003,21 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
12675
13003
|
return;
|
|
12676
13004
|
}
|
|
12677
13005
|
if (pathname === "/metrics") {
|
|
13006
|
+
if (identityRegistry.getIdentities().length > 0) {
|
|
13007
|
+
try {
|
|
13008
|
+
await authenticateHttp(req, identityRegistry);
|
|
13009
|
+
} catch (err) {
|
|
13010
|
+
if (err instanceof MikroMCPError) {
|
|
13011
|
+
res.writeHead(401, {
|
|
13012
|
+
"Content-Type": "application/json",
|
|
13013
|
+
"WWW-Authenticate": "Bearer"
|
|
13014
|
+
});
|
|
13015
|
+
res.end(JSON.stringify({ error: err.message, code: err.code }));
|
|
13016
|
+
return;
|
|
13017
|
+
}
|
|
13018
|
+
throw err;
|
|
13019
|
+
}
|
|
13020
|
+
}
|
|
12678
13021
|
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
12679
13022
|
res.end(renderPrometheus());
|
|
12680
13023
|
return;
|
|
@@ -12692,14 +13035,18 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
12692
13035
|
identity = await authenticateHttp(req, identityRegistry);
|
|
12693
13036
|
} catch (err) {
|
|
12694
13037
|
if (err instanceof MikroMCPError) {
|
|
12695
|
-
res.writeHead(401, {
|
|
13038
|
+
res.writeHead(401, {
|
|
13039
|
+
"Content-Type": "application/json",
|
|
13040
|
+
"WWW-Authenticate": "Bearer"
|
|
13041
|
+
});
|
|
12696
13042
|
res.end(JSON.stringify({ error: err.message, code: err.code }));
|
|
12697
13043
|
return;
|
|
12698
13044
|
}
|
|
12699
13045
|
throw err;
|
|
12700
13046
|
}
|
|
12701
|
-
|
|
12702
|
-
|
|
13047
|
+
const boundIdentity = identity;
|
|
13048
|
+
await withIdentity(boundIdentity, async () => {
|
|
13049
|
+
await handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports, boundIdentity.id);
|
|
12703
13050
|
});
|
|
12704
13051
|
return;
|
|
12705
13052
|
}
|
|
@@ -12728,7 +13075,10 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
12728
13075
|
}
|
|
12729
13076
|
}
|
|
12730
13077
|
});
|
|
12731
|
-
httpServer.on("close", () =>
|
|
13078
|
+
httpServer.on("close", () => {
|
|
13079
|
+
clearInterval(sweepTimer);
|
|
13080
|
+
clearInterval(sessionSweep);
|
|
13081
|
+
});
|
|
12732
13082
|
await new Promise((resolve, reject) => {
|
|
12733
13083
|
httpServer.listen(port, bindHost, () => {
|
|
12734
13084
|
log51.info(
|
|
@@ -12741,10 +13091,19 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
12741
13091
|
});
|
|
12742
13092
|
return httpServer;
|
|
12743
13093
|
}
|
|
12744
|
-
async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports) {
|
|
13094
|
+
async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports, identityId) {
|
|
12745
13095
|
if (pathname === "/mcp") {
|
|
12746
13096
|
const sessionId = req.headers["mcp-session-id"];
|
|
12747
|
-
let
|
|
13097
|
+
let session = sessionId ? streamableSessions.get(sessionId) : void 0;
|
|
13098
|
+
if (session) {
|
|
13099
|
+
if (session.identityId !== identityId) {
|
|
13100
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
13101
|
+
res.end(JSON.stringify({ error: "Session belongs to a different identity." }));
|
|
13102
|
+
return;
|
|
13103
|
+
}
|
|
13104
|
+
session.lastSeenMs = Date.now();
|
|
13105
|
+
}
|
|
13106
|
+
let transport2 = session?.transport;
|
|
12748
13107
|
if (!transport2) {
|
|
12749
13108
|
if (sessionId) {
|
|
12750
13109
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
@@ -12754,8 +13113,8 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
12754
13113
|
transport2 = new StreamableHTTPServerTransport({
|
|
12755
13114
|
sessionIdGenerator: () => randomUUID(),
|
|
12756
13115
|
onsessioninitialized: (id) => {
|
|
12757
|
-
streamableSessions.set(id, transport2);
|
|
12758
|
-
log51.info({ sessionId: id }, "Streamable HTTP session created");
|
|
13116
|
+
streamableSessions.set(id, { transport: transport2, identityId, lastSeenMs: Date.now() });
|
|
13117
|
+
log51.info({ sessionId: id, identityId }, "Streamable HTTP session created");
|
|
12759
13118
|
}
|
|
12760
13119
|
});
|
|
12761
13120
|
transport2.onclose = () => {
|
|
@@ -12798,6 +13157,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
12798
13157
|
|
|
12799
13158
|
// src/config/identity-registry.ts
|
|
12800
13159
|
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
|
|
13160
|
+
import { createHash as createHash6 } from "crypto";
|
|
12801
13161
|
import { parse as parse2 } from "yaml";
|
|
12802
13162
|
import { z as z49 } from "zod";
|
|
12803
13163
|
import bcrypt from "bcryptjs";
|
|
@@ -12813,6 +13173,9 @@ var ConfigFileSchema2 = z49.object({
|
|
|
12813
13173
|
}).strict();
|
|
12814
13174
|
var IdentityRegistry = class {
|
|
12815
13175
|
identities = [];
|
|
13176
|
+
// Maps sha256(token) → resolved identity id. Avoids a bcrypt compare against
|
|
13177
|
+
// every identity on each authenticated request after the first match.
|
|
13178
|
+
tokenCache = /* @__PURE__ */ new Map();
|
|
12816
13179
|
constructor(configPath) {
|
|
12817
13180
|
if (!existsSync2(configPath)) {
|
|
12818
13181
|
log52.warn(
|
|
@@ -12849,15 +13212,24 @@ ${issues}`);
|
|
|
12849
13212
|
}));
|
|
12850
13213
|
}
|
|
12851
13214
|
async findIdentityByToken(token) {
|
|
13215
|
+
const cacheKey = createHash6("sha256").update(token).digest("hex");
|
|
13216
|
+
const toIdentity = (stored) => ({
|
|
13217
|
+
id: stored.id,
|
|
13218
|
+
role: stored.role,
|
|
13219
|
+
allowedRouters: stored.allowedRouters,
|
|
13220
|
+
allowedToolPatterns: stored.allowedToolPatterns
|
|
13221
|
+
});
|
|
13222
|
+
const cachedId = this.tokenCache.get(cacheKey);
|
|
13223
|
+
if (cachedId !== void 0) {
|
|
13224
|
+
const stored = this.identities.find((i) => i.id === cachedId);
|
|
13225
|
+
if (stored) return toIdentity(stored);
|
|
13226
|
+
this.tokenCache.delete(cacheKey);
|
|
13227
|
+
}
|
|
12852
13228
|
for (const stored of this.identities) {
|
|
12853
13229
|
const match = await bcrypt.compare(token, stored.tokenHash);
|
|
12854
13230
|
if (match) {
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
role: stored.role,
|
|
12858
|
-
allowedRouters: stored.allowedRouters,
|
|
12859
|
-
allowedToolPatterns: stored.allowedToolPatterns
|
|
12860
|
-
};
|
|
13231
|
+
this.tokenCache.set(cacheKey, stored.id);
|
|
13232
|
+
return toIdentity(stored);
|
|
12861
13233
|
}
|
|
12862
13234
|
}
|
|
12863
13235
|
return null;
|
|
@@ -12957,7 +13329,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
|
12957
13329
|
import { homedir as homedir2 } from "os";
|
|
12958
13330
|
import * as net from "net";
|
|
12959
13331
|
import chalk from "chalk";
|
|
12960
|
-
var localVersion = "1.
|
|
13332
|
+
var localVersion = "1.8.0";
|
|
12961
13333
|
var results = [];
|
|
12962
13334
|
function ok(msg) {
|
|
12963
13335
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -13669,7 +14041,7 @@ async function runInit() {
|
|
|
13669
14041
|
}
|
|
13670
14042
|
|
|
13671
14043
|
// src/cli/index.ts
|
|
13672
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.
|
|
14044
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.8.0");
|
|
13673
14045
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
13674
14046
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
13675
14047
|
program.command("init").description("Interactive setup wizard").action(runInit);
|