mikromcp 1.0.9 → 1.1.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 +2 -1
- package/dist/main.js +888 -592
- package/package.json +3 -2
package/dist/main.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/main.ts
|
|
4
4
|
import { config as loadDotenv } from "dotenv";
|
|
5
5
|
import { join as join5 } from "path";
|
|
6
|
-
import { homedir as
|
|
6
|
+
import { homedir as homedir4 } from "os";
|
|
7
7
|
|
|
8
8
|
// src/cli/index.ts
|
|
9
9
|
import { Command } from "commander";
|
|
@@ -50,9 +50,8 @@ function loadAppConfig() {
|
|
|
50
50
|
failureThreshold: 5,
|
|
51
51
|
cooldownMs: 3e4
|
|
52
52
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
maxLimit: 500
|
|
53
|
+
retention: {
|
|
54
|
+
snapshotMaxAgeDays: parseInt(env.MIKROMCP_SNAPSHOT_RETENTION_DAYS ?? "30", 10)
|
|
56
55
|
}
|
|
57
56
|
};
|
|
58
57
|
}
|
|
@@ -371,8 +370,8 @@ function buildTextSummary(sections) {
|
|
|
371
370
|
if (sections.health) {
|
|
372
371
|
const h = sections.health;
|
|
373
372
|
lines.push(`Health:`);
|
|
374
|
-
for (const [
|
|
375
|
-
if (
|
|
373
|
+
for (const [key2, value] of Object.entries(h)) {
|
|
374
|
+
if (key2 !== ".id") lines.push(` ${key2}: ${value}`);
|
|
376
375
|
}
|
|
377
376
|
}
|
|
378
377
|
if (sections.clock) {
|
|
@@ -493,9 +492,9 @@ var listInterfacesTool = {
|
|
|
493
492
|
const paginated = interfaces.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
494
493
|
const results2 = parsed.includeCounters ? paginated : paginated.map((iface) => {
|
|
495
494
|
const cleaned = {};
|
|
496
|
-
for (const [
|
|
497
|
-
if (!COUNTER_PROPS.includes(
|
|
498
|
-
cleaned[
|
|
495
|
+
for (const [key2, value] of Object.entries(iface)) {
|
|
496
|
+
if (!COUNTER_PROPS.includes(key2)) {
|
|
497
|
+
cleaned[key2] = value;
|
|
499
498
|
}
|
|
500
499
|
}
|
|
501
500
|
return cleaned;
|
|
@@ -2112,8 +2111,8 @@ var runCommandTool = {
|
|
|
2112
2111
|
async handler(params, context) {
|
|
2113
2112
|
const parsed = runCommandInputSchema.parse(params);
|
|
2114
2113
|
log8.info({ routerId: context.routerId, command: parsed.command }, "Running command");
|
|
2115
|
-
const globalAllow =
|
|
2116
|
-
const globalDeny =
|
|
2114
|
+
const globalAllow = context.appConfig.cmdAllow;
|
|
2115
|
+
const globalDeny = context.appConfig.cmdDeny;
|
|
2117
2116
|
const policy = resolveCommandPolicy(context.routerConfig, globalAllow, globalDeny);
|
|
2118
2117
|
checkCommand(parsed.command, policy);
|
|
2119
2118
|
if (parsed.dryRun) {
|
|
@@ -2711,9 +2710,9 @@ var listPeersTool = {
|
|
|
2711
2710
|
const lines = [`WireGuard peers on ${context.routerId}: ${total} total`];
|
|
2712
2711
|
for (const peer of peers) {
|
|
2713
2712
|
const rec = peer;
|
|
2714
|
-
const
|
|
2713
|
+
const key2 = rec["public-key"] ? rec["public-key"].slice(0, 8) + "\u2026" : "unknown";
|
|
2715
2714
|
lines.push(
|
|
2716
|
-
` [${rec.interface ?? "?"}] ${
|
|
2715
|
+
` [${rec.interface ?? "?"}] ${key2} last-handshake=${rec["last-handshake"] ?? "never"}`
|
|
2717
2716
|
);
|
|
2718
2717
|
}
|
|
2719
2718
|
return {
|
|
@@ -4905,12 +4904,10 @@ var containerTools = [listContainersTool, manageContainerTool];
|
|
|
4905
4904
|
|
|
4906
4905
|
// src/domain/tools/change-management-tools.ts
|
|
4907
4906
|
import { z as z23 } from "zod";
|
|
4908
|
-
import { readFileSync
|
|
4909
|
-
import { join as join3 } from "path";
|
|
4910
|
-
import { homedir as homedir2 } from "os";
|
|
4907
|
+
import { readFileSync } from "fs";
|
|
4911
4908
|
|
|
4912
4909
|
// src/domain/snapshot/snapshot-engine.ts
|
|
4913
|
-
import {
|
|
4910
|
+
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
4914
4911
|
import { join as join2 } from "path";
|
|
4915
4912
|
import { nanoid } from "nanoid";
|
|
4916
4913
|
function pathToSlug(rosPath) {
|
|
@@ -4925,13 +4922,13 @@ async function takeSnapshot(client, routerId, path, snapshotDir) {
|
|
|
4925
4922
|
const dir = join2(snapshotDir, routerId);
|
|
4926
4923
|
const filePath = join2(dir, `${id}.json`);
|
|
4927
4924
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
4928
|
-
|
|
4925
|
+
await mkdir(dir, { recursive: true });
|
|
4929
4926
|
const stored = { id, routerId, path, ts, records };
|
|
4930
|
-
|
|
4927
|
+
await writeFile(filePath, JSON.stringify(stored, null, 2));
|
|
4931
4928
|
return { id, routerId, path, ts, filePath, recordCount: records.length };
|
|
4932
4929
|
}
|
|
4933
|
-
function loadSnapshot(filePath) {
|
|
4934
|
-
return JSON.parse(
|
|
4930
|
+
async function loadSnapshot(filePath) {
|
|
4931
|
+
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
4935
4932
|
}
|
|
4936
4933
|
|
|
4937
4934
|
// src/domain/snapshot/diff-engine.ts
|
|
@@ -4951,7 +4948,16 @@ var SEMANTIC_KEYS = {
|
|
|
4951
4948
|
"system/scheduler": ["name"],
|
|
4952
4949
|
"system/script": ["name"],
|
|
4953
4950
|
"system/package": ["name"],
|
|
4954
|
-
"container": ["name"]
|
|
4951
|
+
"container": ["name"],
|
|
4952
|
+
"certificate": ["name"],
|
|
4953
|
+
"file": ["name"],
|
|
4954
|
+
"interface/vrrp": ["name"],
|
|
4955
|
+
"ip/dhcp-server": ["name"],
|
|
4956
|
+
"ip/ipsec/peer": ["name"],
|
|
4957
|
+
"ip/pool": ["name"],
|
|
4958
|
+
"queue/simple": ["name"],
|
|
4959
|
+
"tool/netwatch": ["host"],
|
|
4960
|
+
"user": ["name"]
|
|
4955
4961
|
};
|
|
4956
4962
|
function normalizeValue(v) {
|
|
4957
4963
|
if (v === "true" || v === "yes") return "true";
|
|
@@ -4986,8 +4992,8 @@ function computeRestorePlan(path, before, current) {
|
|
|
4986
4992
|
const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
|
|
4987
4993
|
const toCreate = [];
|
|
4988
4994
|
const toUpdate = [];
|
|
4989
|
-
for (const [
|
|
4990
|
-
const currentRecord = currentByKey.get(
|
|
4995
|
+
for (const [key2, beforeRecord] of beforeByKey) {
|
|
4996
|
+
const currentRecord = currentByKey.get(key2);
|
|
4991
4997
|
if (!currentRecord) {
|
|
4992
4998
|
toCreate.push(beforeRecord);
|
|
4993
4999
|
} else if (!recordsAreEqual(beforeRecord, currentRecord)) {
|
|
@@ -5012,52 +5018,63 @@ async function applyRestorePlan(plan, client) {
|
|
|
5012
5018
|
}
|
|
5013
5019
|
|
|
5014
5020
|
// src/domain/snapshot/write-journal.ts
|
|
5015
|
-
import {
|
|
5021
|
+
import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
|
|
5016
5022
|
import { dirname } from "path";
|
|
5017
5023
|
import { nanoid as nanoid2 } from "nanoid";
|
|
5018
5024
|
|
|
5019
5025
|
// src/observability/audit-log.ts
|
|
5020
|
-
import {
|
|
5026
|
+
import { appendFile } from "fs/promises";
|
|
5021
5027
|
var log22 = createLogger("audit");
|
|
5022
|
-
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
5028
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
5029
|
+
"password",
|
|
5030
|
+
"pass",
|
|
5031
|
+
"token",
|
|
5032
|
+
"secret",
|
|
5033
|
+
"authorization",
|
|
5034
|
+
"credentials",
|
|
5035
|
+
"privatekey",
|
|
5036
|
+
"presharedkey",
|
|
5037
|
+
"psk",
|
|
5038
|
+
"passphrase",
|
|
5039
|
+
"community",
|
|
5040
|
+
"apikey",
|
|
5041
|
+
"confirmationsecret"
|
|
5042
|
+
]);
|
|
5023
5043
|
function redactParams(params) {
|
|
5024
5044
|
const out = {};
|
|
5025
|
-
for (const [
|
|
5026
|
-
if (SENSITIVE_KEYS.has(
|
|
5045
|
+
for (const [key2, value] of Object.entries(params)) {
|
|
5046
|
+
if (SENSITIVE_KEYS.has(key2.toLowerCase())) continue;
|
|
5027
5047
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
5028
|
-
out[
|
|
5048
|
+
out[key2] = redactParams(value);
|
|
5029
5049
|
} else {
|
|
5030
|
-
out[
|
|
5050
|
+
out[key2] = value;
|
|
5031
5051
|
}
|
|
5032
5052
|
}
|
|
5033
5053
|
return out;
|
|
5034
5054
|
}
|
|
5035
|
-
|
|
5036
|
-
function auditLog(event) {
|
|
5055
|
+
function auditLog(event, auditLogPath) {
|
|
5037
5056
|
const safeEvent = { ...event, params: redactParams(event.params) };
|
|
5038
5057
|
log22.info(safeEvent, "audit");
|
|
5039
|
-
if (
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
}
|
|
5043
|
-
log22.error({ err, auditFilePath }, "Failed to write audit event to file");
|
|
5044
|
-
}
|
|
5058
|
+
if (auditLogPath) {
|
|
5059
|
+
appendFile(auditLogPath, JSON.stringify(safeEvent) + "\n").catch((err) => {
|
|
5060
|
+
log22.error({ err, auditLogPath }, "Failed to write audit event to file");
|
|
5061
|
+
});
|
|
5045
5062
|
}
|
|
5046
5063
|
}
|
|
5047
5064
|
|
|
5048
5065
|
// src/domain/snapshot/write-journal.ts
|
|
5049
5066
|
var log23 = createLogger("write-journal");
|
|
5050
|
-
function appendLine(filePath, obj) {
|
|
5067
|
+
async function appendLine(filePath, obj) {
|
|
5051
5068
|
try {
|
|
5052
|
-
|
|
5053
|
-
|
|
5069
|
+
await mkdir2(dirname(filePath), { recursive: true });
|
|
5070
|
+
await appendFile2(filePath, JSON.stringify(obj) + "\n");
|
|
5054
5071
|
} catch (err) {
|
|
5055
5072
|
log23.error({ err, filePath }, "Failed to write to write journal");
|
|
5056
5073
|
}
|
|
5057
5074
|
}
|
|
5058
5075
|
function recordAttempt(args) {
|
|
5059
5076
|
const id = nanoid2();
|
|
5060
|
-
appendLine(args.journalPath, {
|
|
5077
|
+
void appendLine(args.journalPath, {
|
|
5061
5078
|
id,
|
|
5062
5079
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5063
5080
|
identityId: args.identityId,
|
|
@@ -5071,7 +5088,7 @@ function recordAttempt(args) {
|
|
|
5071
5088
|
return id;
|
|
5072
5089
|
}
|
|
5073
5090
|
function recordOutcome(args) {
|
|
5074
|
-
appendLine(args.journalPath, {
|
|
5091
|
+
void appendLine(args.journalPath, {
|
|
5075
5092
|
id: args.journalId,
|
|
5076
5093
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5077
5094
|
phase: args.phase,
|
|
@@ -5185,9 +5202,8 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5185
5202
|
async handler(params, context) {
|
|
5186
5203
|
const parsed = applyPlanInputSchema.parse(params);
|
|
5187
5204
|
log24.info({ routerId: context.routerId, stepCount: parsed.steps.length }, "Applying plan");
|
|
5188
|
-
const
|
|
5189
|
-
const
|
|
5190
|
-
const snapshotDir = `${dataDir}/snapshots`;
|
|
5205
|
+
const journalPath = context.appConfig.journalPath;
|
|
5206
|
+
const snapshotDir = context.appConfig.snapshotDir;
|
|
5191
5207
|
const results2 = [];
|
|
5192
5208
|
for (const [i, step] of parsed.steps.entries()) {
|
|
5193
5209
|
const tool = requireTool(step.tool);
|
|
@@ -5212,9 +5228,11 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5212
5228
|
params: stepParams,
|
|
5213
5229
|
snapshotIds
|
|
5214
5230
|
});
|
|
5231
|
+
const stepStartMs = Date.now();
|
|
5215
5232
|
try {
|
|
5216
|
-
const
|
|
5217
|
-
|
|
5233
|
+
const runStep = () => tool.handler(stepParams, context);
|
|
5234
|
+
const result = context.circuitBreaker ? await context.circuitBreaker.execute(runStep) : await runStep();
|
|
5235
|
+
recordOutcome({ journalPath, journalId: stepJournalId, phase: "success", durationMs: Date.now() - stepStartMs });
|
|
5218
5236
|
results2.push({
|
|
5219
5237
|
stepIndex: i,
|
|
5220
5238
|
tool: step.tool,
|
|
@@ -5224,7 +5242,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5224
5242
|
});
|
|
5225
5243
|
} catch (err) {
|
|
5226
5244
|
const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: step.tool });
|
|
5227
|
-
recordOutcome({ journalPath, journalId: stepJournalId, phase: "failure", outcome: error.code, durationMs:
|
|
5245
|
+
recordOutcome({ journalPath, journalId: stepJournalId, phase: "failure", outcome: error.code, durationMs: Date.now() - stepStartMs });
|
|
5228
5246
|
log24.error({ err: error, tool: step.tool, step: i }, "apply_plan step failed");
|
|
5229
5247
|
return {
|
|
5230
5248
|
content: `Apply failed at step ${i + 1}/${parsed.steps.length} (${step.tool}): ${error.message}. ${i} step(s) completed before failure.`,
|
|
@@ -5250,7 +5268,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5250
5268
|
}
|
|
5251
5269
|
};
|
|
5252
5270
|
function findJournalEntry(journalPath, journalId) {
|
|
5253
|
-
const raw =
|
|
5271
|
+
const raw = readFileSync(journalPath, "utf-8");
|
|
5254
5272
|
const lines = raw.split("\n").filter(Boolean);
|
|
5255
5273
|
const entry = lines.map((l) => JSON.parse(l)).find((e) => e.id === journalId && e.phase === "attempt");
|
|
5256
5274
|
if (!entry) {
|
|
@@ -5279,9 +5297,8 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5279
5297
|
},
|
|
5280
5298
|
async handler(params, context) {
|
|
5281
5299
|
const parsed = rollbackChangeInputSchema.parse(params);
|
|
5282
|
-
const
|
|
5283
|
-
const
|
|
5284
|
-
const snapshotDir = `${dataDir}/snapshots`;
|
|
5300
|
+
const journalPath = context.appConfig.journalPath;
|
|
5301
|
+
const snapshotDir = context.appConfig.snapshotDir;
|
|
5285
5302
|
log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
|
|
5286
5303
|
const { snapshotIds } = findJournalEntry(journalPath, parsed.journalId);
|
|
5287
5304
|
if (snapshotIds.length === 0) {
|
|
@@ -5293,7 +5310,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5293
5310
|
const restorePlans = [];
|
|
5294
5311
|
for (const snapshotId of snapshotIds) {
|
|
5295
5312
|
const filePath = `${snapshotDir}/${context.routerId}/${snapshotId}.json`;
|
|
5296
|
-
const stored = loadSnapshot(filePath);
|
|
5313
|
+
const stored = await loadSnapshot(filePath);
|
|
5297
5314
|
const current = await context.routerClient.get(stored.path, {});
|
|
5298
5315
|
const plan = computeRestorePlan(stored.path, stored.records, current);
|
|
5299
5316
|
restorePlans.push(plan);
|
|
@@ -7284,6 +7301,51 @@ function checkAuthz(identity, toolName, routerId) {
|
|
|
7284
7301
|
}
|
|
7285
7302
|
}
|
|
7286
7303
|
|
|
7304
|
+
// src/config/secrets.ts
|
|
7305
|
+
function getCredentials(router) {
|
|
7306
|
+
const { source, envPrefix, vaultPath } = router.credentials;
|
|
7307
|
+
if (source === "vault") {
|
|
7308
|
+
throw new MikroMCPError({
|
|
7309
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7310
|
+
code: "VAULT_NOT_SUPPORTED",
|
|
7311
|
+
message: `Vault credential source is not supported in v0.1 (vaultPath=${vaultPath ?? "n/a"})`,
|
|
7312
|
+
recoverability: {
|
|
7313
|
+
retryable: false,
|
|
7314
|
+
suggestedAction: 'Switch to source="env" and provide credentials via environment variables.'
|
|
7315
|
+
}
|
|
7316
|
+
});
|
|
7317
|
+
}
|
|
7318
|
+
if (!envPrefix) {
|
|
7319
|
+
throw new MikroMCPError({
|
|
7320
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7321
|
+
code: "MISSING_ENV_PREFIX",
|
|
7322
|
+
message: `Router "${router.id}" uses source="env" but no envPrefix is configured`,
|
|
7323
|
+
recoverability: {
|
|
7324
|
+
retryable: false,
|
|
7325
|
+
suggestedAction: "Set envPrefix in the router configuration."
|
|
7326
|
+
}
|
|
7327
|
+
});
|
|
7328
|
+
}
|
|
7329
|
+
const userKey = `${envPrefix}_USER`;
|
|
7330
|
+
const passKey = `${envPrefix}_PASS`;
|
|
7331
|
+
const username = process.env[userKey];
|
|
7332
|
+
const password = process.env[passKey];
|
|
7333
|
+
if (!username || !password) {
|
|
7334
|
+
const missing = [!username ? userKey : null, !password ? passKey : null].filter(Boolean);
|
|
7335
|
+
throw new MikroMCPError({
|
|
7336
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7337
|
+
code: "MISSING_CREDENTIALS",
|
|
7338
|
+
message: `Missing environment variable(s) for router "${router.id}": ${missing.join(", ")}`,
|
|
7339
|
+
details: { missing },
|
|
7340
|
+
recoverability: {
|
|
7341
|
+
retryable: false,
|
|
7342
|
+
suggestedAction: `Set the ${missing.join(" and ")} environment variable(s).`
|
|
7343
|
+
}
|
|
7344
|
+
});
|
|
7345
|
+
}
|
|
7346
|
+
return { username, password };
|
|
7347
|
+
}
|
|
7348
|
+
|
|
7287
7349
|
// src/adapter/ssh-client.ts
|
|
7288
7350
|
import { createHash } from "crypto";
|
|
7289
7351
|
import { Client } from "ssh2";
|
|
@@ -7356,8 +7418,8 @@ var SshClient = class {
|
|
|
7356
7418
|
};
|
|
7357
7419
|
if (this.config.sshFingerprint) {
|
|
7358
7420
|
const expected = this.config.sshFingerprint.toLowerCase();
|
|
7359
|
-
connectOptions.hostVerifier = (
|
|
7360
|
-
const actual = createHash("sha256").update(
|
|
7421
|
+
connectOptions.hostVerifier = (key2) => {
|
|
7422
|
+
const actual = createHash("sha256").update(key2).digest("hex");
|
|
7361
7423
|
return actual === expected;
|
|
7362
7424
|
};
|
|
7363
7425
|
}
|
|
@@ -7413,51 +7475,6 @@ var FtpClient = class {
|
|
|
7413
7475
|
}
|
|
7414
7476
|
};
|
|
7415
7477
|
|
|
7416
|
-
// src/config/secrets.ts
|
|
7417
|
-
function getCredentials(router) {
|
|
7418
|
-
const { source, envPrefix, vaultPath } = router.credentials;
|
|
7419
|
-
if (source === "vault") {
|
|
7420
|
-
throw new MikroMCPError({
|
|
7421
|
-
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7422
|
-
code: "VAULT_NOT_SUPPORTED",
|
|
7423
|
-
message: `Vault credential source is not supported in v0.1 (vaultPath=${vaultPath ?? "n/a"})`,
|
|
7424
|
-
recoverability: {
|
|
7425
|
-
retryable: false,
|
|
7426
|
-
suggestedAction: 'Switch to source="env" and provide credentials via environment variables.'
|
|
7427
|
-
}
|
|
7428
|
-
});
|
|
7429
|
-
}
|
|
7430
|
-
if (!envPrefix) {
|
|
7431
|
-
throw new MikroMCPError({
|
|
7432
|
-
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7433
|
-
code: "MISSING_ENV_PREFIX",
|
|
7434
|
-
message: `Router "${router.id}" uses source="env" but no envPrefix is configured`,
|
|
7435
|
-
recoverability: {
|
|
7436
|
-
retryable: false,
|
|
7437
|
-
suggestedAction: "Set envPrefix in the router configuration."
|
|
7438
|
-
}
|
|
7439
|
-
});
|
|
7440
|
-
}
|
|
7441
|
-
const userKey = `${envPrefix}_USER`;
|
|
7442
|
-
const passKey = `${envPrefix}_PASS`;
|
|
7443
|
-
const username = process.env[userKey];
|
|
7444
|
-
const password = process.env[passKey];
|
|
7445
|
-
if (!username || !password) {
|
|
7446
|
-
const missing = [!username ? userKey : null, !password ? passKey : null].filter(Boolean);
|
|
7447
|
-
throw new MikroMCPError({
|
|
7448
|
-
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7449
|
-
code: "MISSING_CREDENTIALS",
|
|
7450
|
-
message: `Missing environment variable(s) for router "${router.id}": ${missing.join(", ")}`,
|
|
7451
|
-
details: { missing },
|
|
7452
|
-
recoverability: {
|
|
7453
|
-
retryable: false,
|
|
7454
|
-
suggestedAction: `Set the ${missing.join(" and ")} environment variable(s).`
|
|
7455
|
-
}
|
|
7456
|
-
});
|
|
7457
|
-
}
|
|
7458
|
-
return { username, password };
|
|
7459
|
-
}
|
|
7460
|
-
|
|
7461
7478
|
// src/adapter/adapter-factory.ts
|
|
7462
7479
|
function createSshClient(routerConfig, options) {
|
|
7463
7480
|
const creds = getCredentials(routerConfig);
|
|
@@ -7468,6 +7485,82 @@ function createFtpClient(routerConfig) {
|
|
|
7468
7485
|
return new FtpClient(routerConfig.host, 21, creds.username, creds.password);
|
|
7469
7486
|
}
|
|
7470
7487
|
|
|
7488
|
+
// src/mcp/tool-context.ts
|
|
7489
|
+
function buildRouterToolContext(args) {
|
|
7490
|
+
const { routerConfig, correlationId, identity, pool, config, registry } = args;
|
|
7491
|
+
const credentials = getCredentials(routerConfig);
|
|
7492
|
+
return {
|
|
7493
|
+
routerClient: pool.getClient(routerConfig, credentials),
|
|
7494
|
+
routerId: routerConfig.id,
|
|
7495
|
+
correlationId,
|
|
7496
|
+
routerConfig,
|
|
7497
|
+
sshClient: createSshClient(routerConfig, config.ssh),
|
|
7498
|
+
ftpClient: createFtpClient(routerConfig),
|
|
7499
|
+
identity,
|
|
7500
|
+
routerRegistry: registry,
|
|
7501
|
+
connectionPool: pool,
|
|
7502
|
+
appConfig: config
|
|
7503
|
+
};
|
|
7504
|
+
}
|
|
7505
|
+
|
|
7506
|
+
// src/middleware/fleet-confirmation.ts
|
|
7507
|
+
import { createHmac, createHash as createHash2 } from "crypto";
|
|
7508
|
+
var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
|
|
7509
|
+
var pending = /* @__PURE__ */ new Map();
|
|
7510
|
+
function fingerprint(args) {
|
|
7511
|
+
const sortedRouters = [...args.routerIds].sort();
|
|
7512
|
+
const payload = JSON.stringify({
|
|
7513
|
+
toolName: args.toolName,
|
|
7514
|
+
routerIds: sortedRouters,
|
|
7515
|
+
params: args.params,
|
|
7516
|
+
identityId: args.identityId
|
|
7517
|
+
});
|
|
7518
|
+
return createHash2("sha256").update(payload).digest("hex");
|
|
7519
|
+
}
|
|
7520
|
+
function evictExpired() {
|
|
7521
|
+
const now = /* @__PURE__ */ new Date();
|
|
7522
|
+
for (const [token, entry] of pending) {
|
|
7523
|
+
if (entry.expiresAt <= now) pending.delete(token);
|
|
7524
|
+
}
|
|
7525
|
+
}
|
|
7526
|
+
function checkFleetConfirmation(args, secret) {
|
|
7527
|
+
evictExpired();
|
|
7528
|
+
const fp = fingerprint(args);
|
|
7529
|
+
if (args.submittedToken) {
|
|
7530
|
+
const entry = pending.get(args.submittedToken);
|
|
7531
|
+
if (!entry || entry.expiresAt <= /* @__PURE__ */ new Date() || entry.fingerprint !== fp) {
|
|
7532
|
+
throw new MikroMCPError({
|
|
7533
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7534
|
+
code: "FLEET_CONFIRMATION_MISMATCH",
|
|
7535
|
+
message: "Fleet confirmation token is expired, already used, or does not match the current toolName/routerIds/params.",
|
|
7536
|
+
recoverability: {
|
|
7537
|
+
retryable: true,
|
|
7538
|
+
suggestedAction: "Re-submit bulk_execute without confirmationToken to get a fresh token."
|
|
7539
|
+
}
|
|
7540
|
+
});
|
|
7541
|
+
}
|
|
7542
|
+
pending.delete(args.submittedToken);
|
|
7543
|
+
return;
|
|
7544
|
+
}
|
|
7545
|
+
const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS);
|
|
7546
|
+
const token = createHmac("sha256", secret).update(`${fp}:${expiresAt.toISOString()}`).digest("hex");
|
|
7547
|
+
pending.set(token, { expiresAt, fingerprint: fp });
|
|
7548
|
+
throw new MikroMCPError({
|
|
7549
|
+
category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
|
|
7550
|
+
code: "FLEET_CONFIRMATION_REQUIRED",
|
|
7551
|
+
message: `This will run destructive tool "${args.toolName}" across ${args.routerIds.length} router(s). Re-submit with confirmationToken to proceed.`,
|
|
7552
|
+
details: {
|
|
7553
|
+
confirmationToken: token,
|
|
7554
|
+
expiresAt: expiresAt.toISOString(),
|
|
7555
|
+
routerCount: args.routerIds.length
|
|
7556
|
+
},
|
|
7557
|
+
recoverability: {
|
|
7558
|
+
retryable: true,
|
|
7559
|
+
suggestedAction: "Re-submit the identical bulk_execute call with details.confirmationToken."
|
|
7560
|
+
}
|
|
7561
|
+
});
|
|
7562
|
+
}
|
|
7563
|
+
|
|
7471
7564
|
// src/domain/tools/fleet-tools.ts
|
|
7472
7565
|
var log33 = createLogger("fleet-tools");
|
|
7473
7566
|
var checkHealthInputSchema = z31.object({
|
|
@@ -7478,7 +7571,10 @@ var bulkExecuteInputSchema = z31.object({
|
|
|
7478
7571
|
routerIds: z31.array(z31.string()).optional().describe("Explicit list of router IDs to target"),
|
|
7479
7572
|
tags: z31.array(z31.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
|
|
7480
7573
|
params: z31.record(z31.string(), z31.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
|
|
7481
|
-
concurrency: z31.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls")
|
|
7574
|
+
concurrency: z31.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
|
|
7575
|
+
confirmationToken: z31.string().optional().describe(
|
|
7576
|
+
"Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
|
|
7577
|
+
)
|
|
7482
7578
|
}).strict();
|
|
7483
7579
|
function createFleetTools(baseTools2) {
|
|
7484
7580
|
const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
|
|
@@ -7533,7 +7629,7 @@ function createFleetTools(baseTools2) {
|
|
|
7533
7629
|
const bulkExecuteTool = {
|
|
7534
7630
|
name: "bulk_execute",
|
|
7535
7631
|
title: "Bulk Execute",
|
|
7536
|
-
description: "Fan out a single-router tool to multiple routers in parallel (up to `concurrency` at a time). Target routers via explicit routerIds or by tag. Destructive tools
|
|
7632
|
+
description: "Fan out a single-router tool to multiple routers in parallel (up to `concurrency` at a time). Target routers via explicit routerIds or by tag. Non-destructive tools fan out immediately. Destructive tools require a two-step confirmation: call without `confirmationToken` to receive a fleet token (requires `MIKROMCP_CONFIRMATION_SECRET`), then re-call with that token to proceed. Returns per-router results with succeeded/failed counts.",
|
|
7537
7633
|
inputSchema: bulkExecuteInputSchema,
|
|
7538
7634
|
annotations: {
|
|
7539
7635
|
readOnlyHint: false,
|
|
@@ -7561,6 +7657,17 @@ function createFleetTools(baseTools2) {
|
|
|
7561
7657
|
{ toolName: parsed.toolName, concurrency: parsed.concurrency },
|
|
7562
7658
|
"bulk_execute invoked"
|
|
7563
7659
|
);
|
|
7660
|
+
auditLog({
|
|
7661
|
+
type: "audit",
|
|
7662
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7663
|
+
correlationId: context.correlationId,
|
|
7664
|
+
identityId: context.identity.id,
|
|
7665
|
+
role: context.identity.role,
|
|
7666
|
+
tool: "bulk_execute",
|
|
7667
|
+
routerId: "(fleet)",
|
|
7668
|
+
phase: "attempt",
|
|
7669
|
+
params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
|
|
7670
|
+
}, context.appConfig.auditLogPath);
|
|
7564
7671
|
if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
|
|
7565
7672
|
throw new MikroMCPError({
|
|
7566
7673
|
category: "VALIDATION" /* VALIDATION */,
|
|
@@ -7585,17 +7692,6 @@ function createFleetTools(baseTools2) {
|
|
|
7585
7692
|
});
|
|
7586
7693
|
}
|
|
7587
7694
|
const targetTool = foundTool;
|
|
7588
|
-
if (targetTool.annotations.destructiveHint) {
|
|
7589
|
-
throw new MikroMCPError({
|
|
7590
|
-
category: "VALIDATION" /* VALIDATION */,
|
|
7591
|
-
code: "BULK_DESTRUCTIVE_NOT_ALLOWED",
|
|
7592
|
-
message: `Tool "${parsed.toolName}" is destructive. Destructive tools require per-router confirmation and cannot be fanned out via bulk_execute.`,
|
|
7593
|
-
recoverability: {
|
|
7594
|
-
retryable: false,
|
|
7595
|
-
suggestedAction: "Call the tool directly on each router to provide the required confirmation token."
|
|
7596
|
-
}
|
|
7597
|
-
});
|
|
7598
|
-
}
|
|
7599
7695
|
const preErrors = [];
|
|
7600
7696
|
let routers;
|
|
7601
7697
|
if (hasRouterIds) {
|
|
@@ -7605,14 +7701,55 @@ function createFleetTools(baseTools2) {
|
|
|
7605
7701
|
resolved.push(context.routerRegistry.getRouter(id));
|
|
7606
7702
|
} catch {
|
|
7607
7703
|
log33.warn({ routerId: id }, "bulk_execute: router not found");
|
|
7608
|
-
preErrors.push({
|
|
7704
|
+
preErrors.push({
|
|
7705
|
+
routerId: id,
|
|
7706
|
+
status: "error",
|
|
7707
|
+
error: `Router "${id}" not found in registry`,
|
|
7708
|
+
durationMs: 0
|
|
7709
|
+
});
|
|
7609
7710
|
}
|
|
7610
7711
|
}
|
|
7611
7712
|
routers = resolved;
|
|
7612
7713
|
} else {
|
|
7613
7714
|
routers = context.routerRegistry.listRouters(parsed.tags);
|
|
7614
7715
|
}
|
|
7716
|
+
if (targetTool.annotations.destructiveHint) {
|
|
7717
|
+
const secret = context.appConfig.confirmationSecret;
|
|
7718
|
+
if (!secret) {
|
|
7719
|
+
throw new MikroMCPError({
|
|
7720
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7721
|
+
code: "FLEET_CONFIRMATION_UNAVAILABLE",
|
|
7722
|
+
message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
|
|
7723
|
+
recoverability: {
|
|
7724
|
+
retryable: false,
|
|
7725
|
+
suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
|
|
7726
|
+
}
|
|
7727
|
+
});
|
|
7728
|
+
}
|
|
7729
|
+
const routerIdList = routers.map((r) => r.id);
|
|
7730
|
+
checkFleetConfirmation(
|
|
7731
|
+
{
|
|
7732
|
+
toolName: parsed.toolName,
|
|
7733
|
+
routerIds: routerIdList,
|
|
7734
|
+
params: parsed.params,
|
|
7735
|
+
identityId: context.identity.id,
|
|
7736
|
+
submittedToken: parsed.confirmationToken
|
|
7737
|
+
},
|
|
7738
|
+
secret
|
|
7739
|
+
);
|
|
7740
|
+
}
|
|
7615
7741
|
if (routers.length === 0 && preErrors.length === 0) {
|
|
7742
|
+
auditLog({
|
|
7743
|
+
type: "audit",
|
|
7744
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7745
|
+
correlationId: context.correlationId,
|
|
7746
|
+
identityId: context.identity.id,
|
|
7747
|
+
role: context.identity.role,
|
|
7748
|
+
tool: "bulk_execute",
|
|
7749
|
+
routerId: "(fleet)",
|
|
7750
|
+
phase: "success",
|
|
7751
|
+
params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
|
|
7752
|
+
}, context.appConfig.auditLogPath);
|
|
7616
7753
|
return {
|
|
7617
7754
|
content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
|
|
7618
7755
|
structuredContent: {
|
|
@@ -7628,28 +7765,50 @@ function createFleetTools(baseTools2) {
|
|
|
7628
7765
|
const start = Date.now();
|
|
7629
7766
|
try {
|
|
7630
7767
|
checkAuthz(context.identity, parsed.toolName, router.id);
|
|
7631
|
-
const
|
|
7632
|
-
const client = context.connectionPool.getClient(router, credentials);
|
|
7633
|
-
const sshClient = createSshClient(router, {});
|
|
7634
|
-
const ftpClient = createFtpClient(router);
|
|
7635
|
-
const routerContext = {
|
|
7636
|
-
routerClient: client,
|
|
7637
|
-
routerId: router.id,
|
|
7638
|
-
correlationId: context.correlationId,
|
|
7768
|
+
const routerContext = buildRouterToolContext({
|
|
7639
7769
|
routerConfig: router,
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7770
|
+
correlationId: context.correlationId,
|
|
7771
|
+
identity: context.identity,
|
|
7772
|
+
pool: context.connectionPool,
|
|
7773
|
+
config: context.appConfig,
|
|
7774
|
+
registry: context.routerRegistry
|
|
7775
|
+
});
|
|
7644
7776
|
const toolParams = { ...parsed.params, routerId: router.id };
|
|
7645
7777
|
const result = await targetTool.handler(
|
|
7646
7778
|
toolParams,
|
|
7647
7779
|
routerContext
|
|
7648
7780
|
);
|
|
7649
|
-
|
|
7781
|
+
const elapsed = Date.now() - start;
|
|
7782
|
+
auditLog({
|
|
7783
|
+
type: "audit",
|
|
7784
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7785
|
+
correlationId: context.correlationId,
|
|
7786
|
+
identityId: context.identity.id,
|
|
7787
|
+
role: context.identity.role,
|
|
7788
|
+
tool: parsed.toolName,
|
|
7789
|
+
routerId: router.id,
|
|
7790
|
+
phase: "success",
|
|
7791
|
+
params: parsed.params,
|
|
7792
|
+
durationMs: elapsed
|
|
7793
|
+
}, context.appConfig.auditLogPath);
|
|
7794
|
+
return { routerId: router.id, status: "ok", result, durationMs: elapsed };
|
|
7650
7795
|
} catch (err) {
|
|
7796
|
+
const elapsed = Date.now() - start;
|
|
7651
7797
|
const message = err instanceof Error ? err.message : String(err);
|
|
7652
|
-
|
|
7798
|
+
auditLog({
|
|
7799
|
+
type: "audit",
|
|
7800
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7801
|
+
correlationId: context.correlationId,
|
|
7802
|
+
identityId: context.identity.id,
|
|
7803
|
+
role: context.identity.role,
|
|
7804
|
+
tool: parsed.toolName,
|
|
7805
|
+
routerId: router.id,
|
|
7806
|
+
phase: "failure",
|
|
7807
|
+
params: parsed.params,
|
|
7808
|
+
outcome: message,
|
|
7809
|
+
durationMs: elapsed
|
|
7810
|
+
}, context.appConfig.auditLogPath);
|
|
7811
|
+
return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
|
|
7653
7812
|
}
|
|
7654
7813
|
}
|
|
7655
7814
|
const results2 = [...preErrors];
|
|
@@ -7660,6 +7819,17 @@ function createFleetTools(baseTools2) {
|
|
|
7660
7819
|
}
|
|
7661
7820
|
const succeeded = results2.filter((r) => r.status === "ok").length;
|
|
7662
7821
|
const failed = results2.filter((r) => r.status === "error").length;
|
|
7822
|
+
auditLog({
|
|
7823
|
+
type: "audit",
|
|
7824
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7825
|
+
correlationId: context.correlationId,
|
|
7826
|
+
identityId: context.identity.id,
|
|
7827
|
+
role: context.identity.role,
|
|
7828
|
+
tool: "bulk_execute",
|
|
7829
|
+
routerId: "(fleet)",
|
|
7830
|
+
phase: "success",
|
|
7831
|
+
params: { toolName: parsed.toolName, succeeded, failed }
|
|
7832
|
+
}, context.appConfig.auditLogPath);
|
|
7663
7833
|
return {
|
|
7664
7834
|
content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
|
|
7665
7835
|
structuredContent: {
|
|
@@ -7712,217 +7882,63 @@ var allTools = [
|
|
|
7712
7882
|
...createFleetTools(baseTools)
|
|
7713
7883
|
];
|
|
7714
7884
|
|
|
7715
|
-
// src/
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
};
|
|
7721
|
-
var
|
|
7722
|
-
function
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
return statusCode >= 500;
|
|
7731
|
-
}
|
|
7732
|
-
}
|
|
7733
|
-
return false;
|
|
7885
|
+
// src/mcp/tool-executor.ts
|
|
7886
|
+
import { nanoid as nanoid4 } from "nanoid";
|
|
7887
|
+
|
|
7888
|
+
// src/observability/correlation.ts
|
|
7889
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
7890
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
7891
|
+
var requestContext = new AsyncLocalStorage();
|
|
7892
|
+
function withContext(ctx, fn) {
|
|
7893
|
+
const fullContext = {
|
|
7894
|
+
correlationId: ctx.correlationId ?? nanoid3(),
|
|
7895
|
+
routerId: ctx.routerId,
|
|
7896
|
+
tool: ctx.tool,
|
|
7897
|
+
identity: ctx.identity
|
|
7898
|
+
};
|
|
7899
|
+
return requestContext.run(fullContext, fn);
|
|
7734
7900
|
}
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7901
|
+
|
|
7902
|
+
// src/middleware/auth.ts
|
|
7903
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
7904
|
+
var identityStorage = new AsyncLocalStorage2();
|
|
7905
|
+
function getCurrentIdentity() {
|
|
7906
|
+
return identityStorage.getStore();
|
|
7907
|
+
}
|
|
7908
|
+
function withIdentity(identity, fn) {
|
|
7909
|
+
return identityStorage.run(identity, fn);
|
|
7910
|
+
}
|
|
7911
|
+
function extractBearerToken(authHeader) {
|
|
7912
|
+
if (!authHeader) return null;
|
|
7913
|
+
const match = /^Bearer (.+)$/i.exec(authHeader);
|
|
7914
|
+
return match ? match[1] : null;
|
|
7915
|
+
}
|
|
7916
|
+
async function authenticateHttp(req, registry) {
|
|
7917
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
7918
|
+
if (!token) {
|
|
7919
|
+
throw new MikroMCPError({
|
|
7920
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7921
|
+
code: "INVALID_TOKEN",
|
|
7922
|
+
message: "Missing or malformed Authorization header. Use: Authorization: Bearer <token>",
|
|
7923
|
+
recoverability: {
|
|
7924
|
+
retryable: false,
|
|
7925
|
+
suggestedAction: "Provide a valid bearer token in the Authorization header."
|
|
7745
7926
|
}
|
|
7746
|
-
|
|
7747
|
-
const jitter = Math.random() * opts.baseDelayMs;
|
|
7748
|
-
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
|
|
7749
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
7750
|
-
}
|
|
7927
|
+
});
|
|
7751
7928
|
}
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
}
|
|
7766
|
-
return TRANSIENT_CATEGORIES.has(error.category);
|
|
7767
|
-
}
|
|
7768
|
-
var DEFAULTS2 = {
|
|
7769
|
-
failureThreshold: 5,
|
|
7770
|
-
cooldownMs: 3e4
|
|
7771
|
-
};
|
|
7772
|
-
var CircuitBreaker = class {
|
|
7773
|
-
constructor(routerId, options) {
|
|
7774
|
-
this.routerId = routerId;
|
|
7775
|
-
this.options = { ...DEFAULTS2, ...options };
|
|
7776
|
-
}
|
|
7777
|
-
routerId;
|
|
7778
|
-
options;
|
|
7779
|
-
currentState = "closed";
|
|
7780
|
-
consecutiveFailures = 0;
|
|
7781
|
-
lastFailureTime = 0;
|
|
7782
|
-
get state() {
|
|
7783
|
-
if (this.currentState === "open") {
|
|
7784
|
-
const elapsed = Date.now() - this.lastFailureTime;
|
|
7785
|
-
if (elapsed >= this.options.cooldownMs) {
|
|
7786
|
-
this.currentState = "half-open";
|
|
7787
|
-
}
|
|
7788
|
-
}
|
|
7789
|
-
return this.currentState;
|
|
7790
|
-
}
|
|
7791
|
-
/**
|
|
7792
|
-
* Execute `fn` through the circuit breaker.
|
|
7793
|
-
*
|
|
7794
|
-
* - **closed**: execute normally; track consecutive failures.
|
|
7795
|
-
* - **open**: reject immediately with `ROUTER_BUSY`.
|
|
7796
|
-
* - **half-open**: execute as a probe; success closes, failure reopens.
|
|
7797
|
-
*/
|
|
7798
|
-
async execute(fn) {
|
|
7799
|
-
const currentState = this.state;
|
|
7800
|
-
if (currentState === "open") {
|
|
7801
|
-
throw new MikroMCPError({
|
|
7802
|
-
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
7803
|
-
code: "CIRCUIT_OPEN",
|
|
7804
|
-
message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
|
|
7805
|
-
details: {
|
|
7806
|
-
routerId: this.routerId,
|
|
7807
|
-
consecutiveFailures: this.consecutiveFailures,
|
|
7808
|
-
cooldownMs: this.options.cooldownMs
|
|
7809
|
-
},
|
|
7810
|
-
recoverability: {
|
|
7811
|
-
retryable: true,
|
|
7812
|
-
retryAfterMs: this.options.cooldownMs,
|
|
7813
|
-
suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
|
|
7814
|
-
}
|
|
7815
|
-
});
|
|
7816
|
-
}
|
|
7817
|
-
try {
|
|
7818
|
-
const result = await fn();
|
|
7819
|
-
this.onSuccess();
|
|
7820
|
-
return result;
|
|
7821
|
-
} catch (error) {
|
|
7822
|
-
if (isTransientFailure(error)) {
|
|
7823
|
-
this.onFailure();
|
|
7824
|
-
}
|
|
7825
|
-
throw error;
|
|
7826
|
-
}
|
|
7827
|
-
}
|
|
7828
|
-
/** Force the circuit back to the closed state. */
|
|
7829
|
-
reset() {
|
|
7830
|
-
this.currentState = "closed";
|
|
7831
|
-
this.consecutiveFailures = 0;
|
|
7832
|
-
this.lastFailureTime = 0;
|
|
7833
|
-
}
|
|
7834
|
-
// ---------- internal helpers ----------
|
|
7835
|
-
onSuccess() {
|
|
7836
|
-
this.consecutiveFailures = 0;
|
|
7837
|
-
this.currentState = "closed";
|
|
7838
|
-
}
|
|
7839
|
-
onFailure() {
|
|
7840
|
-
this.consecutiveFailures++;
|
|
7841
|
-
this.lastFailureTime = Date.now();
|
|
7842
|
-
if (this.currentState === "half-open") {
|
|
7843
|
-
this.currentState = "open";
|
|
7844
|
-
} else if (this.consecutiveFailures >= this.options.failureThreshold) {
|
|
7845
|
-
this.currentState = "open";
|
|
7846
|
-
}
|
|
7847
|
-
}
|
|
7848
|
-
};
|
|
7849
|
-
|
|
7850
|
-
// src/mcp/response-formatter.ts
|
|
7851
|
-
function formatToolResult(result) {
|
|
7852
|
-
return {
|
|
7853
|
-
content: [{ type: "text", text: result.content }],
|
|
7854
|
-
structuredContent: result.structuredContent,
|
|
7855
|
-
...result.isError ? { isError: true } : {}
|
|
7856
|
-
};
|
|
7857
|
-
}
|
|
7858
|
-
function formatError(error) {
|
|
7859
|
-
const hint = error.recoverability.suggestedAction;
|
|
7860
|
-
const text = `Error [${error.category}]: ${error.message}${hint ? `
|
|
7861
|
-
Suggested action: ${hint}` : ""}`;
|
|
7862
|
-
return {
|
|
7863
|
-
content: [{ type: "text", text }],
|
|
7864
|
-
structuredContent: error.toJSON(),
|
|
7865
|
-
isError: true
|
|
7866
|
-
};
|
|
7867
|
-
}
|
|
7868
|
-
|
|
7869
|
-
// src/observability/correlation.ts
|
|
7870
|
-
import { AsyncLocalStorage } from "async_hooks";
|
|
7871
|
-
import { nanoid as nanoid3 } from "nanoid";
|
|
7872
|
-
var requestContext = new AsyncLocalStorage();
|
|
7873
|
-
function withContext(ctx, fn) {
|
|
7874
|
-
const fullContext = {
|
|
7875
|
-
correlationId: ctx.correlationId ?? nanoid3(),
|
|
7876
|
-
routerId: ctx.routerId,
|
|
7877
|
-
tool: ctx.tool,
|
|
7878
|
-
identity: ctx.identity
|
|
7879
|
-
};
|
|
7880
|
-
return requestContext.run(fullContext, fn);
|
|
7881
|
-
}
|
|
7882
|
-
|
|
7883
|
-
// src/mcp/tool-registry.ts
|
|
7884
|
-
import { nanoid as nanoid4 } from "nanoid";
|
|
7885
|
-
|
|
7886
|
-
// src/middleware/auth.ts
|
|
7887
|
-
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
7888
|
-
var identityStorage = new AsyncLocalStorage2();
|
|
7889
|
-
function getCurrentIdentity() {
|
|
7890
|
-
return identityStorage.getStore();
|
|
7891
|
-
}
|
|
7892
|
-
function withIdentity(identity, fn) {
|
|
7893
|
-
return identityStorage.run(identity, fn);
|
|
7894
|
-
}
|
|
7895
|
-
function extractBearerToken(authHeader) {
|
|
7896
|
-
if (!authHeader) return null;
|
|
7897
|
-
const match = /^Bearer (.+)$/i.exec(authHeader);
|
|
7898
|
-
return match ? match[1] : null;
|
|
7899
|
-
}
|
|
7900
|
-
async function authenticateHttp(req, registry) {
|
|
7901
|
-
const token = extractBearerToken(req.headers.authorization);
|
|
7902
|
-
if (!token) {
|
|
7903
|
-
throw new MikroMCPError({
|
|
7904
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7905
|
-
code: "INVALID_TOKEN",
|
|
7906
|
-
message: "Missing or malformed Authorization header. Use: Authorization: Bearer <token>",
|
|
7907
|
-
recoverability: {
|
|
7908
|
-
retryable: false,
|
|
7909
|
-
suggestedAction: "Provide a valid bearer token in the Authorization header."
|
|
7910
|
-
}
|
|
7911
|
-
});
|
|
7912
|
-
}
|
|
7913
|
-
const identity = await registry.findIdentityByToken(token);
|
|
7914
|
-
if (!identity) {
|
|
7915
|
-
throw new MikroMCPError({
|
|
7916
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7917
|
-
code: "INVALID_TOKEN",
|
|
7918
|
-
message: "Token does not match any configured identity.",
|
|
7919
|
-
recoverability: {
|
|
7920
|
-
retryable: false,
|
|
7921
|
-
suggestedAction: "Verify the bearer token matches a configured identity in identities.yaml."
|
|
7922
|
-
}
|
|
7923
|
-
});
|
|
7924
|
-
}
|
|
7925
|
-
return identity;
|
|
7929
|
+
const identity = await registry.findIdentityByToken(token);
|
|
7930
|
+
if (!identity) {
|
|
7931
|
+
throw new MikroMCPError({
|
|
7932
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7933
|
+
code: "INVALID_TOKEN",
|
|
7934
|
+
message: "Token does not match any configured identity.",
|
|
7935
|
+
recoverability: {
|
|
7936
|
+
retryable: false,
|
|
7937
|
+
suggestedAction: "Verify the bearer token matches a configured identity in identities.yaml."
|
|
7938
|
+
}
|
|
7939
|
+
});
|
|
7940
|
+
}
|
|
7941
|
+
return identity;
|
|
7926
7942
|
}
|
|
7927
7943
|
var BUILTIN_SUPERADMIN = {
|
|
7928
7944
|
id: "superadmin-builtin",
|
|
@@ -7951,19 +7967,19 @@ function getStdioIdentity(stdioIdentityName, registry) {
|
|
|
7951
7967
|
}
|
|
7952
7968
|
|
|
7953
7969
|
// src/middleware/confirmation.ts
|
|
7954
|
-
import { createHmac, createHash as
|
|
7955
|
-
var
|
|
7970
|
+
import { createHmac as createHmac2, createHash as createHash3 } from "crypto";
|
|
7971
|
+
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
7956
7972
|
var BYPASS_ROLES = ["admin", "superadmin"];
|
|
7957
7973
|
var pendingConfirmations = /* @__PURE__ */ new Map();
|
|
7958
7974
|
function computeParamHash(params) {
|
|
7959
7975
|
const { confirmationToken: _, ...rest } = params;
|
|
7960
|
-
return
|
|
7976
|
+
return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
7961
7977
|
}
|
|
7962
7978
|
function computeToken(tool, routerId, identityId, paramHash, expiresAt, secret) {
|
|
7963
7979
|
const payload = JSON.stringify({ tool, routerId, paramHash, identityId, expiresAt });
|
|
7964
|
-
return
|
|
7980
|
+
return createHmac2("sha256", secret).update(payload).digest("hex");
|
|
7965
7981
|
}
|
|
7966
|
-
function
|
|
7982
|
+
function evictExpired2() {
|
|
7967
7983
|
const now = /* @__PURE__ */ new Date();
|
|
7968
7984
|
for (const [token, entry] of pendingConfirmations) {
|
|
7969
7985
|
if (entry.expiresAt <= now) pendingConfirmations.delete(token);
|
|
@@ -7971,7 +7987,7 @@ function evictExpired() {
|
|
|
7971
7987
|
}
|
|
7972
7988
|
async function checkConfirmation(toolName, routerId, params, identity, secret) {
|
|
7973
7989
|
if (BYPASS_ROLES.includes(identity.role)) return;
|
|
7974
|
-
|
|
7990
|
+
evictExpired2();
|
|
7975
7991
|
const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
|
|
7976
7992
|
if (submittedToken) {
|
|
7977
7993
|
const entry = pendingConfirmations.get(submittedToken);
|
|
@@ -8001,7 +8017,7 @@ async function checkConfirmation(toolName, routerId, params, identity, secret) {
|
|
|
8001
8017
|
pendingConfirmations.delete(submittedToken);
|
|
8002
8018
|
return;
|
|
8003
8019
|
}
|
|
8004
|
-
const expiresAt = new Date(Date.now() +
|
|
8020
|
+
const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS2).toISOString();
|
|
8005
8021
|
const paramHash = computeParamHash(params);
|
|
8006
8022
|
const token = computeToken(toolName, routerId, identity.id, paramHash, expiresAt, secret);
|
|
8007
8023
|
pendingConfirmations.set(token, {
|
|
@@ -8028,6 +8044,49 @@ async function checkConfirmation(toolName, routerId, params, identity, secret) {
|
|
|
8028
8044
|
});
|
|
8029
8045
|
}
|
|
8030
8046
|
|
|
8047
|
+
// src/adapter/retry-engine.ts
|
|
8048
|
+
var DEFAULTS = {
|
|
8049
|
+
maxRetries: 3,
|
|
8050
|
+
baseDelayMs: 200,
|
|
8051
|
+
maxDelayMs: 5e3
|
|
8052
|
+
};
|
|
8053
|
+
var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
|
|
8054
|
+
function isRetryable(error) {
|
|
8055
|
+
if (error instanceof MikroMCPError) {
|
|
8056
|
+
return error.recoverability.retryable;
|
|
8057
|
+
}
|
|
8058
|
+
if (error instanceof Error) {
|
|
8059
|
+
const code = error.code;
|
|
8060
|
+
if (code && RETRYABLE_CODES.has(code)) {
|
|
8061
|
+
return true;
|
|
8062
|
+
}
|
|
8063
|
+
const statusCode = error.statusCode;
|
|
8064
|
+
if (typeof statusCode === "number") {
|
|
8065
|
+
return statusCode >= 500;
|
|
8066
|
+
}
|
|
8067
|
+
}
|
|
8068
|
+
return false;
|
|
8069
|
+
}
|
|
8070
|
+
async function withRetry(fn, options) {
|
|
8071
|
+
const opts = { ...DEFAULTS, ...options };
|
|
8072
|
+
let lastError;
|
|
8073
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
8074
|
+
try {
|
|
8075
|
+
return await fn();
|
|
8076
|
+
} catch (error) {
|
|
8077
|
+
lastError = error;
|
|
8078
|
+
if (attempt >= opts.maxRetries || !isRetryable(error)) {
|
|
8079
|
+
throw error;
|
|
8080
|
+
}
|
|
8081
|
+
const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
|
|
8082
|
+
const jitter = Math.random() * opts.baseDelayMs;
|
|
8083
|
+
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
|
|
8084
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
8085
|
+
}
|
|
8086
|
+
}
|
|
8087
|
+
throw lastError;
|
|
8088
|
+
}
|
|
8089
|
+
|
|
8031
8090
|
// src/config/maintenance-window.ts
|
|
8032
8091
|
var DAY_ABBREVS = {
|
|
8033
8092
|
Sun: "Sun",
|
|
@@ -8058,9 +8117,352 @@ function isWithinMaintenanceWindow(windows, now) {
|
|
|
8058
8117
|
});
|
|
8059
8118
|
}
|
|
8060
8119
|
|
|
8120
|
+
// src/mcp/response-formatter.ts
|
|
8121
|
+
function formatToolResult(result) {
|
|
8122
|
+
return {
|
|
8123
|
+
content: [{ type: "text", text: result.content }],
|
|
8124
|
+
structuredContent: result.structuredContent,
|
|
8125
|
+
...result.isError ? { isError: true } : {}
|
|
8126
|
+
};
|
|
8127
|
+
}
|
|
8128
|
+
function formatError(error) {
|
|
8129
|
+
const hint = error.recoverability.suggestedAction;
|
|
8130
|
+
const text = `Error [${error.category}]: ${error.message}${hint ? `
|
|
8131
|
+
Suggested action: ${hint}` : ""}`;
|
|
8132
|
+
return {
|
|
8133
|
+
content: [{ type: "text", text }],
|
|
8134
|
+
structuredContent: error.toJSON(),
|
|
8135
|
+
isError: true
|
|
8136
|
+
};
|
|
8137
|
+
}
|
|
8138
|
+
|
|
8139
|
+
// src/observability/metrics.ts
|
|
8140
|
+
var toolCalls = /* @__PURE__ */ new Map();
|
|
8141
|
+
function key(tool, status) {
|
|
8142
|
+
return `${tool}\0${status}`;
|
|
8143
|
+
}
|
|
8144
|
+
function recordToolCall(tool, status) {
|
|
8145
|
+
const k = key(tool, status);
|
|
8146
|
+
toolCalls.set(k, (toolCalls.get(k) ?? 0) + 1);
|
|
8147
|
+
}
|
|
8148
|
+
function renderPrometheus() {
|
|
8149
|
+
const lines = [
|
|
8150
|
+
"# HELP mikromcp_tool_calls_total Total MCP tool calls by tool and status.",
|
|
8151
|
+
"# TYPE mikromcp_tool_calls_total counter"
|
|
8152
|
+
];
|
|
8153
|
+
for (const [k, count] of toolCalls) {
|
|
8154
|
+
const [tool, status] = k.split("\0");
|
|
8155
|
+
lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
|
|
8156
|
+
}
|
|
8157
|
+
return lines.join("\n") + "\n";
|
|
8158
|
+
}
|
|
8159
|
+
|
|
8160
|
+
// src/adapter/circuit-breaker.ts
|
|
8161
|
+
var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
|
|
8162
|
+
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
|
|
8163
|
+
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
8164
|
+
"ROUTER_ERROR" /* ROUTER_ERROR */,
|
|
8165
|
+
"ROUTER_BUSY" /* ROUTER_BUSY */
|
|
8166
|
+
]);
|
|
8167
|
+
function isTransientFailure(error) {
|
|
8168
|
+
if (!(error instanceof MikroMCPError)) {
|
|
8169
|
+
return false;
|
|
8170
|
+
}
|
|
8171
|
+
return TRANSIENT_CATEGORIES.has(error.category);
|
|
8172
|
+
}
|
|
8173
|
+
var DEFAULTS2 = {
|
|
8174
|
+
failureThreshold: 5,
|
|
8175
|
+
cooldownMs: 3e4
|
|
8176
|
+
};
|
|
8177
|
+
var CircuitBreaker = class {
|
|
8178
|
+
constructor(routerId, options) {
|
|
8179
|
+
this.routerId = routerId;
|
|
8180
|
+
this.options = { ...DEFAULTS2, ...options };
|
|
8181
|
+
}
|
|
8182
|
+
routerId;
|
|
8183
|
+
options;
|
|
8184
|
+
currentState = "closed";
|
|
8185
|
+
consecutiveFailures = 0;
|
|
8186
|
+
lastFailureTime = 0;
|
|
8187
|
+
probeInFlight = false;
|
|
8188
|
+
get state() {
|
|
8189
|
+
if (this.currentState === "open") {
|
|
8190
|
+
const elapsed = Date.now() - this.lastFailureTime;
|
|
8191
|
+
if (elapsed >= this.options.cooldownMs) {
|
|
8192
|
+
this.currentState = "half-open";
|
|
8193
|
+
}
|
|
8194
|
+
}
|
|
8195
|
+
return this.currentState;
|
|
8196
|
+
}
|
|
8197
|
+
/**
|
|
8198
|
+
* Execute `fn` through the circuit breaker.
|
|
8199
|
+
*
|
|
8200
|
+
* - **closed**: execute normally; track consecutive failures.
|
|
8201
|
+
* - **open**: reject immediately with `ROUTER_BUSY`.
|
|
8202
|
+
* - **half-open**: execute as a probe; success closes, failure reopens.
|
|
8203
|
+
*/
|
|
8204
|
+
async execute(fn) {
|
|
8205
|
+
const currentState = this.state;
|
|
8206
|
+
if (currentState === "open") {
|
|
8207
|
+
throw new MikroMCPError({
|
|
8208
|
+
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
8209
|
+
code: "CIRCUIT_OPEN",
|
|
8210
|
+
message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
|
|
8211
|
+
details: {
|
|
8212
|
+
routerId: this.routerId,
|
|
8213
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
8214
|
+
cooldownMs: this.options.cooldownMs
|
|
8215
|
+
},
|
|
8216
|
+
recoverability: {
|
|
8217
|
+
retryable: true,
|
|
8218
|
+
retryAfterMs: this.options.cooldownMs,
|
|
8219
|
+
suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
|
|
8220
|
+
}
|
|
8221
|
+
});
|
|
8222
|
+
}
|
|
8223
|
+
if (currentState === "half-open") {
|
|
8224
|
+
if (this.probeInFlight) {
|
|
8225
|
+
throw new MikroMCPError({
|
|
8226
|
+
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
8227
|
+
code: "CIRCUIT_PROBE_IN_FLIGHT",
|
|
8228
|
+
message: `Circuit breaker for router "${this.routerId}" is half-open and a recovery probe is already in flight.`,
|
|
8229
|
+
details: { routerId: this.routerId },
|
|
8230
|
+
recoverability: {
|
|
8231
|
+
retryable: true,
|
|
8232
|
+
retryAfterMs: 1e3,
|
|
8233
|
+
suggestedAction: "Wait for the in-flight probe to complete, then retry."
|
|
8234
|
+
}
|
|
8235
|
+
});
|
|
8236
|
+
}
|
|
8237
|
+
this.probeInFlight = true;
|
|
8238
|
+
}
|
|
8239
|
+
try {
|
|
8240
|
+
const result = await fn();
|
|
8241
|
+
this.onSuccess();
|
|
8242
|
+
return result;
|
|
8243
|
+
} catch (error) {
|
|
8244
|
+
if (isTransientFailure(error)) {
|
|
8245
|
+
this.onFailure();
|
|
8246
|
+
}
|
|
8247
|
+
throw error;
|
|
8248
|
+
} finally {
|
|
8249
|
+
this.probeInFlight = false;
|
|
8250
|
+
}
|
|
8251
|
+
}
|
|
8252
|
+
/** Force the circuit back to the closed state. */
|
|
8253
|
+
reset() {
|
|
8254
|
+
this.currentState = "closed";
|
|
8255
|
+
this.consecutiveFailures = 0;
|
|
8256
|
+
this.lastFailureTime = 0;
|
|
8257
|
+
this.probeInFlight = false;
|
|
8258
|
+
}
|
|
8259
|
+
// ---------- internal helpers ----------
|
|
8260
|
+
onSuccess() {
|
|
8261
|
+
this.consecutiveFailures = 0;
|
|
8262
|
+
this.currentState = "closed";
|
|
8263
|
+
}
|
|
8264
|
+
onFailure() {
|
|
8265
|
+
this.consecutiveFailures++;
|
|
8266
|
+
this.lastFailureTime = Date.now();
|
|
8267
|
+
if (this.currentState === "half-open") {
|
|
8268
|
+
this.currentState = "open";
|
|
8269
|
+
} else if (this.consecutiveFailures >= this.options.failureThreshold) {
|
|
8270
|
+
this.currentState = "open";
|
|
8271
|
+
}
|
|
8272
|
+
}
|
|
8273
|
+
};
|
|
8274
|
+
|
|
8275
|
+
// src/mcp/tool-executor.ts
|
|
8276
|
+
var log34 = createLogger("tool-executor");
|
|
8277
|
+
async function executeToolCall(tool, args, deps) {
|
|
8278
|
+
const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
|
|
8279
|
+
const correlationId = nanoid4();
|
|
8280
|
+
return withContext({ correlationId, tool: tool.name }, async () => {
|
|
8281
|
+
const startMs = Date.now();
|
|
8282
|
+
let journalId;
|
|
8283
|
+
try {
|
|
8284
|
+
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8285
|
+
if (tool.skipRouterContext) {
|
|
8286
|
+
const fleetContext = {
|
|
8287
|
+
routerClient: null,
|
|
8288
|
+
routerId: "",
|
|
8289
|
+
correlationId,
|
|
8290
|
+
routerConfig: null,
|
|
8291
|
+
sshClient: null,
|
|
8292
|
+
ftpClient: null,
|
|
8293
|
+
identity,
|
|
8294
|
+
routerRegistry: registry,
|
|
8295
|
+
connectionPool: pool,
|
|
8296
|
+
appConfig: config
|
|
8297
|
+
};
|
|
8298
|
+
const fleetResult = await tool.handler(args, fleetContext);
|
|
8299
|
+
recordToolCall(tool.name, "success");
|
|
8300
|
+
return formatToolResult(fleetResult);
|
|
8301
|
+
}
|
|
8302
|
+
const routerId = args.routerId;
|
|
8303
|
+
if (!routerId) {
|
|
8304
|
+
throw new MikroMCPError({
|
|
8305
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
8306
|
+
code: "MISSING_ROUTER_ID",
|
|
8307
|
+
message: "routerId is required",
|
|
8308
|
+
recoverability: {
|
|
8309
|
+
retryable: false,
|
|
8310
|
+
suggestedAction: "Provide a valid routerId parameter."
|
|
8311
|
+
}
|
|
8312
|
+
});
|
|
8313
|
+
}
|
|
8314
|
+
checkAuthz(identity, tool.name, routerId);
|
|
8315
|
+
const routerConfig = registry.getRouter(routerId);
|
|
8316
|
+
if (tool.annotations.destructiveHint) {
|
|
8317
|
+
const windows = routerConfig.maintenanceWindows;
|
|
8318
|
+
if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
|
|
8319
|
+
throw new MikroMCPError({
|
|
8320
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
8321
|
+
code: "OUTSIDE_MAINTENANCE_WINDOW",
|
|
8322
|
+
message: `Router "${routerId}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
|
|
8323
|
+
details: { maintenanceWindows: windows },
|
|
8324
|
+
recoverability: {
|
|
8325
|
+
retryable: true,
|
|
8326
|
+
suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
|
|
8327
|
+
}
|
|
8328
|
+
});
|
|
8329
|
+
}
|
|
8330
|
+
}
|
|
8331
|
+
if (tool.annotations.destructiveHint && config.confirmationSecret) {
|
|
8332
|
+
await checkConfirmation(tool.name, routerId, args, identity, config.confirmationSecret);
|
|
8333
|
+
}
|
|
8334
|
+
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
8335
|
+
if (shouldAudit) {
|
|
8336
|
+
auditLog({
|
|
8337
|
+
type: "audit",
|
|
8338
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8339
|
+
correlationId,
|
|
8340
|
+
identityId: identity.id,
|
|
8341
|
+
role: identity.role,
|
|
8342
|
+
tool: tool.name,
|
|
8343
|
+
routerId,
|
|
8344
|
+
phase: "attempt",
|
|
8345
|
+
params: args
|
|
8346
|
+
}, config.auditLogPath);
|
|
8347
|
+
}
|
|
8348
|
+
const toolContext = buildRouterToolContext({
|
|
8349
|
+
routerConfig,
|
|
8350
|
+
correlationId,
|
|
8351
|
+
identity,
|
|
8352
|
+
pool,
|
|
8353
|
+
config,
|
|
8354
|
+
registry
|
|
8355
|
+
});
|
|
8356
|
+
let cb = circuitBreakers.get(routerId);
|
|
8357
|
+
if (!cb) {
|
|
8358
|
+
cb = new CircuitBreaker(routerId, {
|
|
8359
|
+
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
8360
|
+
cooldownMs: config.circuitBreaker.cooldownMs
|
|
8361
|
+
});
|
|
8362
|
+
circuitBreakers.set(routerId, cb);
|
|
8363
|
+
}
|
|
8364
|
+
toolContext.circuitBreaker = cb;
|
|
8365
|
+
const { confirmationToken: _ct, ...handlerArgs } = args;
|
|
8366
|
+
const snapshotIds = [];
|
|
8367
|
+
if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
|
|
8368
|
+
for (const path of tool.snapshotPaths) {
|
|
8369
|
+
try {
|
|
8370
|
+
const meta = await takeSnapshot(toolContext.routerClient, routerId, path, config.snapshotDir);
|
|
8371
|
+
snapshotIds.push(meta.id);
|
|
8372
|
+
log34.debug({ snapshotId: meta.id, path }, "Snapshot taken");
|
|
8373
|
+
} catch (err) {
|
|
8374
|
+
log34.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
|
|
8375
|
+
}
|
|
8376
|
+
}
|
|
8377
|
+
}
|
|
8378
|
+
if (!tool.annotations.readOnlyHint && config.journalPath) {
|
|
8379
|
+
journalId = recordAttempt({
|
|
8380
|
+
journalPath: config.journalPath,
|
|
8381
|
+
identityId: identity.id,
|
|
8382
|
+
role: identity.role,
|
|
8383
|
+
tool: tool.name,
|
|
8384
|
+
routerId,
|
|
8385
|
+
params: handlerArgs,
|
|
8386
|
+
snapshotIds
|
|
8387
|
+
});
|
|
8388
|
+
}
|
|
8389
|
+
const runHandler = () => tool.handler(handlerArgs, toolContext);
|
|
8390
|
+
const executeHandler = () => cb.execute(
|
|
8391
|
+
tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
|
|
8392
|
+
);
|
|
8393
|
+
const result = await executeHandler();
|
|
8394
|
+
if (journalId) {
|
|
8395
|
+
recordOutcome({
|
|
8396
|
+
journalPath: config.journalPath,
|
|
8397
|
+
journalId,
|
|
8398
|
+
phase: "success",
|
|
8399
|
+
durationMs: Date.now() - startMs
|
|
8400
|
+
});
|
|
8401
|
+
}
|
|
8402
|
+
if (shouldAudit) {
|
|
8403
|
+
auditLog({
|
|
8404
|
+
type: "audit",
|
|
8405
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8406
|
+
correlationId,
|
|
8407
|
+
identityId: identity.id,
|
|
8408
|
+
role: identity.role,
|
|
8409
|
+
tool: tool.name,
|
|
8410
|
+
routerId,
|
|
8411
|
+
phase: "success",
|
|
8412
|
+
params: args,
|
|
8413
|
+
durationMs: Date.now() - startMs
|
|
8414
|
+
}, config.auditLogPath);
|
|
8415
|
+
}
|
|
8416
|
+
log34.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
|
|
8417
|
+
recordToolCall(tool.name, "success");
|
|
8418
|
+
return formatToolResult(result);
|
|
8419
|
+
} catch (err) {
|
|
8420
|
+
const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
|
|
8421
|
+
if (error.category === "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */) {
|
|
8422
|
+
const failedRouterId = args.routerId;
|
|
8423
|
+
if (failedRouterId) {
|
|
8424
|
+
pool.removeClient(failedRouterId);
|
|
8425
|
+
log34.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
|
|
8426
|
+
}
|
|
8427
|
+
}
|
|
8428
|
+
if (journalId) {
|
|
8429
|
+
recordOutcome({
|
|
8430
|
+
journalPath: config.journalPath,
|
|
8431
|
+
journalId,
|
|
8432
|
+
phase: "failure",
|
|
8433
|
+
outcome: error.code,
|
|
8434
|
+
durationMs: Date.now() - startMs
|
|
8435
|
+
});
|
|
8436
|
+
}
|
|
8437
|
+
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
8438
|
+
if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
|
|
8439
|
+
const routerId = args.routerId ?? "unknown";
|
|
8440
|
+
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8441
|
+
auditLog({
|
|
8442
|
+
type: "audit",
|
|
8443
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8444
|
+
correlationId,
|
|
8445
|
+
identityId: identity.id,
|
|
8446
|
+
role: identity.role,
|
|
8447
|
+
tool: tool.name,
|
|
8448
|
+
routerId,
|
|
8449
|
+
phase: "failure",
|
|
8450
|
+
params: args,
|
|
8451
|
+
outcome: error.code,
|
|
8452
|
+
durationMs: Date.now() - startMs
|
|
8453
|
+
}, config.auditLogPath);
|
|
8454
|
+
}
|
|
8455
|
+
log34.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
|
|
8456
|
+
recordToolCall(tool.name, "error");
|
|
8457
|
+
return formatError(error);
|
|
8458
|
+
}
|
|
8459
|
+
});
|
|
8460
|
+
}
|
|
8461
|
+
|
|
8061
8462
|
// src/mcp/tool-registry.ts
|
|
8062
|
-
var
|
|
8463
|
+
var log35 = createLogger("tool-registry");
|
|
8063
8464
|
function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
|
|
8465
|
+
const deps = { registry, pool, circuitBreakers, config, identityRegistry };
|
|
8064
8466
|
for (const tool of allTools) {
|
|
8065
8467
|
const registrationSchema = tool.annotations.destructiveHint ? tool.inputSchema.extend({
|
|
8066
8468
|
confirmationToken: z32.string().optional().describe(
|
|
@@ -8075,193 +8477,18 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
|
|
|
8075
8477
|
inputSchema: registrationSchema,
|
|
8076
8478
|
annotations: tool.annotations
|
|
8077
8479
|
},
|
|
8078
|
-
|
|
8079
|
-
const correlationId = nanoid4();
|
|
8080
|
-
return withContext({ correlationId, tool: tool.name }, async () => {
|
|
8081
|
-
const startMs = Date.now();
|
|
8082
|
-
let journalId;
|
|
8083
|
-
try {
|
|
8084
|
-
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8085
|
-
if (tool.skipRouterContext) {
|
|
8086
|
-
const fleetContext = {
|
|
8087
|
-
routerClient: null,
|
|
8088
|
-
routerId: "",
|
|
8089
|
-
correlationId,
|
|
8090
|
-
routerConfig: null,
|
|
8091
|
-
sshClient: null,
|
|
8092
|
-
ftpClient: null,
|
|
8093
|
-
identity,
|
|
8094
|
-
routerRegistry: registry,
|
|
8095
|
-
connectionPool: pool
|
|
8096
|
-
};
|
|
8097
|
-
return formatToolResult(await tool.handler(args, fleetContext));
|
|
8098
|
-
}
|
|
8099
|
-
const routerId = args.routerId;
|
|
8100
|
-
if (!routerId) {
|
|
8101
|
-
throw new MikroMCPError({
|
|
8102
|
-
category: "VALIDATION" /* VALIDATION */,
|
|
8103
|
-
code: "MISSING_ROUTER_ID",
|
|
8104
|
-
message: "routerId is required",
|
|
8105
|
-
recoverability: {
|
|
8106
|
-
retryable: false,
|
|
8107
|
-
suggestedAction: "Provide a valid routerId parameter."
|
|
8108
|
-
}
|
|
8109
|
-
});
|
|
8110
|
-
}
|
|
8111
|
-
checkAuthz(identity, tool.name, routerId);
|
|
8112
|
-
const routerConfig = registry.getRouter(routerId);
|
|
8113
|
-
if (tool.annotations.destructiveHint) {
|
|
8114
|
-
const windows = routerConfig.maintenanceWindows;
|
|
8115
|
-
if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
|
|
8116
|
-
throw new MikroMCPError({
|
|
8117
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
8118
|
-
code: "OUTSIDE_MAINTENANCE_WINDOW",
|
|
8119
|
-
message: `Router "${routerId}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
|
|
8120
|
-
details: { maintenanceWindows: windows },
|
|
8121
|
-
recoverability: {
|
|
8122
|
-
retryable: true,
|
|
8123
|
-
suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
|
|
8124
|
-
}
|
|
8125
|
-
});
|
|
8126
|
-
}
|
|
8127
|
-
}
|
|
8128
|
-
if (tool.annotations.destructiveHint && config.confirmationSecret) {
|
|
8129
|
-
await checkConfirmation(tool.name, routerId, args, identity, config.confirmationSecret);
|
|
8130
|
-
}
|
|
8131
|
-
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
8132
|
-
if (shouldAudit) {
|
|
8133
|
-
auditLog({
|
|
8134
|
-
type: "audit",
|
|
8135
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8136
|
-
correlationId,
|
|
8137
|
-
identityId: identity.id,
|
|
8138
|
-
role: identity.role,
|
|
8139
|
-
tool: tool.name,
|
|
8140
|
-
routerId,
|
|
8141
|
-
phase: "attempt",
|
|
8142
|
-
params: args
|
|
8143
|
-
});
|
|
8144
|
-
}
|
|
8145
|
-
const credentials = getCredentials(routerConfig);
|
|
8146
|
-
const client = pool.getClient(routerConfig, credentials);
|
|
8147
|
-
const sshClient = createSshClient(routerConfig, config.ssh);
|
|
8148
|
-
const ftpClient = createFtpClient(routerConfig);
|
|
8149
|
-
let cb = circuitBreakers.get(routerId);
|
|
8150
|
-
if (!cb) {
|
|
8151
|
-
cb = new CircuitBreaker(routerId, {
|
|
8152
|
-
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
8153
|
-
cooldownMs: config.circuitBreaker.cooldownMs
|
|
8154
|
-
});
|
|
8155
|
-
circuitBreakers.set(routerId, cb);
|
|
8156
|
-
}
|
|
8157
|
-
const { confirmationToken: _ct, ...handlerArgs } = args;
|
|
8158
|
-
const snapshotIds = [];
|
|
8159
|
-
if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
|
|
8160
|
-
for (const path of tool.snapshotPaths) {
|
|
8161
|
-
try {
|
|
8162
|
-
const meta = await takeSnapshot(client, routerId, path, config.snapshotDir);
|
|
8163
|
-
snapshotIds.push(meta.id);
|
|
8164
|
-
log34.debug({ snapshotId: meta.id, path }, "Snapshot taken");
|
|
8165
|
-
} catch (err) {
|
|
8166
|
-
log34.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
|
|
8167
|
-
}
|
|
8168
|
-
}
|
|
8169
|
-
}
|
|
8170
|
-
if (!tool.annotations.readOnlyHint && config.journalPath) {
|
|
8171
|
-
journalId = recordAttempt({
|
|
8172
|
-
journalPath: config.journalPath,
|
|
8173
|
-
identityId: identity.id,
|
|
8174
|
-
role: identity.role,
|
|
8175
|
-
tool: tool.name,
|
|
8176
|
-
routerId,
|
|
8177
|
-
params: handlerArgs,
|
|
8178
|
-
snapshotIds
|
|
8179
|
-
});
|
|
8180
|
-
}
|
|
8181
|
-
const runHandler = () => tool.handler(handlerArgs, {
|
|
8182
|
-
routerClient: client,
|
|
8183
|
-
routerId,
|
|
8184
|
-
correlationId,
|
|
8185
|
-
routerConfig,
|
|
8186
|
-
sshClient,
|
|
8187
|
-
ftpClient,
|
|
8188
|
-
identity,
|
|
8189
|
-
routerRegistry: registry,
|
|
8190
|
-
connectionPool: pool
|
|
8191
|
-
});
|
|
8192
|
-
const executeHandler = () => cb.execute(
|
|
8193
|
-
tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
|
|
8194
|
-
);
|
|
8195
|
-
const result = await executeHandler();
|
|
8196
|
-
if (journalId) {
|
|
8197
|
-
recordOutcome({
|
|
8198
|
-
journalPath: config.journalPath,
|
|
8199
|
-
journalId,
|
|
8200
|
-
phase: "success",
|
|
8201
|
-
durationMs: Date.now() - startMs
|
|
8202
|
-
});
|
|
8203
|
-
}
|
|
8204
|
-
if (shouldAudit) {
|
|
8205
|
-
auditLog({
|
|
8206
|
-
type: "audit",
|
|
8207
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8208
|
-
correlationId,
|
|
8209
|
-
identityId: identity.id,
|
|
8210
|
-
role: identity.role,
|
|
8211
|
-
tool: tool.name,
|
|
8212
|
-
routerId,
|
|
8213
|
-
phase: "success",
|
|
8214
|
-
params: args,
|
|
8215
|
-
durationMs: Date.now() - startMs
|
|
8216
|
-
});
|
|
8217
|
-
}
|
|
8218
|
-
log34.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
|
|
8219
|
-
return formatToolResult(result);
|
|
8220
|
-
} catch (err) {
|
|
8221
|
-
const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
|
|
8222
|
-
if (journalId) {
|
|
8223
|
-
recordOutcome({
|
|
8224
|
-
journalPath: config.journalPath,
|
|
8225
|
-
journalId,
|
|
8226
|
-
phase: "failure",
|
|
8227
|
-
outcome: error.code,
|
|
8228
|
-
durationMs: Date.now() - startMs
|
|
8229
|
-
});
|
|
8230
|
-
}
|
|
8231
|
-
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
8232
|
-
if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
|
|
8233
|
-
const routerId = args.routerId ?? "unknown";
|
|
8234
|
-
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8235
|
-
auditLog({
|
|
8236
|
-
type: "audit",
|
|
8237
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8238
|
-
correlationId,
|
|
8239
|
-
identityId: identity.id,
|
|
8240
|
-
role: identity.role,
|
|
8241
|
-
tool: tool.name,
|
|
8242
|
-
routerId,
|
|
8243
|
-
phase: "failure",
|
|
8244
|
-
params: args,
|
|
8245
|
-
outcome: error.code,
|
|
8246
|
-
durationMs: Date.now() - startMs
|
|
8247
|
-
});
|
|
8248
|
-
}
|
|
8249
|
-
log34.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
|
|
8250
|
-
return formatError(error);
|
|
8251
|
-
}
|
|
8252
|
-
});
|
|
8253
|
-
}
|
|
8480
|
+
(args) => executeToolCall(tool, args, deps)
|
|
8254
8481
|
);
|
|
8255
|
-
|
|
8482
|
+
log35.debug({ tool: tool.name }, "Registered tool");
|
|
8256
8483
|
}
|
|
8257
|
-
|
|
8484
|
+
log35.info({ count: allTools.length }, "All tools registered");
|
|
8258
8485
|
}
|
|
8259
8486
|
|
|
8260
8487
|
// src/config/router-registry.ts
|
|
8261
|
-
import { readFileSync as
|
|
8488
|
+
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
8262
8489
|
import { parse } from "yaml";
|
|
8263
8490
|
import { z as z33 } from "zod";
|
|
8264
|
-
var
|
|
8491
|
+
var log36 = createLogger("router-registry");
|
|
8265
8492
|
var RouterConfigSchema = z33.object({
|
|
8266
8493
|
host: z33.string().min(1, "host is required"),
|
|
8267
8494
|
port: z33.number().int().min(1).max(65535),
|
|
@@ -8301,10 +8528,10 @@ var RouterRegistry = class {
|
|
|
8301
8528
|
constructor(configPath) {
|
|
8302
8529
|
this.routers = /* @__PURE__ */ new Map();
|
|
8303
8530
|
if (!existsSync(configPath)) {
|
|
8304
|
-
|
|
8531
|
+
log36.warn({ configPath }, "Router config file not found; starting with empty registry");
|
|
8305
8532
|
return;
|
|
8306
8533
|
}
|
|
8307
|
-
const raw =
|
|
8534
|
+
const raw = readFileSync2(configPath, "utf-8");
|
|
8308
8535
|
const parsed = parse(raw);
|
|
8309
8536
|
const result = ConfigFileSchema.safeParse(parsed);
|
|
8310
8537
|
if (!result.success) {
|
|
@@ -8315,14 +8542,14 @@ ${issues}`);
|
|
|
8315
8542
|
for (const [id, config] of Object.entries(result.data.routers)) {
|
|
8316
8543
|
const routerConfig = { ...config, id };
|
|
8317
8544
|
if (routerConfig.tls.enabled && !routerConfig.tls.rejectUnauthorized) {
|
|
8318
|
-
|
|
8545
|
+
log36.warn(
|
|
8319
8546
|
{ routerId: id },
|
|
8320
8547
|
"TLS certificate validation is DISABLED (rejectUnauthorized=false). Set tls.fingerprint to pin the server certificate instead."
|
|
8321
8548
|
);
|
|
8322
8549
|
}
|
|
8323
8550
|
this.routers.set(id, routerConfig);
|
|
8324
8551
|
}
|
|
8325
|
-
|
|
8552
|
+
log36.info({ count: this.routers.size }, "Loaded routers from config");
|
|
8326
8553
|
}
|
|
8327
8554
|
getRouter(id) {
|
|
8328
8555
|
const router = this.routers.get(id);
|
|
@@ -8345,7 +8572,7 @@ ${issues}`);
|
|
|
8345
8572
|
import { request, Agent } from "undici";
|
|
8346
8573
|
|
|
8347
8574
|
// src/adapter/tls-manager.ts
|
|
8348
|
-
import { readFileSync as
|
|
8575
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
8349
8576
|
function buildAgentOptions(tls) {
|
|
8350
8577
|
if (!tls.enabled) {
|
|
8351
8578
|
return {};
|
|
@@ -8354,7 +8581,7 @@ function buildAgentOptions(tls) {
|
|
|
8354
8581
|
rejectUnauthorized: tls.rejectUnauthorized
|
|
8355
8582
|
};
|
|
8356
8583
|
if (tls.ca) {
|
|
8357
|
-
connectOptions.ca =
|
|
8584
|
+
connectOptions.ca = readFileSync3(tls.ca, "utf-8");
|
|
8358
8585
|
}
|
|
8359
8586
|
if (tls.fingerprint) {
|
|
8360
8587
|
const expected = tls.fingerprint.replace(/:/g, "").toLowerCase();
|
|
@@ -8376,8 +8603,8 @@ function buildAgentOptions(tls) {
|
|
|
8376
8603
|
// src/adapter/response-parser.ts
|
|
8377
8604
|
var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
8378
8605
|
var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
|
|
8379
|
-
function parseRouterOSValue(
|
|
8380
|
-
if (
|
|
8606
|
+
function parseRouterOSValue(key2, value) {
|
|
8607
|
+
if (key2 === ".id") {
|
|
8381
8608
|
return value;
|
|
8382
8609
|
}
|
|
8383
8610
|
if (value === "true") return true;
|
|
@@ -8395,8 +8622,8 @@ function parseRouterOSValue(key, value) {
|
|
|
8395
8622
|
}
|
|
8396
8623
|
function parseRecord(raw) {
|
|
8397
8624
|
const parsed = {};
|
|
8398
|
-
for (const [
|
|
8399
|
-
parsed[
|
|
8625
|
+
for (const [key2, value] of Object.entries(raw)) {
|
|
8626
|
+
parsed[key2] = parseRouterOSValue(key2, value);
|
|
8400
8627
|
}
|
|
8401
8628
|
return parsed;
|
|
8402
8629
|
}
|
|
@@ -8418,8 +8645,8 @@ function buildListQuery(options) {
|
|
|
8418
8645
|
}
|
|
8419
8646
|
if (hasFilter) {
|
|
8420
8647
|
const queryParts = [];
|
|
8421
|
-
for (const [
|
|
8422
|
-
queryParts.push(`${
|
|
8648
|
+
for (const [key2, value] of Object.entries(options.filter)) {
|
|
8649
|
+
queryParts.push(`${key2}=${value}`);
|
|
8423
8650
|
}
|
|
8424
8651
|
body[".query"] = queryParts;
|
|
8425
8652
|
}
|
|
@@ -8427,8 +8654,8 @@ function buildListQuery(options) {
|
|
|
8427
8654
|
}
|
|
8428
8655
|
if (hasFilter) {
|
|
8429
8656
|
const queryParams = {};
|
|
8430
|
-
for (const [
|
|
8431
|
-
queryParams[
|
|
8657
|
+
for (const [key2, value] of Object.entries(options.filter)) {
|
|
8658
|
+
queryParams[key2] = value;
|
|
8432
8659
|
}
|
|
8433
8660
|
return { method: "GET", queryParams };
|
|
8434
8661
|
}
|
|
@@ -8606,6 +8833,9 @@ var ConnectionPool = class {
|
|
|
8606
8833
|
}
|
|
8607
8834
|
};
|
|
8608
8835
|
|
|
8836
|
+
// src/version.ts
|
|
8837
|
+
var VERSION = "1.1.0";
|
|
8838
|
+
|
|
8609
8839
|
// src/mcp/server.ts
|
|
8610
8840
|
function createServerFactory(config, identityRegistry) {
|
|
8611
8841
|
const registry = new RouterRegistry(config.configPath);
|
|
@@ -8614,7 +8844,7 @@ function createServerFactory(config, identityRegistry) {
|
|
|
8614
8844
|
const makeServer = () => {
|
|
8615
8845
|
const server = new McpServer({
|
|
8616
8846
|
name: "mikromcp",
|
|
8617
|
-
version:
|
|
8847
|
+
version: VERSION
|
|
8618
8848
|
});
|
|
8619
8849
|
registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry);
|
|
8620
8850
|
return server;
|
|
@@ -8634,17 +8864,17 @@ import { randomUUID } from "crypto";
|
|
|
8634
8864
|
import { createServer } from "http";
|
|
8635
8865
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8636
8866
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
8637
|
-
var
|
|
8867
|
+
var log37 = createLogger("transport-http");
|
|
8638
8868
|
var BodyTooLargeError = class extends Error {
|
|
8639
8869
|
constructor() {
|
|
8640
8870
|
super("Request body exceeds maximum allowed size");
|
|
8641
8871
|
}
|
|
8642
8872
|
};
|
|
8643
8873
|
function createRateLimiter(rpm) {
|
|
8644
|
-
if (rpm === 0) return () => true;
|
|
8645
8874
|
const WINDOW_MS = 6e4;
|
|
8646
8875
|
const windows = /* @__PURE__ */ new Map();
|
|
8647
|
-
|
|
8876
|
+
const check = (ip) => {
|
|
8877
|
+
if (rpm === 0) return true;
|
|
8648
8878
|
const now = Date.now();
|
|
8649
8879
|
const entry = windows.get(ip);
|
|
8650
8880
|
if (!entry || now - entry.windowStart >= WINDOW_MS) {
|
|
@@ -8657,6 +8887,17 @@ function createRateLimiter(rpm) {
|
|
|
8657
8887
|
entry.count++;
|
|
8658
8888
|
return true;
|
|
8659
8889
|
};
|
|
8890
|
+
const limiter = check;
|
|
8891
|
+
limiter.sweep = () => {
|
|
8892
|
+
const now = Date.now();
|
|
8893
|
+
for (const [ip, entry] of windows) {
|
|
8894
|
+
if (now - entry.windowStart >= WINDOW_MS) {
|
|
8895
|
+
windows.delete(ip);
|
|
8896
|
+
}
|
|
8897
|
+
}
|
|
8898
|
+
};
|
|
8899
|
+
limiter.size = () => windows.size;
|
|
8900
|
+
return limiter;
|
|
8660
8901
|
}
|
|
8661
8902
|
async function readBody(req, maxBytes) {
|
|
8662
8903
|
return new Promise((resolve, reject) => {
|
|
@@ -8696,18 +8937,30 @@ async function readBody(req, maxBytes) {
|
|
|
8696
8937
|
async function connectHttp(makeServer, config, identityRegistry) {
|
|
8697
8938
|
const { port, bindHost, maxBodyBytes, rateLimitRpm } = config;
|
|
8698
8939
|
const checkRateLimit = createRateLimiter(rateLimitRpm);
|
|
8940
|
+
const sweepTimer = setInterval(() => checkRateLimit.sweep(), 6e4);
|
|
8941
|
+
sweepTimer.unref();
|
|
8699
8942
|
const streamableSessions = /* @__PURE__ */ new Map();
|
|
8700
8943
|
const sseTransports = /* @__PURE__ */ new Map();
|
|
8701
8944
|
const httpServer = createServer(async (req, res) => {
|
|
8702
8945
|
const ip = req.socket.remoteAddress ?? "unknown";
|
|
8946
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
8947
|
+
const { pathname } = url;
|
|
8948
|
+
if (pathname === "/healthz") {
|
|
8949
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
8950
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
8951
|
+
return;
|
|
8952
|
+
}
|
|
8953
|
+
if (pathname === "/metrics") {
|
|
8954
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
8955
|
+
res.end(renderPrometheus());
|
|
8956
|
+
return;
|
|
8957
|
+
}
|
|
8703
8958
|
if (!checkRateLimit(ip)) {
|
|
8704
|
-
|
|
8959
|
+
log37.warn({ ip }, "Rate limit exceeded");
|
|
8705
8960
|
res.writeHead(429, { "Content-Type": "application/json" });
|
|
8706
8961
|
res.end(JSON.stringify({ error: "Too many requests. Retry after 60 seconds." }));
|
|
8707
8962
|
return;
|
|
8708
8963
|
}
|
|
8709
|
-
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
8710
|
-
const { pathname } = url;
|
|
8711
8964
|
try {
|
|
8712
8965
|
if (pathname === "/mcp" || pathname === "/sse" || pathname === "/messages") {
|
|
8713
8966
|
let identity;
|
|
@@ -8744,16 +8997,17 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
8744
8997
|
}
|
|
8745
8998
|
return;
|
|
8746
8999
|
}
|
|
8747
|
-
|
|
9000
|
+
log37.error({ err }, "HTTP transport error");
|
|
8748
9001
|
if (!res.headersSent) {
|
|
8749
9002
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
8750
9003
|
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
8751
9004
|
}
|
|
8752
9005
|
}
|
|
8753
9006
|
});
|
|
9007
|
+
httpServer.on("close", () => clearInterval(sweepTimer));
|
|
8754
9008
|
await new Promise((resolve, reject) => {
|
|
8755
9009
|
httpServer.listen(port, bindHost, () => {
|
|
8756
|
-
|
|
9010
|
+
log37.info(
|
|
8757
9011
|
{ port, bindHost },
|
|
8758
9012
|
"HTTP transport listening (POST /mcp for Streamable HTTP, GET /sse for legacy SSE)"
|
|
8759
9013
|
);
|
|
@@ -8761,6 +9015,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
8761
9015
|
});
|
|
8762
9016
|
httpServer.on("error", reject);
|
|
8763
9017
|
});
|
|
9018
|
+
return httpServer;
|
|
8764
9019
|
}
|
|
8765
9020
|
async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports) {
|
|
8766
9021
|
if (pathname === "/mcp") {
|
|
@@ -8776,13 +9031,13 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
8776
9031
|
sessionIdGenerator: () => randomUUID(),
|
|
8777
9032
|
onsessioninitialized: (id) => {
|
|
8778
9033
|
streamableSessions.set(id, transport2);
|
|
8779
|
-
|
|
9034
|
+
log37.info({ sessionId: id }, "Streamable HTTP session created");
|
|
8780
9035
|
}
|
|
8781
9036
|
});
|
|
8782
9037
|
transport2.onclose = () => {
|
|
8783
9038
|
if (transport2.sessionId) {
|
|
8784
9039
|
streamableSessions.delete(transport2.sessionId);
|
|
8785
|
-
|
|
9040
|
+
log37.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
|
|
8786
9041
|
}
|
|
8787
9042
|
};
|
|
8788
9043
|
await makeServer().connect(transport2);
|
|
@@ -8799,7 +9054,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
8799
9054
|
sseTransport.onclose = () => {
|
|
8800
9055
|
sseTransports.delete(sseTransport.sessionId);
|
|
8801
9056
|
};
|
|
8802
|
-
|
|
9057
|
+
log37.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
|
|
8803
9058
|
await sseTransport.start();
|
|
8804
9059
|
return;
|
|
8805
9060
|
}
|
|
@@ -8818,11 +9073,11 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
8818
9073
|
}
|
|
8819
9074
|
|
|
8820
9075
|
// src/config/identity-registry.ts
|
|
8821
|
-
import { readFileSync as
|
|
9076
|
+
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
|
|
8822
9077
|
import { parse as parse2 } from "yaml";
|
|
8823
9078
|
import { z as z34 } from "zod";
|
|
8824
9079
|
import bcrypt from "bcryptjs";
|
|
8825
|
-
var
|
|
9080
|
+
var log38 = createLogger("identity-registry");
|
|
8826
9081
|
var IdentityConfigSchema = z34.object({
|
|
8827
9082
|
token: z34.string().min(1, "token hash is required"),
|
|
8828
9083
|
role: z34.enum(["readonly", "operator", "admin", "superadmin"]),
|
|
@@ -8836,13 +9091,13 @@ var IdentityRegistry = class {
|
|
|
8836
9091
|
identities = [];
|
|
8837
9092
|
constructor(configPath) {
|
|
8838
9093
|
if (!existsSync2(configPath)) {
|
|
8839
|
-
|
|
9094
|
+
log38.warn(
|
|
8840
9095
|
{ configPath },
|
|
8841
9096
|
"Identity config file not found; starting with empty registry (stdio callers do not require it)"
|
|
8842
9097
|
);
|
|
8843
9098
|
return;
|
|
8844
9099
|
}
|
|
8845
|
-
const raw =
|
|
9100
|
+
const raw = readFileSync4(configPath, "utf-8");
|
|
8846
9101
|
const parsed = parse2(raw);
|
|
8847
9102
|
const result = ConfigFileSchema2.safeParse(parsed);
|
|
8848
9103
|
if (!result.success) {
|
|
@@ -8859,7 +9114,7 @@ ${issues}`);
|
|
|
8859
9114
|
allowedToolPatterns: config.allowedToolPatterns
|
|
8860
9115
|
});
|
|
8861
9116
|
}
|
|
8862
|
-
|
|
9117
|
+
log38.info({ count: this.identities.length }, "Loaded identities from config");
|
|
8863
9118
|
}
|
|
8864
9119
|
getIdentities() {
|
|
8865
9120
|
return this.identities.map(({ id, role, allowedRouters, allowedToolPatterns }) => ({
|
|
@@ -8885,18 +9140,59 @@ ${issues}`);
|
|
|
8885
9140
|
}
|
|
8886
9141
|
};
|
|
8887
9142
|
|
|
9143
|
+
// src/domain/snapshot/retention.ts
|
|
9144
|
+
import { readdir, stat, unlink } from "fs/promises";
|
|
9145
|
+
import { join as join3 } from "path";
|
|
9146
|
+
var log39 = createLogger("snapshot-retention");
|
|
9147
|
+
async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
9148
|
+
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
9149
|
+
let removed = 0;
|
|
9150
|
+
let routerDirs;
|
|
9151
|
+
try {
|
|
9152
|
+
routerDirs = await readdir(snapshotDir);
|
|
9153
|
+
} catch {
|
|
9154
|
+
return 0;
|
|
9155
|
+
}
|
|
9156
|
+
for (const routerDir of routerDirs) {
|
|
9157
|
+
const dirPath = join3(snapshotDir, routerDir);
|
|
9158
|
+
let files;
|
|
9159
|
+
try {
|
|
9160
|
+
files = await readdir(dirPath);
|
|
9161
|
+
} catch {
|
|
9162
|
+
continue;
|
|
9163
|
+
}
|
|
9164
|
+
for (const file of files) {
|
|
9165
|
+
const filePath = join3(dirPath, file);
|
|
9166
|
+
try {
|
|
9167
|
+
const info = await stat(filePath);
|
|
9168
|
+
if (info.isFile() && info.mtimeMs < cutoffMs) {
|
|
9169
|
+
await unlink(filePath);
|
|
9170
|
+
removed++;
|
|
9171
|
+
}
|
|
9172
|
+
} catch (err) {
|
|
9173
|
+
log39.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
|
|
9174
|
+
}
|
|
9175
|
+
}
|
|
9176
|
+
}
|
|
9177
|
+
if (removed > 0) {
|
|
9178
|
+
log39.info({ removed, maxAgeDays }, "Pruned expired snapshots");
|
|
9179
|
+
}
|
|
9180
|
+
return removed;
|
|
9181
|
+
}
|
|
9182
|
+
|
|
8888
9183
|
// src/cli/serve.ts
|
|
8889
|
-
var
|
|
9184
|
+
var log40 = createLogger("main");
|
|
8890
9185
|
async function runServe() {
|
|
8891
9186
|
const config = loadAppConfig();
|
|
8892
|
-
|
|
9187
|
+
log40.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
|
|
9188
|
+
void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
|
|
8893
9189
|
const identityRegistry = new IdentityRegistry(config.identitiesPath);
|
|
8894
9190
|
if (config.transport === "http") {
|
|
8895
9191
|
const hasLimitedRoles = identityRegistry.getIdentities().some(
|
|
8896
9192
|
(i) => i.role === "readonly" || i.role === "operator"
|
|
8897
9193
|
);
|
|
8898
9194
|
if (hasLimitedRoles && !config.confirmationSecret) {
|
|
8899
|
-
|
|
9195
|
+
log40.error(
|
|
8900
9196
|
"MIKROMCP_CONFIRMATION_SECRET is required in HTTP mode when identities with role readonly or operator are configured"
|
|
8901
9197
|
);
|
|
8902
9198
|
process.exit(1);
|
|
@@ -8906,7 +9202,7 @@ async function runServe() {
|
|
|
8906
9202
|
if (config.transport === "stdio") {
|
|
8907
9203
|
const stdioIdentity = getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8908
9204
|
await withIdentity(stdioIdentity, () => connectStdio(makeServer()));
|
|
8909
|
-
|
|
9205
|
+
log40.info("MikroMCP server running via stdio");
|
|
8910
9206
|
} else if (config.transport === "http") {
|
|
8911
9207
|
await connectHttp(
|
|
8912
9208
|
makeServer,
|
|
@@ -8918,13 +9214,13 @@ async function runServe() {
|
|
|
8918
9214
|
},
|
|
8919
9215
|
identityRegistry
|
|
8920
9216
|
);
|
|
8921
|
-
|
|
9217
|
+
log40.info(
|
|
8922
9218
|
{ port: config.port, bindHost: config.bindHost },
|
|
8923
9219
|
"MikroMCP server running via HTTP/SSE"
|
|
8924
9220
|
);
|
|
8925
9221
|
}
|
|
8926
9222
|
const shutdown = () => {
|
|
8927
|
-
|
|
9223
|
+
log40.info("Shutting down MikroMCP server");
|
|
8928
9224
|
pool.closeAll();
|
|
8929
9225
|
process.exit(0);
|
|
8930
9226
|
};
|
|
@@ -8933,11 +9229,11 @@ async function runServe() {
|
|
|
8933
9229
|
}
|
|
8934
9230
|
|
|
8935
9231
|
// src/cli/doctor.ts
|
|
8936
|
-
import { existsSync as existsSync3, readFileSync as
|
|
8937
|
-
import { homedir as
|
|
9232
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
9233
|
+
import { homedir as homedir2 } from "os";
|
|
8938
9234
|
import * as net from "net";
|
|
8939
9235
|
import chalk from "chalk";
|
|
8940
|
-
var localVersion = "1.0
|
|
9236
|
+
var localVersion = "1.1.0";
|
|
8941
9237
|
var results = [];
|
|
8942
9238
|
function ok(msg) {
|
|
8943
9239
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -9117,8 +9413,8 @@ function checkIdentities(identitiesPath, transport2) {
|
|
|
9117
9413
|
}
|
|
9118
9414
|
function checkClaudeDesktop() {
|
|
9119
9415
|
const candidatePaths = [
|
|
9120
|
-
`${
|
|
9121
|
-
`${
|
|
9416
|
+
`${homedir2()}/Library/Application Support/Claude/claude_desktop_config.json`,
|
|
9417
|
+
`${homedir2()}/.config/Claude/claude_desktop_config.json`
|
|
9122
9418
|
];
|
|
9123
9419
|
const configPath = candidatePaths.find((p) => existsSync3(p));
|
|
9124
9420
|
if (!configPath) {
|
|
@@ -9126,7 +9422,7 @@ function checkClaudeDesktop() {
|
|
|
9126
9422
|
return;
|
|
9127
9423
|
}
|
|
9128
9424
|
try {
|
|
9129
|
-
const raw =
|
|
9425
|
+
const raw = readFileSync5(configPath, "utf-8");
|
|
9130
9426
|
const config = JSON.parse(raw);
|
|
9131
9427
|
const servers = config.mcpServers ?? {};
|
|
9132
9428
|
if ("mikromcp" in servers || "mikrotik-mcp-server" in servers) {
|
|
@@ -9211,18 +9507,18 @@ import { input, confirm, select } from "@inquirer/prompts";
|
|
|
9211
9507
|
import { randomBytes } from "crypto";
|
|
9212
9508
|
import {
|
|
9213
9509
|
existsSync as existsSync4,
|
|
9214
|
-
readFileSync as
|
|
9215
|
-
writeFileSync
|
|
9216
|
-
mkdirSync
|
|
9510
|
+
readFileSync as readFileSync6,
|
|
9511
|
+
writeFileSync,
|
|
9512
|
+
mkdirSync,
|
|
9217
9513
|
copyFileSync
|
|
9218
9514
|
} from "fs";
|
|
9219
|
-
import { homedir as
|
|
9515
|
+
import { homedir as homedir3 } from "os";
|
|
9220
9516
|
import { join as join4 } from "path";
|
|
9221
9517
|
import { parse as yamlParse, Document, visit } from "yaml";
|
|
9222
9518
|
import bcrypt2 from "bcryptjs";
|
|
9223
9519
|
import chalk2 from "chalk";
|
|
9224
9520
|
function mikromcpDir2() {
|
|
9225
|
-
return join4(
|
|
9521
|
+
return join4(homedir3(), ".mikromcp");
|
|
9226
9522
|
}
|
|
9227
9523
|
function defaultAuditLogPath() {
|
|
9228
9524
|
return join4(mikromcpDir2(), "audit.ndjson");
|
|
@@ -9230,8 +9526,8 @@ function defaultAuditLogPath() {
|
|
|
9230
9526
|
function toYaml(obj) {
|
|
9231
9527
|
const doc = new Document(obj);
|
|
9232
9528
|
visit(doc, {
|
|
9233
|
-
Scalar(
|
|
9234
|
-
if (
|
|
9529
|
+
Scalar(key2, node) {
|
|
9530
|
+
if (key2 !== "key" && typeof node.value === "string") {
|
|
9235
9531
|
node.type = "QUOTE_DOUBLE";
|
|
9236
9532
|
}
|
|
9237
9533
|
},
|
|
@@ -9253,8 +9549,8 @@ function splitTags(val) {
|
|
|
9253
9549
|
}
|
|
9254
9550
|
function findClaudeDesktopConfig() {
|
|
9255
9551
|
const candidates = [
|
|
9256
|
-
join4(
|
|
9257
|
-
join4(
|
|
9552
|
+
join4(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
9553
|
+
join4(homedir3(), ".config", "Claude", "claude_desktop_config.json")
|
|
9258
9554
|
];
|
|
9259
9555
|
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
9260
9556
|
}
|
|
@@ -9419,13 +9715,13 @@ function writeRoutersYaml(data) {
|
|
|
9419
9715
|
};
|
|
9420
9716
|
let parsed = { routers: {} };
|
|
9421
9717
|
if (existsSync4(filePath)) {
|
|
9422
|
-
const raw =
|
|
9718
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
9423
9719
|
parsed = yamlParse(raw) ?? { routers: {} };
|
|
9424
9720
|
if (!parsed.routers) parsed.routers = {};
|
|
9425
9721
|
}
|
|
9426
9722
|
parsed.routers[data.routerId] = newEntry;
|
|
9427
|
-
|
|
9428
|
-
|
|
9723
|
+
mkdirSync(data.configDir, { recursive: true });
|
|
9724
|
+
writeFileSync(filePath, toYaml(parsed));
|
|
9429
9725
|
return true;
|
|
9430
9726
|
}
|
|
9431
9727
|
function writeIdentitiesYaml(data) {
|
|
@@ -9433,18 +9729,18 @@ function writeIdentitiesYaml(data) {
|
|
|
9433
9729
|
const newEntry = {
|
|
9434
9730
|
token: data.tokenHash,
|
|
9435
9731
|
role: data.role,
|
|
9436
|
-
allowedRouters: data.allowedRouters === "*" ? [
|
|
9437
|
-
allowedToolPatterns: data.allowedToolPatterns === "*" ? [
|
|
9732
|
+
allowedRouters: data.allowedRouters === "*" ? [] : data.allowedRouters.split(",").map((s) => s.trim()),
|
|
9733
|
+
allowedToolPatterns: data.allowedToolPatterns === "*" ? [] : data.allowedToolPatterns.split(",").map((s) => s.trim())
|
|
9438
9734
|
};
|
|
9439
9735
|
let parsed = { identities: {} };
|
|
9440
9736
|
if (existsSync4(filePath)) {
|
|
9441
|
-
const raw =
|
|
9737
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
9442
9738
|
parsed = yamlParse(raw) ?? { identities: {} };
|
|
9443
9739
|
if (!parsed.identities) parsed.identities = {};
|
|
9444
9740
|
}
|
|
9445
9741
|
parsed.identities[data.identityId] = newEntry;
|
|
9446
|
-
|
|
9447
|
-
|
|
9742
|
+
mkdirSync(data.configDir, { recursive: true });
|
|
9743
|
+
writeFileSync(filePath, toYaml(parsed));
|
|
9448
9744
|
return true;
|
|
9449
9745
|
}
|
|
9450
9746
|
function writeDotEnv(data) {
|
|
@@ -9478,8 +9774,8 @@ function writeDotEnv(data) {
|
|
|
9478
9774
|
"# \u2500\u2500 RBAC (optional, for stdio with identity enforcement) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9479
9775
|
"# MIKROMCP_STDIO_IDENTITY="
|
|
9480
9776
|
];
|
|
9481
|
-
|
|
9482
|
-
|
|
9777
|
+
mkdirSync(data.configDir, { recursive: true });
|
|
9778
|
+
writeFileSync(data.envPath, lines.join("\n") + "\n");
|
|
9483
9779
|
}
|
|
9484
9780
|
function registerClaudeDesktop() {
|
|
9485
9781
|
const configPath = findClaudeDesktopConfig();
|
|
@@ -9500,13 +9796,13 @@ function registerClaudeDesktop() {
|
|
|
9500
9796
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
9501
9797
|
const backupPath = `${configPath}.backup-${timestamp}`;
|
|
9502
9798
|
copyFileSync(configPath, backupPath);
|
|
9503
|
-
const raw =
|
|
9799
|
+
const raw = readFileSync6(configPath, "utf-8");
|
|
9504
9800
|
const config = JSON.parse(raw);
|
|
9505
9801
|
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
9506
9802
|
config.mcpServers = {};
|
|
9507
9803
|
}
|
|
9508
9804
|
config.mcpServers["mikromcp"] = entry;
|
|
9509
|
-
|
|
9805
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
9510
9806
|
console.log(chalk2.dim(` Backed up to ${backupPath}`));
|
|
9511
9807
|
console.log(chalk2.dim(" Restart Claude to apply."));
|
|
9512
9808
|
return { registered: true };
|
|
@@ -9589,7 +9885,7 @@ async function runInit() {
|
|
|
9589
9885
|
}
|
|
9590
9886
|
|
|
9591
9887
|
// src/cli/index.ts
|
|
9592
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.0
|
|
9888
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.1.0");
|
|
9593
9889
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
9594
9890
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
9595
9891
|
program.command("init").description("Interactive setup wizard").action(runInit);
|
|
@@ -9599,6 +9895,6 @@ program.command("update").description("Update mikromcp to the latest version via
|
|
|
9599
9895
|
});
|
|
9600
9896
|
|
|
9601
9897
|
// src/main.ts
|
|
9602
|
-
loadDotenv({ path: join5(
|
|
9898
|
+
loadDotenv({ path: join5(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
|
|
9603
9899
|
program.parse();
|
|
9604
9900
|
//# sourceMappingURL=main.js.map
|