mikromcp 1.0.10 → 1.2.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 +15 -8
- package/dist/main.js +1759 -946
- package/package.json +2 -1
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
|
}
|
|
@@ -61,7 +60,7 @@ function loadAppConfig() {
|
|
|
61
60
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
62
61
|
|
|
63
62
|
// src/mcp/tool-registry.ts
|
|
64
|
-
import { z as
|
|
63
|
+
import { z as z36 } from "zod";
|
|
65
64
|
|
|
66
65
|
// src/domain/tools/system-tools.ts
|
|
67
66
|
import { z } from "zod";
|
|
@@ -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) {
|
|
@@ -447,7 +446,12 @@ var COUNTER_PROPS = [
|
|
|
447
446
|
"rx-drop",
|
|
448
447
|
"tx-error",
|
|
449
448
|
"rx-error",
|
|
450
|
-
"tx-queue-drop"
|
|
449
|
+
"tx-queue-drop",
|
|
450
|
+
"fp-tx-byte",
|
|
451
|
+
"fp-rx-byte",
|
|
452
|
+
"fp-tx-packet",
|
|
453
|
+
"fp-rx-packet",
|
|
454
|
+
"fp-rps-drop"
|
|
451
455
|
];
|
|
452
456
|
var listInterfacesTool = {
|
|
453
457
|
name: "list_interfaces",
|
|
@@ -491,14 +495,17 @@ var listInterfacesTool = {
|
|
|
491
495
|
}
|
|
492
496
|
const total = interfaces.length;
|
|
493
497
|
const paginated = interfaces.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
494
|
-
const results2 =
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
498
|
+
const results2 = paginated.map((iface) => {
|
|
499
|
+
const rec = iface;
|
|
500
|
+
const isRunning = rec.running === true || rec.running === "true";
|
|
501
|
+
const isDisabled = rec.disabled === true || rec.disabled === "true";
|
|
502
|
+
const enriched = { status: isDisabled ? "disabled" : isRunning ? "up" : "down" };
|
|
503
|
+
for (const [key2, value] of Object.entries(rec)) {
|
|
504
|
+
if (parsed.includeCounters || !COUNTER_PROPS.includes(key2)) {
|
|
505
|
+
enriched[key2] = value;
|
|
499
506
|
}
|
|
500
507
|
}
|
|
501
|
-
return
|
|
508
|
+
return enriched;
|
|
502
509
|
});
|
|
503
510
|
const hasMore = parsed.offset + parsed.limit < total;
|
|
504
511
|
const lines = [
|
|
@@ -508,8 +515,8 @@ var listInterfacesTool = {
|
|
|
508
515
|
const rec = iface;
|
|
509
516
|
const name = rec.name ?? rec[".id"] ?? "unknown";
|
|
510
517
|
const type = rec.type ?? parsed.type;
|
|
511
|
-
const
|
|
512
|
-
lines.push(` ${name} [${type}] ${
|
|
518
|
+
const status = String(rec.status).toUpperCase();
|
|
519
|
+
lines.push(` ${name} [${type}] ${status}`);
|
|
513
520
|
}
|
|
514
521
|
return {
|
|
515
522
|
content: lines.join("\n"),
|
|
@@ -527,115 +534,7 @@ var listInterfacesTool = {
|
|
|
527
534
|
}
|
|
528
535
|
}
|
|
529
536
|
};
|
|
530
|
-
var
|
|
531
|
-
routerId: z2.string().describe("Target router identifier from the router registry"),
|
|
532
|
-
name: z2.string().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe("VLAN interface name (alphanumeric, hyphens, underscores, max 15 chars)"),
|
|
533
|
-
vlanId: z2.number().int().min(1).max(4094).describe("VLAN ID (1-4094)"),
|
|
534
|
-
parentInterface: z2.string().describe("Parent interface name (e.g., ether1, bridge1)"),
|
|
535
|
-
mtu: z2.number().int().min(68).max(9e3).default(1500).describe("MTU size"),
|
|
536
|
-
disabled: z2.boolean().default(false).describe("Whether the VLAN interface should be disabled"),
|
|
537
|
-
comment: z2.string().max(255).optional().describe("Optional comment for the VLAN interface"),
|
|
538
|
-
dryRun: z2.boolean().default(false).describe("If true, validate and return planned changes without applying")
|
|
539
|
-
}).strict();
|
|
540
|
-
function sanitizeComment(comment) {
|
|
541
|
-
if (comment === void 0) return void 0;
|
|
542
|
-
return comment.replace(/[\x00-\x1f\x7f]/g, "");
|
|
543
|
-
}
|
|
544
|
-
var createVlanTool = {
|
|
545
|
-
name: "create_vlan",
|
|
546
|
-
title: "Create VLAN Interface",
|
|
547
|
-
description: "Create a new VLAN interface on a MikroTik router. Performs idempotency checks: if a VLAN with the same name already exists with matching configuration, returns success without changes. Supports dry-run mode.",
|
|
548
|
-
inputSchema: createVlanInputSchema,
|
|
549
|
-
annotations: {
|
|
550
|
-
readOnlyHint: false,
|
|
551
|
-
destructiveHint: false,
|
|
552
|
-
idempotentHint: true,
|
|
553
|
-
openWorldHint: false
|
|
554
|
-
},
|
|
555
|
-
async handler(params, context) {
|
|
556
|
-
const parsed = createVlanInputSchema.parse(params);
|
|
557
|
-
const comment = sanitizeComment(parsed.comment);
|
|
558
|
-
log2.info(
|
|
559
|
-
{ routerId: context.routerId, name: parsed.name, vlanId: parsed.vlanId },
|
|
560
|
-
"Creating VLAN interface"
|
|
561
|
-
);
|
|
562
|
-
try {
|
|
563
|
-
const existing = await context.routerClient.get("interface/vlan", {
|
|
564
|
-
filter: { name: parsed.name }
|
|
565
|
-
});
|
|
566
|
-
if (existing.length > 0) {
|
|
567
|
-
const found = existing[0];
|
|
568
|
-
const existingVlanId = found["vlan-id"];
|
|
569
|
-
const existingInterface = found.interface;
|
|
570
|
-
if (existingVlanId === String(parsed.vlanId) && existingInterface === parsed.parentInterface) {
|
|
571
|
-
log2.info({ name: parsed.name }, "VLAN already exists with matching config");
|
|
572
|
-
return {
|
|
573
|
-
content: `VLAN "${parsed.name}" already exists with VLAN ID ${parsed.vlanId} on ${parsed.parentInterface}. No changes made.`,
|
|
574
|
-
structuredContent: {
|
|
575
|
-
action: "already_exists",
|
|
576
|
-
vlan: found
|
|
577
|
-
}
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
throw new MikroMCPError({
|
|
581
|
-
category: "CONFLICT" /* CONFLICT */,
|
|
582
|
-
code: "VLAN_NAME_CONFLICT",
|
|
583
|
-
message: `VLAN "${parsed.name}" already exists but with different configuration (vlan-id=${existingVlanId}, interface=${existingInterface}). Expected vlan-id=${parsed.vlanId}, interface=${parsed.parentInterface}.`,
|
|
584
|
-
details: {
|
|
585
|
-
existingVlanId,
|
|
586
|
-
existingInterface,
|
|
587
|
-
requestedVlanId: parsed.vlanId,
|
|
588
|
-
requestedInterface: parsed.parentInterface
|
|
589
|
-
},
|
|
590
|
-
recoverability: {
|
|
591
|
-
retryable: false,
|
|
592
|
-
suggestedAction: "Use a different name or update the existing VLAN."
|
|
593
|
-
}
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
if (parsed.dryRun) {
|
|
597
|
-
const diff = [
|
|
598
|
-
{ property: "name", before: null, after: parsed.name },
|
|
599
|
-
{ property: "vlan-id", before: null, after: String(parsed.vlanId) },
|
|
600
|
-
{ property: "interface", before: null, after: parsed.parentInterface },
|
|
601
|
-
{ property: "mtu", before: null, after: String(parsed.mtu) },
|
|
602
|
-
{ property: "disabled", before: null, after: parsed.disabled ? "true" : "false" },
|
|
603
|
-
...comment ? [{ property: "comment", before: null, after: comment }] : []
|
|
604
|
-
];
|
|
605
|
-
return {
|
|
606
|
-
content: `Dry run: Would create VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
|
|
607
|
-
structuredContent: {
|
|
608
|
-
action: "dry_run",
|
|
609
|
-
diff
|
|
610
|
-
}
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
const body = {
|
|
614
|
-
name: parsed.name,
|
|
615
|
-
"vlan-id": String(parsed.vlanId),
|
|
616
|
-
interface: parsed.parentInterface,
|
|
617
|
-
mtu: String(parsed.mtu),
|
|
618
|
-
disabled: parsed.disabled ? "true" : "false"
|
|
619
|
-
};
|
|
620
|
-
if (comment) {
|
|
621
|
-
body.comment = comment;
|
|
622
|
-
}
|
|
623
|
-
const created = await context.routerClient.create("interface/vlan", body);
|
|
624
|
-
log2.info({ name: parsed.name, id: created[".id"] }, "VLAN created successfully");
|
|
625
|
-
return {
|
|
626
|
-
content: `Created VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
|
|
627
|
-
structuredContent: {
|
|
628
|
-
action: "created",
|
|
629
|
-
vlan: created
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
} catch (err) {
|
|
633
|
-
if (err instanceof MikroMCPError) throw err;
|
|
634
|
-
throw enrichError(err, { routerId: context.routerId, tool: "create_vlan" });
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
};
|
|
638
|
-
var interfaceTools = [listInterfacesTool, createVlanTool];
|
|
537
|
+
var interfaceTools = [listInterfacesTool];
|
|
639
538
|
|
|
640
539
|
// src/domain/tools/ip-tools.ts
|
|
641
540
|
import { z as z4 } from "zod";
|
|
@@ -686,7 +585,7 @@ var inputSchema2 = z4.object({
|
|
|
686
585
|
disabled: z4.boolean().default(false).describe("Whether the IP address should be disabled"),
|
|
687
586
|
dryRun: z4.boolean().default(false).describe("If true, validate and return planned changes without applying")
|
|
688
587
|
}).strict();
|
|
689
|
-
function
|
|
588
|
+
function sanitizeComment(comment) {
|
|
690
589
|
if (comment === void 0) return void 0;
|
|
691
590
|
return comment.replace(/[\x00-\x1f\x7f]/g, "");
|
|
692
591
|
}
|
|
@@ -710,7 +609,7 @@ var manageIpAddressTool = {
|
|
|
710
609
|
snapshotPaths: ["ip/address"],
|
|
711
610
|
async handler(params, context) {
|
|
712
611
|
const parsed = inputSchema2.parse(params);
|
|
713
|
-
const comment =
|
|
612
|
+
const comment = sanitizeComment(parsed.comment);
|
|
714
613
|
log3.info(
|
|
715
614
|
{
|
|
716
615
|
routerId: context.routerId,
|
|
@@ -899,13 +798,14 @@ var listDhcpLeasesInputSchema = z5.object({
|
|
|
899
798
|
server: z5.string().optional().describe("Filter by DHCP server name"),
|
|
900
799
|
status: z5.enum(["bound", "waiting", "offered", "blocked", "all"]).default("all").describe("Filter by lease status"),
|
|
901
800
|
macAddress: z5.string().optional().describe("Filter by MAC address (exact match, case-insensitive)"),
|
|
801
|
+
leaseType: z5.enum(["dynamic", "static", "all"]).default("all").describe("Filter by lease type (dynamic or static)"),
|
|
902
802
|
limit: z5.number().int().min(1).max(500).default(100).describe("Maximum number of leases to return"),
|
|
903
803
|
offset: z5.number().int().min(0).default(0).describe("Offset for pagination")
|
|
904
804
|
}).strict();
|
|
905
805
|
var listDhcpLeasesTool = {
|
|
906
806
|
name: "list_dhcp_leases",
|
|
907
807
|
title: "List DHCP Leases",
|
|
908
|
-
description: "List DHCP leases on a MikroTik router with optional filtering by server, status, and MAC address. Supports pagination.",
|
|
808
|
+
description: "List DHCP leases on a MikroTik router with optional filtering by server, status, lease type (dynamic/static), and MAC address. Supports pagination.",
|
|
909
809
|
inputSchema: listDhcpLeasesInputSchema,
|
|
910
810
|
annotations: {
|
|
911
811
|
readOnlyHint: true,
|
|
@@ -949,6 +849,14 @@ var listDhcpLeasesTool = {
|
|
|
949
849
|
return leaseMAC === upperMac;
|
|
950
850
|
});
|
|
951
851
|
}
|
|
852
|
+
if (parsed.leaseType !== "all") {
|
|
853
|
+
leases = leases.filter((lease) => {
|
|
854
|
+
const rec = lease;
|
|
855
|
+
const isDynamic = rec.dynamic === true || rec.dynamic === "true";
|
|
856
|
+
if (parsed.leaseType === "dynamic") return isDynamic;
|
|
857
|
+
return !isDynamic;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
952
860
|
const total = leases.length;
|
|
953
861
|
const paginated = leases.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
954
862
|
const hasMore = parsed.offset + parsed.limit < total;
|
|
@@ -980,7 +888,110 @@ var listDhcpLeasesTool = {
|
|
|
980
888
|
}
|
|
981
889
|
}
|
|
982
890
|
};
|
|
983
|
-
var
|
|
891
|
+
var manageDhcpLeaseInputSchema = z5.object({
|
|
892
|
+
routerId: z5.string().describe("Target router identifier from the router registry"),
|
|
893
|
+
action: z5.enum(["make-static", "remove"]).describe(
|
|
894
|
+
"Action to perform: make-static converts a dynamic lease to static; remove deletes the lease"
|
|
895
|
+
),
|
|
896
|
+
macAddress: z5.string().describe("MAC address of the lease \u2014 idempotency key (case-insensitive)"),
|
|
897
|
+
dryRun: z5.boolean().default(false).describe("Preview changes without applying")
|
|
898
|
+
}).strict();
|
|
899
|
+
var manageDhcpLeaseTool = {
|
|
900
|
+
name: "manage_dhcp_lease",
|
|
901
|
+
title: "Manage DHCP Lease",
|
|
902
|
+
description: "Convert a dynamic DHCP lease to static (make-static) or remove a lease. Idempotent by MAC address.",
|
|
903
|
+
inputSchema: manageDhcpLeaseInputSchema,
|
|
904
|
+
annotations: {
|
|
905
|
+
readOnlyHint: false,
|
|
906
|
+
destructiveHint: false,
|
|
907
|
+
idempotentHint: true,
|
|
908
|
+
openWorldHint: false
|
|
909
|
+
},
|
|
910
|
+
snapshotPaths: ["ip/dhcp-server/lease"],
|
|
911
|
+
async handler(params, context) {
|
|
912
|
+
const parsed = manageDhcpLeaseInputSchema.parse(params);
|
|
913
|
+
const upperMac = parsed.macAddress.toUpperCase();
|
|
914
|
+
log4.info(
|
|
915
|
+
{ routerId: context.routerId, action: parsed.action, macAddress: upperMac },
|
|
916
|
+
"Managing DHCP lease"
|
|
917
|
+
);
|
|
918
|
+
try {
|
|
919
|
+
const allLeases = await context.routerClient.get("ip/dhcp-server/lease", {
|
|
920
|
+
limit: void 0,
|
|
921
|
+
offset: void 0
|
|
922
|
+
});
|
|
923
|
+
const existing = allLeases.find(
|
|
924
|
+
(l) => (l["mac-address"] ?? "").toUpperCase() === upperMac
|
|
925
|
+
);
|
|
926
|
+
if (parsed.action === "make-static") {
|
|
927
|
+
if (!existing) {
|
|
928
|
+
throw new MikroMCPError({
|
|
929
|
+
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
930
|
+
code: "DHCP_LEASE_NOT_FOUND",
|
|
931
|
+
message: `No DHCP lease found for MAC address ${upperMac}.`,
|
|
932
|
+
details: { macAddress: upperMac },
|
|
933
|
+
recoverability: {
|
|
934
|
+
retryable: false,
|
|
935
|
+
suggestedAction: "Verify the MAC address with list_dhcp_leases.",
|
|
936
|
+
alternativeTools: ["list_dhcp_leases"]
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
const existingDynamic = existing.dynamic;
|
|
941
|
+
const isAlreadyStatic = existingDynamic === "false" || existingDynamic === false;
|
|
942
|
+
if (isAlreadyStatic) {
|
|
943
|
+
return {
|
|
944
|
+
content: `Lease for ${upperMac} (${existing.address ?? "unknown"}) is already static. No changes made.`,
|
|
945
|
+
structuredContent: { action: "already_static", macAddress: upperMac, id: existing[".id"] }
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
if (parsed.dryRun) {
|
|
949
|
+
return {
|
|
950
|
+
content: `Dry run: Would convert lease for ${upperMac} (${existing.address ?? "unknown"}) to static.`,
|
|
951
|
+
structuredContent: {
|
|
952
|
+
action: "dry_run",
|
|
953
|
+
diff: [{ property: "dynamic", before: "true", after: "false" }],
|
|
954
|
+
lease: existing
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
await context.routerClient.update("ip/dhcp-server/lease", existing[".id"], {
|
|
959
|
+
dynamic: "false"
|
|
960
|
+
});
|
|
961
|
+
log4.info({ macAddress: upperMac, id: existing[".id"] }, "DHCP lease converted to static");
|
|
962
|
+
return {
|
|
963
|
+
content: `Lease for ${upperMac} (${existing.address ?? "unknown"}) converted to static.`,
|
|
964
|
+
structuredContent: { action: "made-static", macAddress: upperMac, id: existing[".id"] }
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
if (!existing) {
|
|
968
|
+
return {
|
|
969
|
+
content: `No DHCP lease found for MAC address ${upperMac}. Nothing to remove.`,
|
|
970
|
+
structuredContent: { action: "not_found", macAddress: upperMac }
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (parsed.dryRun) {
|
|
974
|
+
return {
|
|
975
|
+
content: `Dry run: Would remove lease for ${upperMac}.`,
|
|
976
|
+
structuredContent: {
|
|
977
|
+
action: "dry_run",
|
|
978
|
+
diff: [{ property: "mac-address", before: upperMac, after: null }]
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
await context.routerClient.remove("ip/dhcp-server/lease", existing[".id"]);
|
|
983
|
+
log4.info({ macAddress: upperMac }, "DHCP lease removed");
|
|
984
|
+
return {
|
|
985
|
+
content: `Removed DHCP lease for ${upperMac}.`,
|
|
986
|
+
structuredContent: { action: "removed", macAddress: upperMac, id: existing[".id"] }
|
|
987
|
+
};
|
|
988
|
+
} catch (err) {
|
|
989
|
+
if (err instanceof MikroMCPError) throw err;
|
|
990
|
+
throw enrichError(err, { routerId: context.routerId, tool: "manage_dhcp_lease" });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
var dhcpTools = [listDhcpLeasesTool, manageDhcpLeaseTool];
|
|
984
995
|
|
|
985
996
|
// src/domain/tools/route-tools.ts
|
|
986
997
|
import { z as z6 } from "zod";
|
|
@@ -1084,7 +1095,7 @@ var manageRouteInputSchema = z6.object({
|
|
|
1084
1095
|
disabled: z6.boolean().default(false).describe("Whether the route should be disabled"),
|
|
1085
1096
|
dryRun: z6.boolean().default(false).describe("If true, validate and return planned changes without applying")
|
|
1086
1097
|
}).strict();
|
|
1087
|
-
function
|
|
1098
|
+
function sanitizeComment2(comment) {
|
|
1088
1099
|
if (comment === void 0) return void 0;
|
|
1089
1100
|
return comment.replace(/[\x00-\x1f\x7f]/g, "");
|
|
1090
1101
|
}
|
|
@@ -1112,7 +1123,7 @@ var manageRouteTool = {
|
|
|
1112
1123
|
snapshotPaths: ["ip/route"],
|
|
1113
1124
|
async handler(params, context) {
|
|
1114
1125
|
const parsed = manageRouteInputSchema.parse(params);
|
|
1115
|
-
const comment =
|
|
1126
|
+
const comment = sanitizeComment2(parsed.comment);
|
|
1116
1127
|
log5.info(
|
|
1117
1128
|
{
|
|
1118
1129
|
routerId: context.routerId,
|
|
@@ -1253,7 +1264,7 @@ var log6 = createLogger("firewall-tools");
|
|
|
1253
1264
|
function tableToPath(table) {
|
|
1254
1265
|
return table === "filter" ? "ip/firewall/filter" : "ip/firewall/nat";
|
|
1255
1266
|
}
|
|
1256
|
-
function
|
|
1267
|
+
function sanitizeComment3(comment) {
|
|
1257
1268
|
if (comment === void 0) return void 0;
|
|
1258
1269
|
return comment.replace(/[\x00-\x1f\x7f]/g, "");
|
|
1259
1270
|
}
|
|
@@ -1392,7 +1403,7 @@ var manageFirewallRuleTool = {
|
|
|
1392
1403
|
snapshotPaths: ["ip/firewall/filter", "ip/firewall/nat"],
|
|
1393
1404
|
async handler(params, context) {
|
|
1394
1405
|
const parsed = manageFirewallRuleInputSchema.parse(params);
|
|
1395
|
-
const comment =
|
|
1406
|
+
const comment = sanitizeComment3(parsed.comment);
|
|
1396
1407
|
log6.info(
|
|
1397
1408
|
{
|
|
1398
1409
|
routerId: context.routerId,
|
|
@@ -2112,8 +2123,8 @@ var runCommandTool = {
|
|
|
2112
2123
|
async handler(params, context) {
|
|
2113
2124
|
const parsed = runCommandInputSchema.parse(params);
|
|
2114
2125
|
log8.info({ routerId: context.routerId, command: parsed.command }, "Running command");
|
|
2115
|
-
const globalAllow =
|
|
2116
|
-
const globalDeny =
|
|
2126
|
+
const globalAllow = context.appConfig.cmdAllow;
|
|
2127
|
+
const globalDeny = context.appConfig.cmdDeny;
|
|
2117
2128
|
const policy = resolveCommandPolicy(context.routerConfig, globalAllow, globalDeny);
|
|
2118
2129
|
checkCommand(parsed.command, policy);
|
|
2119
2130
|
if (parsed.dryRun) {
|
|
@@ -2711,9 +2722,9 @@ var listPeersTool = {
|
|
|
2711
2722
|
const lines = [`WireGuard peers on ${context.routerId}: ${total} total`];
|
|
2712
2723
|
for (const peer of peers) {
|
|
2713
2724
|
const rec = peer;
|
|
2714
|
-
const
|
|
2725
|
+
const key2 = rec["public-key"] ? rec["public-key"].slice(0, 8) + "\u2026" : "unknown";
|
|
2715
2726
|
lines.push(
|
|
2716
|
-
` [${rec.interface ?? "?"}] ${
|
|
2727
|
+
` [${rec.interface ?? "?"}] ${key2} last-handshake=${rec["last-handshake"] ?? "never"}`
|
|
2717
2728
|
);
|
|
2718
2729
|
}
|
|
2719
2730
|
return {
|
|
@@ -4905,12 +4916,10 @@ var containerTools = [listContainersTool, manageContainerTool];
|
|
|
4905
4916
|
|
|
4906
4917
|
// src/domain/tools/change-management-tools.ts
|
|
4907
4918
|
import { z as z23 } from "zod";
|
|
4908
|
-
import { readFileSync
|
|
4909
|
-
import { join as join3 } from "path";
|
|
4910
|
-
import { homedir as homedir2 } from "os";
|
|
4919
|
+
import { readFileSync } from "fs";
|
|
4911
4920
|
|
|
4912
4921
|
// src/domain/snapshot/snapshot-engine.ts
|
|
4913
|
-
import {
|
|
4922
|
+
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
4914
4923
|
import { join as join2 } from "path";
|
|
4915
4924
|
import { nanoid } from "nanoid";
|
|
4916
4925
|
function pathToSlug(rosPath) {
|
|
@@ -4925,13 +4934,13 @@ async function takeSnapshot(client, routerId, path, snapshotDir) {
|
|
|
4925
4934
|
const dir = join2(snapshotDir, routerId);
|
|
4926
4935
|
const filePath = join2(dir, `${id}.json`);
|
|
4927
4936
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
4928
|
-
|
|
4937
|
+
await mkdir(dir, { recursive: true });
|
|
4929
4938
|
const stored = { id, routerId, path, ts, records };
|
|
4930
|
-
|
|
4939
|
+
await writeFile(filePath, JSON.stringify(stored, null, 2));
|
|
4931
4940
|
return { id, routerId, path, ts, filePath, recordCount: records.length };
|
|
4932
4941
|
}
|
|
4933
|
-
function loadSnapshot(filePath) {
|
|
4934
|
-
return JSON.parse(
|
|
4942
|
+
async function loadSnapshot(filePath) {
|
|
4943
|
+
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
4935
4944
|
}
|
|
4936
4945
|
|
|
4937
4946
|
// src/domain/snapshot/diff-engine.ts
|
|
@@ -4951,7 +4960,16 @@ var SEMANTIC_KEYS = {
|
|
|
4951
4960
|
"system/scheduler": ["name"],
|
|
4952
4961
|
"system/script": ["name"],
|
|
4953
4962
|
"system/package": ["name"],
|
|
4954
|
-
"container": ["name"]
|
|
4963
|
+
"container": ["name"],
|
|
4964
|
+
"certificate": ["name"],
|
|
4965
|
+
"file": ["name"],
|
|
4966
|
+
"interface/vrrp": ["name"],
|
|
4967
|
+
"ip/dhcp-server": ["name"],
|
|
4968
|
+
"ip/ipsec/peer": ["name"],
|
|
4969
|
+
"ip/pool": ["name"],
|
|
4970
|
+
"queue/simple": ["name"],
|
|
4971
|
+
"tool/netwatch": ["host"],
|
|
4972
|
+
"user": ["name"]
|
|
4955
4973
|
};
|
|
4956
4974
|
function normalizeValue(v) {
|
|
4957
4975
|
if (v === "true" || v === "yes") return "true";
|
|
@@ -4986,8 +5004,8 @@ function computeRestorePlan(path, before, current) {
|
|
|
4986
5004
|
const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
|
|
4987
5005
|
const toCreate = [];
|
|
4988
5006
|
const toUpdate = [];
|
|
4989
|
-
for (const [
|
|
4990
|
-
const currentRecord = currentByKey.get(
|
|
5007
|
+
for (const [key2, beforeRecord] of beforeByKey) {
|
|
5008
|
+
const currentRecord = currentByKey.get(key2);
|
|
4991
5009
|
if (!currentRecord) {
|
|
4992
5010
|
toCreate.push(beforeRecord);
|
|
4993
5011
|
} else if (!recordsAreEqual(beforeRecord, currentRecord)) {
|
|
@@ -5012,52 +5030,63 @@ async function applyRestorePlan(plan, client) {
|
|
|
5012
5030
|
}
|
|
5013
5031
|
|
|
5014
5032
|
// src/domain/snapshot/write-journal.ts
|
|
5015
|
-
import {
|
|
5033
|
+
import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
|
|
5016
5034
|
import { dirname } from "path";
|
|
5017
5035
|
import { nanoid as nanoid2 } from "nanoid";
|
|
5018
5036
|
|
|
5019
5037
|
// src/observability/audit-log.ts
|
|
5020
|
-
import {
|
|
5038
|
+
import { appendFile } from "fs/promises";
|
|
5021
5039
|
var log22 = createLogger("audit");
|
|
5022
|
-
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
5040
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
5041
|
+
"password",
|
|
5042
|
+
"pass",
|
|
5043
|
+
"token",
|
|
5044
|
+
"secret",
|
|
5045
|
+
"authorization",
|
|
5046
|
+
"credentials",
|
|
5047
|
+
"privatekey",
|
|
5048
|
+
"presharedkey",
|
|
5049
|
+
"psk",
|
|
5050
|
+
"passphrase",
|
|
5051
|
+
"community",
|
|
5052
|
+
"apikey",
|
|
5053
|
+
"confirmationsecret"
|
|
5054
|
+
]);
|
|
5023
5055
|
function redactParams(params) {
|
|
5024
5056
|
const out = {};
|
|
5025
|
-
for (const [
|
|
5026
|
-
if (SENSITIVE_KEYS.has(
|
|
5057
|
+
for (const [key2, value] of Object.entries(params)) {
|
|
5058
|
+
if (SENSITIVE_KEYS.has(key2.toLowerCase())) continue;
|
|
5027
5059
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
5028
|
-
out[
|
|
5060
|
+
out[key2] = redactParams(value);
|
|
5029
5061
|
} else {
|
|
5030
|
-
out[
|
|
5062
|
+
out[key2] = value;
|
|
5031
5063
|
}
|
|
5032
5064
|
}
|
|
5033
5065
|
return out;
|
|
5034
5066
|
}
|
|
5035
|
-
|
|
5036
|
-
function auditLog(event) {
|
|
5067
|
+
function auditLog(event, auditLogPath) {
|
|
5037
5068
|
const safeEvent = { ...event, params: redactParams(event.params) };
|
|
5038
5069
|
log22.info(safeEvent, "audit");
|
|
5039
|
-
if (
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
}
|
|
5043
|
-
log22.error({ err, auditFilePath }, "Failed to write audit event to file");
|
|
5044
|
-
}
|
|
5070
|
+
if (auditLogPath) {
|
|
5071
|
+
appendFile(auditLogPath, JSON.stringify(safeEvent) + "\n").catch((err) => {
|
|
5072
|
+
log22.error({ err, auditLogPath }, "Failed to write audit event to file");
|
|
5073
|
+
});
|
|
5045
5074
|
}
|
|
5046
5075
|
}
|
|
5047
5076
|
|
|
5048
5077
|
// src/domain/snapshot/write-journal.ts
|
|
5049
5078
|
var log23 = createLogger("write-journal");
|
|
5050
|
-
function appendLine(filePath, obj) {
|
|
5079
|
+
async function appendLine(filePath, obj) {
|
|
5051
5080
|
try {
|
|
5052
|
-
|
|
5053
|
-
|
|
5081
|
+
await mkdir2(dirname(filePath), { recursive: true });
|
|
5082
|
+
await appendFile2(filePath, JSON.stringify(obj) + "\n");
|
|
5054
5083
|
} catch (err) {
|
|
5055
5084
|
log23.error({ err, filePath }, "Failed to write to write journal");
|
|
5056
5085
|
}
|
|
5057
5086
|
}
|
|
5058
5087
|
function recordAttempt(args) {
|
|
5059
5088
|
const id = nanoid2();
|
|
5060
|
-
appendLine(args.journalPath, {
|
|
5089
|
+
void appendLine(args.journalPath, {
|
|
5061
5090
|
id,
|
|
5062
5091
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5063
5092
|
identityId: args.identityId,
|
|
@@ -5071,7 +5100,7 @@ function recordAttempt(args) {
|
|
|
5071
5100
|
return id;
|
|
5072
5101
|
}
|
|
5073
5102
|
function recordOutcome(args) {
|
|
5074
|
-
appendLine(args.journalPath, {
|
|
5103
|
+
void appendLine(args.journalPath, {
|
|
5075
5104
|
id: args.journalId,
|
|
5076
5105
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5077
5106
|
phase: args.phase,
|
|
@@ -5185,9 +5214,8 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5185
5214
|
async handler(params, context) {
|
|
5186
5215
|
const parsed = applyPlanInputSchema.parse(params);
|
|
5187
5216
|
log24.info({ routerId: context.routerId, stepCount: parsed.steps.length }, "Applying plan");
|
|
5188
|
-
const
|
|
5189
|
-
const
|
|
5190
|
-
const snapshotDir = `${dataDir}/snapshots`;
|
|
5217
|
+
const journalPath = context.appConfig.journalPath;
|
|
5218
|
+
const snapshotDir = context.appConfig.snapshotDir;
|
|
5191
5219
|
const results2 = [];
|
|
5192
5220
|
for (const [i, step] of parsed.steps.entries()) {
|
|
5193
5221
|
const tool = requireTool(step.tool);
|
|
@@ -5212,9 +5240,11 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5212
5240
|
params: stepParams,
|
|
5213
5241
|
snapshotIds
|
|
5214
5242
|
});
|
|
5243
|
+
const stepStartMs = Date.now();
|
|
5215
5244
|
try {
|
|
5216
|
-
const
|
|
5217
|
-
|
|
5245
|
+
const runStep = () => tool.handler(stepParams, context);
|
|
5246
|
+
const result = context.circuitBreaker ? await context.circuitBreaker.execute(runStep) : await runStep();
|
|
5247
|
+
recordOutcome({ journalPath, journalId: stepJournalId, phase: "success", durationMs: Date.now() - stepStartMs });
|
|
5218
5248
|
results2.push({
|
|
5219
5249
|
stepIndex: i,
|
|
5220
5250
|
tool: step.tool,
|
|
@@ -5224,7 +5254,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5224
5254
|
});
|
|
5225
5255
|
} catch (err) {
|
|
5226
5256
|
const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: step.tool });
|
|
5227
|
-
recordOutcome({ journalPath, journalId: stepJournalId, phase: "failure", outcome: error.code, durationMs:
|
|
5257
|
+
recordOutcome({ journalPath, journalId: stepJournalId, phase: "failure", outcome: error.code, durationMs: Date.now() - stepStartMs });
|
|
5228
5258
|
log24.error({ err: error, tool: step.tool, step: i }, "apply_plan step failed");
|
|
5229
5259
|
return {
|
|
5230
5260
|
content: `Apply failed at step ${i + 1}/${parsed.steps.length} (${step.tool}): ${error.message}. ${i} step(s) completed before failure.`,
|
|
@@ -5250,7 +5280,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5250
5280
|
}
|
|
5251
5281
|
};
|
|
5252
5282
|
function findJournalEntry(journalPath, journalId) {
|
|
5253
|
-
const raw =
|
|
5283
|
+
const raw = readFileSync(journalPath, "utf-8");
|
|
5254
5284
|
const lines = raw.split("\n").filter(Boolean);
|
|
5255
5285
|
const entry = lines.map((l) => JSON.parse(l)).find((e) => e.id === journalId && e.phase === "attempt");
|
|
5256
5286
|
if (!entry) {
|
|
@@ -5279,9 +5309,8 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5279
5309
|
},
|
|
5280
5310
|
async handler(params, context) {
|
|
5281
5311
|
const parsed = rollbackChangeInputSchema.parse(params);
|
|
5282
|
-
const
|
|
5283
|
-
const
|
|
5284
|
-
const snapshotDir = `${dataDir}/snapshots`;
|
|
5312
|
+
const journalPath = context.appConfig.journalPath;
|
|
5313
|
+
const snapshotDir = context.appConfig.snapshotDir;
|
|
5285
5314
|
log24.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");
|
|
5286
5315
|
const { snapshotIds } = findJournalEntry(journalPath, parsed.journalId);
|
|
5287
5316
|
if (snapshotIds.length === 0) {
|
|
@@ -5293,7 +5322,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5293
5322
|
const restorePlans = [];
|
|
5294
5323
|
for (const snapshotId of snapshotIds) {
|
|
5295
5324
|
const filePath = `${snapshotDir}/${context.routerId}/${snapshotId}.json`;
|
|
5296
|
-
const stored = loadSnapshot(filePath);
|
|
5325
|
+
const stored = await loadSnapshot(filePath);
|
|
5297
5326
|
const current = await context.routerClient.get(stored.path, {});
|
|
5298
5327
|
const plan = computeRestorePlan(stored.path, stored.records, current);
|
|
5299
5328
|
restorePlans.push(plan);
|
|
@@ -6034,7 +6063,8 @@ var log28 = createLogger("dhcp-server-tools");
|
|
|
6034
6063
|
var listDhcpServersInputSchema = z27.object({
|
|
6035
6064
|
routerId: z27.string().describe("Target router identifier from the router registry"),
|
|
6036
6065
|
interface: z27.string().optional().describe("Filter by interface name (exact match)"),
|
|
6037
|
-
limit: z27.number().int().min(1).max(500).default(100).describe("Maximum number of DHCP servers to return")
|
|
6066
|
+
limit: z27.number().int().min(1).max(500).default(100).describe("Maximum number of DHCP servers to return"),
|
|
6067
|
+
offset: z27.number().int().min(0).default(0).describe("Offset for pagination")
|
|
6038
6068
|
}).strict();
|
|
6039
6069
|
var listDhcpServersTool = {
|
|
6040
6070
|
name: "list_dhcp_servers",
|
|
@@ -6055,17 +6085,19 @@ var listDhcpServersTool = {
|
|
|
6055
6085
|
limit: void 0,
|
|
6056
6086
|
offset: void 0
|
|
6057
6087
|
});
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
const
|
|
6088
|
+
let filtered = parsed.interface ? allServers.filter((s) => s.interface === parsed.interface) : allServers;
|
|
6089
|
+
const total = filtered.length;
|
|
6090
|
+
const servers = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
6091
|
+
const hasMore = parsed.offset + parsed.limit < total;
|
|
6062
6092
|
return {
|
|
6063
|
-
content: `DHCP servers on ${context.routerId}: ${servers.length}
|
|
6093
|
+
content: `DHCP servers on ${context.routerId}: ${total} total, showing ${servers.length} (offset ${parsed.offset})`,
|
|
6064
6094
|
structuredContent: {
|
|
6065
6095
|
routerId: context.routerId,
|
|
6066
6096
|
servers,
|
|
6067
|
-
total
|
|
6068
|
-
|
|
6097
|
+
total,
|
|
6098
|
+
hasMore,
|
|
6099
|
+
offset: parsed.offset,
|
|
6100
|
+
limit: parsed.limit
|
|
6069
6101
|
}
|
|
6070
6102
|
};
|
|
6071
6103
|
} catch (err) {
|
|
@@ -6256,16 +6288,22 @@ var manageDhcpServerTool = {
|
|
|
6256
6288
|
}
|
|
6257
6289
|
}
|
|
6258
6290
|
};
|
|
6259
|
-
var
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6291
|
+
var dhcpServerTools = [listDhcpServersTool, manageDhcpServerTool];
|
|
6292
|
+
|
|
6293
|
+
// src/domain/tools/ip-pool-tools.ts
|
|
6294
|
+
import { z as z28 } from "zod";
|
|
6295
|
+
var log29 = createLogger("ip-pool-tools");
|
|
6296
|
+
var listIpPoolsInputSchema = z28.object({
|
|
6297
|
+
routerId: z28.string().describe("Target router identifier from the router registry"),
|
|
6298
|
+
name: z28.string().optional().describe("Filter by pool name (substring match)"),
|
|
6299
|
+
limit: z28.number().int().min(1).max(500).default(100).describe("Maximum number of pools to return"),
|
|
6300
|
+
offset: z28.number().int().min(0).default(0).describe("Offset for pagination")
|
|
6263
6301
|
}).strict();
|
|
6264
|
-
var
|
|
6265
|
-
name: "
|
|
6266
|
-
title: "List
|
|
6267
|
-
description: "List IP pools on a MikroTik router.",
|
|
6268
|
-
inputSchema:
|
|
6302
|
+
var listIpPoolsTool = {
|
|
6303
|
+
name: "list_ip_pools",
|
|
6304
|
+
title: "List IP Pools",
|
|
6305
|
+
description: "List IP address pools on a MikroTik router. Supports filtering by name and pagination.",
|
|
6306
|
+
inputSchema: listIpPoolsInputSchema,
|
|
6269
6307
|
annotations: {
|
|
6270
6308
|
readOnlyHint: true,
|
|
6271
6309
|
destructiveHint: false,
|
|
@@ -6273,44 +6311,49 @@ var listDhcpPoolsTool = {
|
|
|
6273
6311
|
openWorldHint: false
|
|
6274
6312
|
},
|
|
6275
6313
|
async handler(params, context) {
|
|
6276
|
-
const parsed =
|
|
6277
|
-
|
|
6314
|
+
const parsed = listIpPoolsInputSchema.parse(params);
|
|
6315
|
+
log29.info({ routerId: context.routerId }, "Listing IP pools");
|
|
6278
6316
|
try {
|
|
6279
6317
|
const allPools = await context.routerClient.get("ip/pool", {
|
|
6280
6318
|
limit: void 0,
|
|
6281
6319
|
offset: void 0
|
|
6282
6320
|
});
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6321
|
+
let filtered = allPools;
|
|
6322
|
+
if (parsed.name) {
|
|
6323
|
+
filtered = filtered.filter((p) => (p.name ?? "").includes(parsed.name));
|
|
6324
|
+
}
|
|
6325
|
+
const total = filtered.length;
|
|
6326
|
+
const pools = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
6327
|
+
const hasMore = parsed.offset + parsed.limit < total;
|
|
6287
6328
|
return {
|
|
6288
|
-
content: `
|
|
6329
|
+
content: `IP pools on ${context.routerId}: ${total} total, showing ${pools.length} (offset ${parsed.offset})`,
|
|
6289
6330
|
structuredContent: {
|
|
6290
6331
|
routerId: context.routerId,
|
|
6291
6332
|
pools,
|
|
6292
|
-
total
|
|
6293
|
-
|
|
6333
|
+
total,
|
|
6334
|
+
hasMore,
|
|
6335
|
+
offset: parsed.offset,
|
|
6336
|
+
limit: parsed.limit
|
|
6294
6337
|
}
|
|
6295
6338
|
};
|
|
6296
6339
|
} catch (err) {
|
|
6297
|
-
throw enrichError(err, { routerId: context.routerId, tool: "
|
|
6340
|
+
throw enrichError(err, { routerId: context.routerId, tool: "list_ip_pools" });
|
|
6298
6341
|
}
|
|
6299
6342
|
}
|
|
6300
6343
|
};
|
|
6301
|
-
var
|
|
6302
|
-
routerId:
|
|
6303
|
-
action:
|
|
6304
|
-
name:
|
|
6305
|
-
ranges:
|
|
6306
|
-
nextPool:
|
|
6307
|
-
dryRun:
|
|
6344
|
+
var manageIpPoolInputSchema = z28.object({
|
|
6345
|
+
routerId: z28.string().describe("Target router identifier from the router registry"),
|
|
6346
|
+
action: z28.enum(["add", "remove"]).describe("Action to perform"),
|
|
6347
|
+
name: z28.string().describe("Pool name \u2014 idempotency key"),
|
|
6348
|
+
ranges: z28.string().optional().describe("IP range (e.g. '192.168.1.100-192.168.1.200'; required for add)"),
|
|
6349
|
+
nextPool: z28.string().optional().describe("Next pool name for overflow"),
|
|
6350
|
+
dryRun: z28.boolean().default(false).describe("Preview changes without applying")
|
|
6308
6351
|
}).strict();
|
|
6309
|
-
var
|
|
6310
|
-
name: "
|
|
6311
|
-
title: "Manage
|
|
6312
|
-
description: "Add or remove an IP pool. Idempotent by name: add returns already_exists if a pool with the same name and ranges already exists.",
|
|
6313
|
-
inputSchema:
|
|
6352
|
+
var manageIpPoolTool = {
|
|
6353
|
+
name: "manage_ip_pool",
|
|
6354
|
+
title: "Manage IP Pool",
|
|
6355
|
+
description: "Add or remove an IP address pool. Idempotent by name: add returns already_exists if a pool with the same name and ranges already exists.",
|
|
6356
|
+
inputSchema: manageIpPoolInputSchema,
|
|
6314
6357
|
annotations: {
|
|
6315
6358
|
readOnlyHint: false,
|
|
6316
6359
|
destructiveHint: false,
|
|
@@ -6319,25 +6362,20 @@ var manageDhcpPoolTool = {
|
|
|
6319
6362
|
},
|
|
6320
6363
|
snapshotPaths: ["ip/pool"],
|
|
6321
6364
|
async handler(params, context) {
|
|
6322
|
-
const parsed =
|
|
6323
|
-
|
|
6324
|
-
{ routerId: context.routerId, action: parsed.action, name: parsed.name },
|
|
6325
|
-
"Managing DHCP pool"
|
|
6326
|
-
);
|
|
6365
|
+
const parsed = manageIpPoolInputSchema.parse(params);
|
|
6366
|
+
log29.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing IP pool");
|
|
6327
6367
|
try {
|
|
6328
6368
|
const allPools = await context.routerClient.get("ip/pool", {
|
|
6329
6369
|
limit: void 0,
|
|
6330
6370
|
offset: void 0
|
|
6331
6371
|
});
|
|
6332
|
-
const existing = allPools.find(
|
|
6333
|
-
(p) => p.name === parsed.name
|
|
6334
|
-
);
|
|
6372
|
+
const existing = allPools.find((p) => p.name === parsed.name);
|
|
6335
6373
|
if (parsed.action === "add") {
|
|
6336
6374
|
if (!parsed.ranges) {
|
|
6337
6375
|
throw new MikroMCPError({
|
|
6338
6376
|
category: "VALIDATION" /* VALIDATION */,
|
|
6339
|
-
code: "
|
|
6340
|
-
message: "ranges is required when adding
|
|
6377
|
+
code: "IP_POOL_RANGES_REQUIRED",
|
|
6378
|
+
message: "ranges is required when adding an IP pool.",
|
|
6341
6379
|
details: { name: parsed.name },
|
|
6342
6380
|
recoverability: {
|
|
6343
6381
|
retryable: false,
|
|
@@ -6348,22 +6386,19 @@ var manageDhcpPoolTool = {
|
|
|
6348
6386
|
if (existing) {
|
|
6349
6387
|
if (existing.ranges === parsed.ranges) {
|
|
6350
6388
|
return {
|
|
6351
|
-
content: `
|
|
6389
|
+
content: `IP pool "${parsed.name}" already exists with the same ranges. No changes made.`,
|
|
6352
6390
|
structuredContent: { action: "already_exists", pool: existing }
|
|
6353
6391
|
};
|
|
6354
6392
|
}
|
|
6355
6393
|
throw new MikroMCPError({
|
|
6356
6394
|
category: "CONFLICT" /* CONFLICT */,
|
|
6357
|
-
code: "
|
|
6358
|
-
message: `
|
|
6359
|
-
details: {
|
|
6360
|
-
existing: existing.ranges,
|
|
6361
|
-
requested: parsed.ranges
|
|
6362
|
-
},
|
|
6395
|
+
code: "IP_POOL_CONFLICT",
|
|
6396
|
+
message: `IP pool "${parsed.name}" exists with different ranges.`,
|
|
6397
|
+
details: { existing: existing.ranges, requested: parsed.ranges },
|
|
6363
6398
|
recoverability: {
|
|
6364
6399
|
retryable: false,
|
|
6365
6400
|
suggestedAction: "Remove the existing pool first or use a different name.",
|
|
6366
|
-
alternativeTools: ["
|
|
6401
|
+
alternativeTools: ["manage_ip_pool"]
|
|
6367
6402
|
}
|
|
6368
6403
|
});
|
|
6369
6404
|
}
|
|
@@ -6374,63 +6409,52 @@ var manageDhcpPoolTool = {
|
|
|
6374
6409
|
...parsed.nextPool ? [{ property: "next-pool", before: null, after: parsed.nextPool }] : []
|
|
6375
6410
|
];
|
|
6376
6411
|
return {
|
|
6377
|
-
content: `Dry run: Would add
|
|
6412
|
+
content: `Dry run: Would add IP pool "${parsed.name}" with ranges ${parsed.ranges}.`,
|
|
6378
6413
|
structuredContent: { action: "dry_run", diff }
|
|
6379
6414
|
};
|
|
6380
6415
|
}
|
|
6381
|
-
const body = {
|
|
6382
|
-
name: parsed.name,
|
|
6383
|
-
ranges: parsed.ranges
|
|
6384
|
-
};
|
|
6416
|
+
const body = { name: parsed.name, ranges: parsed.ranges };
|
|
6385
6417
|
if (parsed.nextPool) body["next-pool"] = parsed.nextPool;
|
|
6386
6418
|
const created = await context.routerClient.create("ip/pool", body);
|
|
6387
|
-
|
|
6419
|
+
log29.info({ name: parsed.name, id: created[".id"] }, "IP pool added");
|
|
6388
6420
|
return {
|
|
6389
|
-
content: `Added
|
|
6421
|
+
content: `Added IP pool "${parsed.name}" with ranges ${parsed.ranges}.`,
|
|
6390
6422
|
structuredContent: { action: "created", pool: created }
|
|
6391
6423
|
};
|
|
6392
6424
|
}
|
|
6393
6425
|
if (!existing) {
|
|
6394
6426
|
return {
|
|
6395
|
-
content: `
|
|
6427
|
+
content: `IP pool "${parsed.name}" not found. Nothing to remove.`,
|
|
6396
6428
|
structuredContent: { action: "not_found", name: parsed.name }
|
|
6397
6429
|
};
|
|
6398
6430
|
}
|
|
6399
6431
|
if (parsed.dryRun) {
|
|
6400
6432
|
return {
|
|
6401
|
-
content: `Dry run: Would remove
|
|
6402
|
-
structuredContent: {
|
|
6403
|
-
action: "dry_run",
|
|
6404
|
-
diff: [{ property: "name", before: parsed.name, after: null }]
|
|
6405
|
-
}
|
|
6433
|
+
content: `Dry run: Would remove IP pool "${parsed.name}".`,
|
|
6434
|
+
structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] }
|
|
6406
6435
|
};
|
|
6407
6436
|
}
|
|
6408
6437
|
await context.routerClient.remove("ip/pool", existing[".id"]);
|
|
6409
|
-
|
|
6438
|
+
log29.info({ name: parsed.name }, "IP pool removed");
|
|
6410
6439
|
return {
|
|
6411
|
-
content: `Removed
|
|
6440
|
+
content: `Removed IP pool "${parsed.name}".`,
|
|
6412
6441
|
structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
|
|
6413
6442
|
};
|
|
6414
6443
|
} catch (err) {
|
|
6415
6444
|
if (err instanceof MikroMCPError) throw err;
|
|
6416
|
-
throw enrichError(err, { routerId: context.routerId, tool: "
|
|
6445
|
+
throw enrichError(err, { routerId: context.routerId, tool: "manage_ip_pool" });
|
|
6417
6446
|
}
|
|
6418
6447
|
}
|
|
6419
6448
|
};
|
|
6420
|
-
var
|
|
6421
|
-
listDhcpServersTool,
|
|
6422
|
-
manageDhcpServerTool,
|
|
6423
|
-
listDhcpPoolsTool,
|
|
6424
|
-
manageDhcpPoolTool
|
|
6425
|
-
];
|
|
6449
|
+
var ipPoolTools = [listIpPoolsTool, manageIpPoolTool];
|
|
6426
6450
|
|
|
6427
6451
|
// src/domain/tools/queue-tools.ts
|
|
6428
|
-
import { z as
|
|
6429
|
-
var
|
|
6430
|
-
var listQueuesInputSchema =
|
|
6431
|
-
routerId:
|
|
6432
|
-
target:
|
|
6433
|
-
limit:
|
|
6452
|
+
import { z as z29 } from "zod";
|
|
6453
|
+
var log30 = createLogger("queue-tools");
|
|
6454
|
+
var listQueuesInputSchema = z29.object({
|
|
6455
|
+
routerId: z29.string().describe("Target router identifier from the router registry"),
|
|
6456
|
+
target: z29.string().optional().describe("Filter by target address (substring match)"),
|
|
6457
|
+
limit: z29.number().int().min(1).max(500).default(100).describe("Maximum number of queues to return")
|
|
6434
6458
|
}).strict();
|
|
6435
6459
|
var listQueuesTool = {
|
|
6436
6460
|
name: "list_queues",
|
|
@@ -6445,7 +6469,7 @@ var listQueuesTool = {
|
|
|
6445
6469
|
},
|
|
6446
6470
|
async handler(params, context) {
|
|
6447
6471
|
const parsed = listQueuesInputSchema.parse(params);
|
|
6448
|
-
|
|
6472
|
+
log30.info({ routerId: context.routerId }, "Listing queues");
|
|
6449
6473
|
try {
|
|
6450
6474
|
const allQueues = await context.routerClient.get("queue/simple", {
|
|
6451
6475
|
limit: void 0,
|
|
@@ -6469,15 +6493,15 @@ var listQueuesTool = {
|
|
|
6469
6493
|
}
|
|
6470
6494
|
}
|
|
6471
6495
|
};
|
|
6472
|
-
var manageQueueInputSchema =
|
|
6473
|
-
routerId:
|
|
6474
|
-
action:
|
|
6475
|
-
name:
|
|
6476
|
-
target:
|
|
6477
|
-
maxLimit:
|
|
6478
|
-
limitAt:
|
|
6479
|
-
comment:
|
|
6480
|
-
dryRun:
|
|
6496
|
+
var manageQueueInputSchema = z29.object({
|
|
6497
|
+
routerId: z29.string().describe("Target router identifier from the router registry"),
|
|
6498
|
+
action: z29.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
6499
|
+
name: z29.string().describe("Queue name \u2014 idempotency key"),
|
|
6500
|
+
target: z29.string().optional().describe("Target address (required for add; e.g. '192.168.1.0/24')"),
|
|
6501
|
+
maxLimit: z29.string().optional().describe("Max upload/download limit (e.g. '10M/10M')"),
|
|
6502
|
+
limitAt: z29.string().optional().describe("Guaranteed rate (e.g. '1M/1M')"),
|
|
6503
|
+
comment: z29.string().optional().describe("Optional comment"),
|
|
6504
|
+
dryRun: z29.boolean().default(false).describe("Preview changes without applying")
|
|
6481
6505
|
}).strict();
|
|
6482
6506
|
var manageQueueTool = {
|
|
6483
6507
|
name: "manage_queue",
|
|
@@ -6493,7 +6517,7 @@ var manageQueueTool = {
|
|
|
6493
6517
|
snapshotPaths: ["queue/simple"],
|
|
6494
6518
|
async handler(params, context) {
|
|
6495
6519
|
const parsed = manageQueueInputSchema.parse(params);
|
|
6496
|
-
|
|
6520
|
+
log30.info(
|
|
6497
6521
|
{ routerId: context.routerId, action: parsed.action, name: parsed.name },
|
|
6498
6522
|
"Managing queue"
|
|
6499
6523
|
);
|
|
@@ -6555,7 +6579,7 @@ var manageQueueTool = {
|
|
|
6555
6579
|
if (parsed.limitAt) body["limit-at"] = parsed.limitAt;
|
|
6556
6580
|
if (parsed.comment) body.comment = parsed.comment;
|
|
6557
6581
|
const created = await context.routerClient.create("queue/simple", body);
|
|
6558
|
-
|
|
6582
|
+
log30.info({ name: parsed.name, id: created[".id"] }, "Queue added");
|
|
6559
6583
|
return {
|
|
6560
6584
|
content: `Added queue "${parsed.name}".`,
|
|
6561
6585
|
structuredContent: { action: "created", queue: created }
|
|
@@ -6578,7 +6602,7 @@ var manageQueueTool = {
|
|
|
6578
6602
|
};
|
|
6579
6603
|
}
|
|
6580
6604
|
await context.routerClient.remove("queue/simple", existing[".id"]);
|
|
6581
|
-
|
|
6605
|
+
log30.info({ name: parsed.name }, "Queue removed");
|
|
6582
6606
|
return {
|
|
6583
6607
|
content: `Removed queue "${parsed.name}".`,
|
|
6584
6608
|
structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
|
|
@@ -6617,7 +6641,7 @@ var manageQueueTool = {
|
|
|
6617
6641
|
disabled: disabledValue
|
|
6618
6642
|
});
|
|
6619
6643
|
const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
|
|
6620
|
-
|
|
6644
|
+
log30.info({ name: parsed.name, action: resultAction }, "Queue updated");
|
|
6621
6645
|
return {
|
|
6622
6646
|
content: `Queue "${parsed.name}" ${resultAction}.`,
|
|
6623
6647
|
structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
|
|
@@ -6631,12 +6655,12 @@ var manageQueueTool = {
|
|
|
6631
6655
|
var queueTools = [listQueuesTool, manageQueueTool];
|
|
6632
6656
|
|
|
6633
6657
|
// src/domain/tools/vrrp-tools.ts
|
|
6634
|
-
import { z as
|
|
6635
|
-
var
|
|
6636
|
-
var listVrrpInstancesInputSchema =
|
|
6637
|
-
routerId:
|
|
6638
|
-
interface:
|
|
6639
|
-
limit:
|
|
6658
|
+
import { z as z30 } from "zod";
|
|
6659
|
+
var log31 = createLogger("vrrp-tools");
|
|
6660
|
+
var listVrrpInstancesInputSchema = z30.object({
|
|
6661
|
+
routerId: z30.string().describe("Target router identifier from the router registry"),
|
|
6662
|
+
interface: z30.string().optional().describe("Filter by master interface name (exact match)"),
|
|
6663
|
+
limit: z30.number().int().min(1).max(500).default(100).describe("Maximum number of instances to return")
|
|
6640
6664
|
}).strict();
|
|
6641
6665
|
var listVrrpInstancesTool = {
|
|
6642
6666
|
name: "list_vrrp_instances",
|
|
@@ -6651,7 +6675,7 @@ var listVrrpInstancesTool = {
|
|
|
6651
6675
|
},
|
|
6652
6676
|
async handler(params, context) {
|
|
6653
6677
|
const parsed = listVrrpInstancesInputSchema.parse(params);
|
|
6654
|
-
|
|
6678
|
+
log31.info({ routerId: context.routerId }, "Listing VRRP instances");
|
|
6655
6679
|
try {
|
|
6656
6680
|
const allInstances = await context.routerClient.get("interface/vrrp", {
|
|
6657
6681
|
limit: void 0,
|
|
@@ -6675,17 +6699,17 @@ var listVrrpInstancesTool = {
|
|
|
6675
6699
|
}
|
|
6676
6700
|
}
|
|
6677
6701
|
};
|
|
6678
|
-
var manageVrrpInstanceInputSchema =
|
|
6679
|
-
routerId:
|
|
6680
|
-
action:
|
|
6681
|
-
name:
|
|
6682
|
-
interface:
|
|
6683
|
-
vrid:
|
|
6684
|
-
priority:
|
|
6685
|
-
interval:
|
|
6686
|
-
version:
|
|
6687
|
-
comment:
|
|
6688
|
-
dryRun:
|
|
6702
|
+
var manageVrrpInstanceInputSchema = z30.object({
|
|
6703
|
+
routerId: z30.string().describe("Target router identifier from the router registry"),
|
|
6704
|
+
action: z30.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
6705
|
+
name: z30.string().describe("VRRP interface name \u2014 idempotency key"),
|
|
6706
|
+
interface: z30.string().optional().describe("Master interface (required for add)"),
|
|
6707
|
+
vrid: z30.number().int().min(1).max(255).optional().describe("Virtual router ID (required for add)"),
|
|
6708
|
+
priority: z30.number().int().min(1).max(254).default(100).describe("Router priority (1\u2013254)"),
|
|
6709
|
+
interval: z30.number().int().min(1).optional().describe("Advertisement interval in seconds"),
|
|
6710
|
+
version: z30.enum(["2", "3"]).default("3").describe("VRRP protocol version"),
|
|
6711
|
+
comment: z30.string().optional().describe("Optional comment"),
|
|
6712
|
+
dryRun: z30.boolean().default(false).describe("Preview changes without applying")
|
|
6689
6713
|
}).strict();
|
|
6690
6714
|
var manageVrrpInstanceTool = {
|
|
6691
6715
|
name: "manage_vrrp_instance",
|
|
@@ -6701,7 +6725,7 @@ var manageVrrpInstanceTool = {
|
|
|
6701
6725
|
snapshotPaths: ["interface/vrrp"],
|
|
6702
6726
|
async handler(params, context) {
|
|
6703
6727
|
const parsed = manageVrrpInstanceInputSchema.parse(params);
|
|
6704
|
-
|
|
6728
|
+
log31.info(
|
|
6705
6729
|
{ routerId: context.routerId, action: parsed.action, name: parsed.name },
|
|
6706
6730
|
"Managing VRRP instance"
|
|
6707
6731
|
);
|
|
@@ -6785,7 +6809,7 @@ var manageVrrpInstanceTool = {
|
|
|
6785
6809
|
if (parsed.interval) body.interval = String(parsed.interval);
|
|
6786
6810
|
if (parsed.comment) body.comment = parsed.comment;
|
|
6787
6811
|
const created = await context.routerClient.create("interface/vrrp", body);
|
|
6788
|
-
|
|
6812
|
+
log31.info({ name: parsed.name, id: created[".id"] }, "VRRP instance added");
|
|
6789
6813
|
return {
|
|
6790
6814
|
content: `Added VRRP instance "${parsed.name}".`,
|
|
6791
6815
|
structuredContent: { action: "created", instance: created }
|
|
@@ -6808,7 +6832,7 @@ var manageVrrpInstanceTool = {
|
|
|
6808
6832
|
};
|
|
6809
6833
|
}
|
|
6810
6834
|
await context.routerClient.remove("interface/vrrp", existing[".id"]);
|
|
6811
|
-
|
|
6835
|
+
log31.info({ name: parsed.name }, "VRRP instance removed");
|
|
6812
6836
|
return {
|
|
6813
6837
|
content: `Removed VRRP instance "${parsed.name}".`,
|
|
6814
6838
|
structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
|
|
@@ -6847,7 +6871,7 @@ var manageVrrpInstanceTool = {
|
|
|
6847
6871
|
disabled: disabledValue
|
|
6848
6872
|
});
|
|
6849
6873
|
const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
|
|
6850
|
-
|
|
6874
|
+
log31.info({ name: parsed.name, action: resultAction }, "VRRP instance updated");
|
|
6851
6875
|
return {
|
|
6852
6876
|
content: `VRRP instance "${parsed.name}" ${resultAction}.`,
|
|
6853
6877
|
structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
|
|
@@ -6861,10 +6885,10 @@ var manageVrrpInstanceTool = {
|
|
|
6861
6885
|
var vrrpTools = [listVrrpInstancesTool, manageVrrpInstanceTool];
|
|
6862
6886
|
|
|
6863
6887
|
// src/domain/tools/network-services-tools.ts
|
|
6864
|
-
import { z as
|
|
6865
|
-
var
|
|
6866
|
-
var getSnmpSettingsInputSchema =
|
|
6867
|
-
routerId:
|
|
6888
|
+
import { z as z31 } from "zod";
|
|
6889
|
+
var log32 = createLogger("network-services-tools");
|
|
6890
|
+
var getSnmpSettingsInputSchema = z31.object({
|
|
6891
|
+
routerId: z31.string().describe("Target router identifier from the router registry")
|
|
6868
6892
|
}).strict();
|
|
6869
6893
|
var getSnmpSettingsTool = {
|
|
6870
6894
|
name: "get_snmp_settings",
|
|
@@ -6879,7 +6903,7 @@ var getSnmpSettingsTool = {
|
|
|
6879
6903
|
},
|
|
6880
6904
|
async handler(params, context) {
|
|
6881
6905
|
const parsed = getSnmpSettingsInputSchema.parse(params);
|
|
6882
|
-
|
|
6906
|
+
log32.info({ routerId: context.routerId }, "Fetching SNMP settings");
|
|
6883
6907
|
try {
|
|
6884
6908
|
const results2 = await context.routerClient.get("snmp");
|
|
6885
6909
|
if (!results2 || results2.length === 0) {
|
|
@@ -6908,8 +6932,8 @@ var getSnmpSettingsTool = {
|
|
|
6908
6932
|
}
|
|
6909
6933
|
}
|
|
6910
6934
|
};
|
|
6911
|
-
var getNtpSettingsInputSchema =
|
|
6912
|
-
routerId:
|
|
6935
|
+
var getNtpSettingsInputSchema = z31.object({
|
|
6936
|
+
routerId: z31.string().describe("Target router identifier from the router registry")
|
|
6913
6937
|
}).strict();
|
|
6914
6938
|
var getNtpSettingsTool = {
|
|
6915
6939
|
name: "get_ntp_settings",
|
|
@@ -6924,7 +6948,7 @@ var getNtpSettingsTool = {
|
|
|
6924
6948
|
},
|
|
6925
6949
|
async handler(params, context) {
|
|
6926
6950
|
const parsed = getNtpSettingsInputSchema.parse(params);
|
|
6927
|
-
|
|
6951
|
+
log32.info({ routerId: context.routerId }, "Fetching NTP settings");
|
|
6928
6952
|
try {
|
|
6929
6953
|
const results2 = await context.routerClient.get("system/ntp/client");
|
|
6930
6954
|
if (!results2 || results2.length === 0) {
|
|
@@ -6953,11 +6977,11 @@ var getNtpSettingsTool = {
|
|
|
6953
6977
|
}
|
|
6954
6978
|
}
|
|
6955
6979
|
};
|
|
6956
|
-
var listNetwatchEntriesInputSchema =
|
|
6957
|
-
routerId:
|
|
6958
|
-
host:
|
|
6959
|
-
status:
|
|
6960
|
-
limit:
|
|
6980
|
+
var listNetwatchEntriesInputSchema = z31.object({
|
|
6981
|
+
routerId: z31.string().describe("Target router identifier from the router registry"),
|
|
6982
|
+
host: z31.string().optional().describe("Filter by host (substring match)"),
|
|
6983
|
+
status: z31.enum(["up", "down", "unknown"]).optional().describe("Filter by current status"),
|
|
6984
|
+
limit: z31.number().int().min(1).max(500).default(100).describe("Maximum number of entries to return")
|
|
6961
6985
|
}).strict();
|
|
6962
6986
|
var listNetwatchEntriesTool = {
|
|
6963
6987
|
name: "list_netwatch_entries",
|
|
@@ -6972,7 +6996,7 @@ var listNetwatchEntriesTool = {
|
|
|
6972
6996
|
},
|
|
6973
6997
|
async handler(params, context) {
|
|
6974
6998
|
const parsed = listNetwatchEntriesInputSchema.parse(params);
|
|
6975
|
-
|
|
6999
|
+
log32.info({ routerId: context.routerId }, "Listing Netwatch entries");
|
|
6976
7000
|
try {
|
|
6977
7001
|
const allEntries = await context.routerClient.get("tool/netwatch", {
|
|
6978
7002
|
limit: void 0,
|
|
@@ -6994,15 +7018,15 @@ var listNetwatchEntriesTool = {
|
|
|
6994
7018
|
}
|
|
6995
7019
|
}
|
|
6996
7020
|
};
|
|
6997
|
-
var manageNetwatchEntryInputSchema =
|
|
6998
|
-
routerId:
|
|
6999
|
-
action:
|
|
7000
|
-
host:
|
|
7001
|
-
port:
|
|
7002
|
-
interval:
|
|
7003
|
-
timeout:
|
|
7004
|
-
comment:
|
|
7005
|
-
dryRun:
|
|
7021
|
+
var manageNetwatchEntryInputSchema = z31.object({
|
|
7022
|
+
routerId: z31.string().describe("Target router identifier from the router registry"),
|
|
7023
|
+
action: z31.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
7024
|
+
host: z31.string().describe("Host to monitor \u2014 idempotency key"),
|
|
7025
|
+
port: z31.number().int().min(1).max(65535).optional().describe("TCP port (optional; ICMP if omitted)"),
|
|
7026
|
+
interval: z31.string().optional().describe("Check interval (e.g. '1m'; default '1m')"),
|
|
7027
|
+
timeout: z31.string().optional().describe("Probe timeout (e.g. '500ms')"),
|
|
7028
|
+
comment: z31.string().optional().describe("Optional comment"),
|
|
7029
|
+
dryRun: z31.boolean().default(false).describe("Preview changes without applying")
|
|
7006
7030
|
}).strict();
|
|
7007
7031
|
var manageNetwatchEntryTool = {
|
|
7008
7032
|
name: "manage_netwatch_entry",
|
|
@@ -7018,7 +7042,7 @@ var manageNetwatchEntryTool = {
|
|
|
7018
7042
|
snapshotPaths: ["tool/netwatch"],
|
|
7019
7043
|
async handler(params, context) {
|
|
7020
7044
|
const parsed = manageNetwatchEntryInputSchema.parse(params);
|
|
7021
|
-
|
|
7045
|
+
log32.info(
|
|
7022
7046
|
{ routerId: context.routerId, action: parsed.action, host: parsed.host },
|
|
7023
7047
|
"Managing Netwatch entry"
|
|
7024
7048
|
);
|
|
@@ -7075,7 +7099,7 @@ var manageNetwatchEntryTool = {
|
|
|
7075
7099
|
if (parsed.timeout) body.timeout = parsed.timeout;
|
|
7076
7100
|
if (parsed.comment) body.comment = parsed.comment;
|
|
7077
7101
|
const created = await context.routerClient.create("tool/netwatch", body);
|
|
7078
|
-
|
|
7102
|
+
log32.info({ host: parsed.host, id: created[".id"] }, "Netwatch entry added");
|
|
7079
7103
|
return {
|
|
7080
7104
|
content: `Added Netwatch entry for "${parsed.host}".`,
|
|
7081
7105
|
structuredContent: { action: "created", entry: created }
|
|
@@ -7099,7 +7123,7 @@ var manageNetwatchEntryTool = {
|
|
|
7099
7123
|
};
|
|
7100
7124
|
}
|
|
7101
7125
|
await context.routerClient.remove("tool/netwatch", existing2[".id"]);
|
|
7102
|
-
|
|
7126
|
+
log32.info({ host: parsed.host }, "Netwatch entry removed");
|
|
7103
7127
|
return {
|
|
7104
7128
|
content: `Removed Netwatch entry for "${parsed.host}".`,
|
|
7105
7129
|
structuredContent: { action: "removed", host: parsed.host, id: existing2[".id"] }
|
|
@@ -7139,7 +7163,7 @@ var manageNetwatchEntryTool = {
|
|
|
7139
7163
|
disabled: disabledValue
|
|
7140
7164
|
});
|
|
7141
7165
|
const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
|
|
7142
|
-
|
|
7166
|
+
log32.info({ host: parsed.host, action: resultAction }, "Netwatch entry updated");
|
|
7143
7167
|
return {
|
|
7144
7168
|
content: `Netwatch entry for "${parsed.host}" ${resultAction}.`,
|
|
7145
7169
|
structuredContent: { action: resultAction, host: parsed.host, id: existing[".id"] }
|
|
@@ -7150,10 +7174,10 @@ var manageNetwatchEntryTool = {
|
|
|
7150
7174
|
}
|
|
7151
7175
|
}
|
|
7152
7176
|
};
|
|
7153
|
-
var listNeighborsInputSchema =
|
|
7154
|
-
routerId:
|
|
7155
|
-
interface:
|
|
7156
|
-
limit:
|
|
7177
|
+
var listNeighborsInputSchema = z31.object({
|
|
7178
|
+
routerId: z31.string().describe("Target router identifier from the router registry"),
|
|
7179
|
+
interface: z31.string().optional().describe("Filter by interface name (substring match)"),
|
|
7180
|
+
limit: z31.number().int().min(1).max(500).default(100).describe("Maximum number of neighbors to return")
|
|
7157
7181
|
}).strict();
|
|
7158
7182
|
var listNeighborsTool = {
|
|
7159
7183
|
name: "list_neighbors",
|
|
@@ -7168,7 +7192,7 @@ var listNeighborsTool = {
|
|
|
7168
7192
|
},
|
|
7169
7193
|
async handler(params, context) {
|
|
7170
7194
|
const parsed = listNeighborsInputSchema.parse(params);
|
|
7171
|
-
|
|
7195
|
+
log32.info({ routerId: context.routerId }, "Listing neighbors");
|
|
7172
7196
|
try {
|
|
7173
7197
|
const allNeighbors = await context.routerClient.get("ip/neighbor", {
|
|
7174
7198
|
limit: void 0,
|
|
@@ -7192,12 +7216,12 @@ var listNeighborsTool = {
|
|
|
7192
7216
|
}
|
|
7193
7217
|
}
|
|
7194
7218
|
};
|
|
7195
|
-
var listArpEntriesInputSchema =
|
|
7196
|
-
routerId:
|
|
7197
|
-
interface:
|
|
7198
|
-
address:
|
|
7199
|
-
macAddress:
|
|
7200
|
-
limit:
|
|
7219
|
+
var listArpEntriesInputSchema = z31.object({
|
|
7220
|
+
routerId: z31.string().describe("Target router identifier from the router registry"),
|
|
7221
|
+
interface: z31.string().optional().describe("Filter by interface (substring match)"),
|
|
7222
|
+
address: z31.string().optional().describe("Filter by IP address (substring match)"),
|
|
7223
|
+
macAddress: z31.string().optional().describe("Filter by MAC address (substring match)"),
|
|
7224
|
+
limit: z31.number().int().min(1).max(500).default(100).describe("Maximum number of ARP entries to return")
|
|
7201
7225
|
}).strict();
|
|
7202
7226
|
var listArpEntriesTool = {
|
|
7203
7227
|
name: "list_arp_entries",
|
|
@@ -7212,7 +7236,7 @@ var listArpEntriesTool = {
|
|
|
7212
7236
|
},
|
|
7213
7237
|
async handler(params, context) {
|
|
7214
7238
|
const parsed = listArpEntriesInputSchema.parse(params);
|
|
7215
|
-
|
|
7239
|
+
log32.info({ routerId: context.routerId }, "Listing ARP entries");
|
|
7216
7240
|
try {
|
|
7217
7241
|
const allEntries = await context.routerClient.get("ip/arp", {
|
|
7218
7242
|
limit: void 0,
|
|
@@ -7245,51 +7269,602 @@ var networkServicesTools = [
|
|
|
7245
7269
|
listArpEntriesTool
|
|
7246
7270
|
];
|
|
7247
7271
|
|
|
7248
|
-
// src/domain/tools/
|
|
7249
|
-
import { z as
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
const prefix = pattern.slice(0, pattern.indexOf("*"));
|
|
7255
|
-
return name.startsWith(prefix);
|
|
7256
|
-
}
|
|
7257
|
-
function checkAuthz(identity, toolName, routerId) {
|
|
7258
|
-
if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId)) {
|
|
7259
|
-
throw new MikroMCPError({
|
|
7260
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7261
|
-
code: "ROUTER_NOT_ALLOWED",
|
|
7262
|
-
message: `Identity "${identity.id}" is not allowed to access router "${routerId}".`,
|
|
7263
|
-
details: { identityId: identity.id, routerId, allowedRouters: identity.allowedRouters },
|
|
7264
|
-
recoverability: {
|
|
7265
|
-
retryable: false,
|
|
7266
|
-
suggestedAction: `Add "${routerId}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
|
|
7267
|
-
}
|
|
7268
|
-
});
|
|
7269
|
-
}
|
|
7270
|
-
if (identity.allowedToolPatterns.length > 0) {
|
|
7271
|
-
const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
|
|
7272
|
-
if (!allowed) {
|
|
7273
|
-
throw new MikroMCPError({
|
|
7274
|
-
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7275
|
-
code: "TOOL_NOT_ALLOWED",
|
|
7276
|
-
message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
|
|
7277
|
-
details: { identityId: identity.id, toolName, allowedToolPatterns: identity.allowedToolPatterns },
|
|
7278
|
-
recoverability: {
|
|
7279
|
-
retryable: false,
|
|
7280
|
-
suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
|
|
7281
|
-
}
|
|
7282
|
-
});
|
|
7283
|
-
}
|
|
7284
|
-
}
|
|
7272
|
+
// src/domain/tools/vlan-tools.ts
|
|
7273
|
+
import { z as z32 } from "zod";
|
|
7274
|
+
var log33 = createLogger("vlan-tools");
|
|
7275
|
+
function sanitizeComment4(comment) {
|
|
7276
|
+
if (comment === void 0) return void 0;
|
|
7277
|
+
return comment.replace(/[\x00-\x1f\x7f]/g, "");
|
|
7285
7278
|
}
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7279
|
+
var manageVlanInputSchema = z32.object({
|
|
7280
|
+
routerId: z32.string().describe("Target router identifier from the router registry"),
|
|
7281
|
+
action: z32.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
7282
|
+
name: z32.string().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe("VLAN interface name \u2014 idempotency key (alphanumeric, hyphens, underscores, max 15 chars)"),
|
|
7283
|
+
vlanId: z32.number().int().min(1).max(4094).optional().describe("VLAN ID (1-4094; required for add)"),
|
|
7284
|
+
parentInterface: z32.string().optional().describe("Parent interface name (e.g., ether1, bridge1; required for add)"),
|
|
7285
|
+
mtu: z32.number().int().min(68).max(9e3).default(1500).describe("MTU size (add only)"),
|
|
7286
|
+
disabled: z32.boolean().default(false).describe("Whether to create the VLAN disabled (add only)"),
|
|
7287
|
+
comment: z32.string().max(255).optional().describe("Optional comment (add only)"),
|
|
7288
|
+
dryRun: z32.boolean().default(false).describe("Preview changes without applying")
|
|
7289
|
+
}).strict();
|
|
7290
|
+
var manageVlanTool = {
|
|
7291
|
+
name: "manage_vlan",
|
|
7292
|
+
title: "Manage VLAN Interface",
|
|
7293
|
+
description: "Add, remove, enable, or disable a VLAN interface. Idempotent by name: add returns already_exists when a VLAN with matching name, vlan-id, and parent interface exists. Supports dry-run mode.",
|
|
7294
|
+
inputSchema: manageVlanInputSchema,
|
|
7295
|
+
annotations: {
|
|
7296
|
+
readOnlyHint: false,
|
|
7297
|
+
destructiveHint: true,
|
|
7298
|
+
idempotentHint: true,
|
|
7299
|
+
openWorldHint: false
|
|
7300
|
+
},
|
|
7301
|
+
snapshotPaths: ["interface/vlan"],
|
|
7302
|
+
async handler(params, context) {
|
|
7303
|
+
const parsed = manageVlanInputSchema.parse(params);
|
|
7304
|
+
const comment = sanitizeComment4(parsed.comment);
|
|
7305
|
+
log33.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing VLAN");
|
|
7306
|
+
try {
|
|
7307
|
+
const allVlans = await context.routerClient.get("interface/vlan", {
|
|
7308
|
+
limit: void 0,
|
|
7309
|
+
offset: void 0
|
|
7310
|
+
});
|
|
7311
|
+
const existing = allVlans.find((v) => v.name === parsed.name);
|
|
7312
|
+
if (parsed.action === "add") {
|
|
7313
|
+
if (parsed.vlanId === void 0) {
|
|
7314
|
+
throw new MikroMCPError({
|
|
7315
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
7316
|
+
code: "VLAN_ID_REQUIRED",
|
|
7317
|
+
message: "vlanId is required when adding a VLAN.",
|
|
7318
|
+
details: { name: parsed.name },
|
|
7319
|
+
recoverability: { retryable: false, suggestedAction: "Provide vlanId (1-4094)." }
|
|
7320
|
+
});
|
|
7321
|
+
}
|
|
7322
|
+
if (!parsed.parentInterface) {
|
|
7323
|
+
throw new MikroMCPError({
|
|
7324
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
7325
|
+
code: "VLAN_PARENT_REQUIRED",
|
|
7326
|
+
message: "parentInterface is required when adding a VLAN.",
|
|
7327
|
+
details: { name: parsed.name },
|
|
7328
|
+
recoverability: { retryable: false, suggestedAction: "Provide the parent interface name." }
|
|
7329
|
+
});
|
|
7330
|
+
}
|
|
7331
|
+
if (existing) {
|
|
7332
|
+
if (existing["vlan-id"] === String(parsed.vlanId) && existing.interface === parsed.parentInterface) {
|
|
7333
|
+
log33.info({ name: parsed.name }, "VLAN already exists with matching config");
|
|
7334
|
+
return {
|
|
7335
|
+
content: `VLAN "${parsed.name}" already exists with VLAN ID ${parsed.vlanId} on ${parsed.parentInterface}. No changes made.`,
|
|
7336
|
+
structuredContent: { action: "already_exists", vlan: existing }
|
|
7337
|
+
};
|
|
7338
|
+
}
|
|
7339
|
+
throw new MikroMCPError({
|
|
7340
|
+
category: "CONFLICT" /* CONFLICT */,
|
|
7341
|
+
code: "VLAN_NAME_CONFLICT",
|
|
7342
|
+
message: `VLAN "${parsed.name}" already exists but with different configuration (vlan-id=${existing["vlan-id"]}, interface=${existing.interface}).`,
|
|
7343
|
+
details: {
|
|
7344
|
+
existing: { vlanId: existing["vlan-id"], interface: existing.interface },
|
|
7345
|
+
requested: { vlanId: parsed.vlanId, interface: parsed.parentInterface }
|
|
7346
|
+
},
|
|
7347
|
+
recoverability: {
|
|
7348
|
+
retryable: false,
|
|
7349
|
+
suggestedAction: "Use a different name or remove the existing VLAN first.",
|
|
7350
|
+
alternativeTools: ["manage_vlan"]
|
|
7351
|
+
}
|
|
7352
|
+
});
|
|
7353
|
+
}
|
|
7354
|
+
if (parsed.dryRun) {
|
|
7355
|
+
const diff = [
|
|
7356
|
+
{ property: "name", before: null, after: parsed.name },
|
|
7357
|
+
{ property: "vlan-id", before: null, after: String(parsed.vlanId) },
|
|
7358
|
+
{ property: "interface", before: null, after: parsed.parentInterface },
|
|
7359
|
+
{ property: "mtu", before: null, after: String(parsed.mtu) },
|
|
7360
|
+
{ property: "disabled", before: null, after: parsed.disabled ? "true" : "false" },
|
|
7361
|
+
...comment ? [{ property: "comment", before: null, after: comment }] : []
|
|
7362
|
+
];
|
|
7363
|
+
return {
|
|
7364
|
+
content: `Dry run: Would create VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
|
|
7365
|
+
structuredContent: { action: "dry_run", diff }
|
|
7366
|
+
};
|
|
7367
|
+
}
|
|
7368
|
+
const body = {
|
|
7369
|
+
name: parsed.name,
|
|
7370
|
+
"vlan-id": String(parsed.vlanId),
|
|
7371
|
+
interface: parsed.parentInterface,
|
|
7372
|
+
mtu: String(parsed.mtu),
|
|
7373
|
+
disabled: parsed.disabled ? "true" : "false"
|
|
7374
|
+
};
|
|
7375
|
+
if (comment) body.comment = comment;
|
|
7376
|
+
const created = await context.routerClient.create("interface/vlan", body);
|
|
7377
|
+
log33.info({ name: parsed.name, id: created[".id"] }, "VLAN created");
|
|
7378
|
+
return {
|
|
7379
|
+
content: `Created VLAN "${parsed.name}" (ID ${parsed.vlanId}) on ${parsed.parentInterface} with MTU ${parsed.mtu}.`,
|
|
7380
|
+
structuredContent: { action: "created", vlan: created }
|
|
7381
|
+
};
|
|
7382
|
+
}
|
|
7383
|
+
if (parsed.action === "remove") {
|
|
7384
|
+
if (!existing) {
|
|
7385
|
+
return {
|
|
7386
|
+
content: `VLAN "${parsed.name}" not found. Nothing to remove.`,
|
|
7387
|
+
structuredContent: { action: "not_found", name: parsed.name }
|
|
7388
|
+
};
|
|
7389
|
+
}
|
|
7390
|
+
if (parsed.dryRun) {
|
|
7391
|
+
return {
|
|
7392
|
+
content: `Dry run: Would remove VLAN "${parsed.name}".`,
|
|
7393
|
+
structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] }
|
|
7394
|
+
};
|
|
7395
|
+
}
|
|
7396
|
+
await context.routerClient.remove("interface/vlan", existing[".id"]);
|
|
7397
|
+
log33.info({ name: parsed.name }, "VLAN removed");
|
|
7398
|
+
return {
|
|
7399
|
+
content: `Removed VLAN "${parsed.name}".`,
|
|
7400
|
+
structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] }
|
|
7401
|
+
};
|
|
7402
|
+
}
|
|
7403
|
+
if (!existing) {
|
|
7404
|
+
throw new MikroMCPError({
|
|
7405
|
+
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
7406
|
+
code: "VLAN_NOT_FOUND",
|
|
7407
|
+
message: `VLAN "${parsed.name}" not found.`,
|
|
7408
|
+
details: { name: parsed.name },
|
|
7409
|
+
recoverability: {
|
|
7410
|
+
retryable: false,
|
|
7411
|
+
suggestedAction: "Verify the VLAN name with list_interfaces.",
|
|
7412
|
+
alternativeTools: ["list_interfaces"]
|
|
7413
|
+
}
|
|
7414
|
+
});
|
|
7415
|
+
}
|
|
7416
|
+
if (parsed.dryRun) {
|
|
7417
|
+
return {
|
|
7418
|
+
content: `Dry run: Would ${parsed.action} VLAN "${parsed.name}".`,
|
|
7419
|
+
structuredContent: {
|
|
7420
|
+
action: "dry_run",
|
|
7421
|
+
diff: [{ property: "disabled", before: existing.disabled, after: parsed.action === "disable" ? "true" : "false" }]
|
|
7422
|
+
}
|
|
7423
|
+
};
|
|
7424
|
+
}
|
|
7425
|
+
const disabledValue = parsed.action === "disable" ? "true" : "false";
|
|
7426
|
+
await context.routerClient.update("interface/vlan", existing[".id"], { disabled: disabledValue });
|
|
7427
|
+
const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
|
|
7428
|
+
log33.info({ name: parsed.name, action: resultAction }, "VLAN updated");
|
|
7429
|
+
return {
|
|
7430
|
+
content: `VLAN "${parsed.name}" ${resultAction}.`,
|
|
7431
|
+
structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
|
|
7432
|
+
};
|
|
7433
|
+
} catch (err) {
|
|
7434
|
+
if (err instanceof MikroMCPError) throw err;
|
|
7435
|
+
throw enrichError(err, { routerId: context.routerId, tool: "manage_vlan" });
|
|
7436
|
+
}
|
|
7437
|
+
}
|
|
7438
|
+
};
|
|
7439
|
+
var vlanTools = [manageVlanTool];
|
|
7440
|
+
|
|
7441
|
+
// src/domain/tools/dhcp-client-tools.ts
|
|
7442
|
+
import { z as z33 } from "zod";
|
|
7443
|
+
var log34 = createLogger("dhcp-client-tools");
|
|
7444
|
+
var listDhcpClientsInputSchema = z33.object({
|
|
7445
|
+
routerId: z33.string().describe("Target router identifier from the router registry"),
|
|
7446
|
+
interface: z33.string().optional().describe("Filter by interface name (exact match)"),
|
|
7447
|
+
status: z33.enum(["bound", "searching", "requesting", "init", "all"]).default("all").describe("Filter by DHCP client status"),
|
|
7448
|
+
limit: z33.number().int().min(1).max(500).default(100).describe("Maximum number of clients to return"),
|
|
7449
|
+
offset: z33.number().int().min(0).default(0).describe("Offset for pagination")
|
|
7450
|
+
}).strict();
|
|
7451
|
+
var listDhcpClientsTool = {
|
|
7452
|
+
name: "list_dhcp_clients",
|
|
7453
|
+
title: "List DHCP Clients",
|
|
7454
|
+
description: "List DHCP client configurations on a MikroTik router. Shows which interfaces obtain their IP via DHCP, current status, and assigned address.",
|
|
7455
|
+
inputSchema: listDhcpClientsInputSchema,
|
|
7456
|
+
annotations: {
|
|
7457
|
+
readOnlyHint: true,
|
|
7458
|
+
destructiveHint: false,
|
|
7459
|
+
idempotentHint: true,
|
|
7460
|
+
openWorldHint: false
|
|
7461
|
+
},
|
|
7462
|
+
async handler(params, context) {
|
|
7463
|
+
const parsed = listDhcpClientsInputSchema.parse(params);
|
|
7464
|
+
log34.info({ routerId: context.routerId }, "Listing DHCP clients");
|
|
7465
|
+
try {
|
|
7466
|
+
const allClients = await context.routerClient.get("ip/dhcp-client", {
|
|
7467
|
+
limit: void 0,
|
|
7468
|
+
offset: void 0
|
|
7469
|
+
});
|
|
7470
|
+
let filtered = allClients;
|
|
7471
|
+
if (parsed.interface) {
|
|
7472
|
+
filtered = filtered.filter((c) => c.interface === parsed.interface);
|
|
7473
|
+
}
|
|
7474
|
+
if (parsed.status !== "all") {
|
|
7475
|
+
filtered = filtered.filter((c) => c.status === parsed.status);
|
|
7476
|
+
}
|
|
7477
|
+
const total = filtered.length;
|
|
7478
|
+
const clients = filtered.slice(parsed.offset, parsed.offset + parsed.limit);
|
|
7479
|
+
const hasMore = parsed.offset + parsed.limit < total;
|
|
7480
|
+
const lines = [
|
|
7481
|
+
`DHCP clients on ${context.routerId}: ${total} total, showing ${clients.length} (offset ${parsed.offset})`
|
|
7482
|
+
];
|
|
7483
|
+
for (const c of clients) {
|
|
7484
|
+
const rec = c;
|
|
7485
|
+
const iface = rec.interface ?? "unknown";
|
|
7486
|
+
const status = rec.status ?? "unknown";
|
|
7487
|
+
const address = rec.address ?? rec["active-address"] ?? "-";
|
|
7488
|
+
const isDisabled = rec.disabled === true || rec.disabled === "true";
|
|
7489
|
+
lines.push(` ${iface} [${status}] ${address}${isDisabled ? " DISABLED" : ""}`);
|
|
7490
|
+
}
|
|
7491
|
+
return {
|
|
7492
|
+
content: lines.join("\n"),
|
|
7493
|
+
structuredContent: {
|
|
7494
|
+
routerId: context.routerId,
|
|
7495
|
+
clients,
|
|
7496
|
+
total,
|
|
7497
|
+
hasMore,
|
|
7498
|
+
offset: parsed.offset,
|
|
7499
|
+
limit: parsed.limit
|
|
7500
|
+
}
|
|
7501
|
+
};
|
|
7502
|
+
} catch (err) {
|
|
7503
|
+
throw enrichError(err, { routerId: context.routerId, tool: "list_dhcp_clients" });
|
|
7504
|
+
}
|
|
7505
|
+
}
|
|
7506
|
+
};
|
|
7507
|
+
var manageDhcpClientInputSchema = z33.object({
|
|
7508
|
+
routerId: z33.string().describe("Target router identifier from the router registry"),
|
|
7509
|
+
action: z33.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
|
|
7510
|
+
interface: z33.string().describe("Interface name \u2014 idempotency key (e.g. ether1, ether2)"),
|
|
7511
|
+
usePeerDns: z33.boolean().default(true).describe("Use DNS servers provided by DHCP server (add only)"),
|
|
7512
|
+
usePeerNtp: z33.boolean().default(false).describe("Use NTP servers provided by DHCP server (add only)"),
|
|
7513
|
+
addDefaultRoute: z33.boolean().default(true).describe("Add default route from DHCP (add only)"),
|
|
7514
|
+
comment: z33.string().max(255).optional().describe("Optional comment (add only)"),
|
|
7515
|
+
dryRun: z33.boolean().default(false).describe("Preview changes without applying")
|
|
7516
|
+
}).strict();
|
|
7517
|
+
var manageDhcpClientTool = {
|
|
7518
|
+
name: "manage_dhcp_client",
|
|
7519
|
+
title: "Manage DHCP Client",
|
|
7520
|
+
description: "Add, remove, enable, or disable a DHCP client on an interface. Idempotent by interface name: add returns already_exists if a DHCP client is already configured on the same interface.",
|
|
7521
|
+
inputSchema: manageDhcpClientInputSchema,
|
|
7522
|
+
annotations: {
|
|
7523
|
+
readOnlyHint: false,
|
|
7524
|
+
destructiveHint: true,
|
|
7525
|
+
idempotentHint: true,
|
|
7526
|
+
openWorldHint: false
|
|
7527
|
+
},
|
|
7528
|
+
snapshotPaths: ["ip/dhcp-client"],
|
|
7529
|
+
async handler(params, context) {
|
|
7530
|
+
const parsed = manageDhcpClientInputSchema.parse(params);
|
|
7531
|
+
log34.info({ routerId: context.routerId, action: parsed.action, interface: parsed.interface }, "Managing DHCP client");
|
|
7532
|
+
try {
|
|
7533
|
+
const allClients = await context.routerClient.get("ip/dhcp-client", {
|
|
7534
|
+
limit: void 0,
|
|
7535
|
+
offset: void 0
|
|
7536
|
+
});
|
|
7537
|
+
const existing = allClients.find(
|
|
7538
|
+
(c) => c.interface === parsed.interface
|
|
7539
|
+
);
|
|
7540
|
+
if (parsed.action === "add") {
|
|
7541
|
+
if (existing) {
|
|
7542
|
+
return {
|
|
7543
|
+
content: `DHCP client already configured on "${parsed.interface}". No changes made.`,
|
|
7544
|
+
structuredContent: { action: "already_exists", client: existing }
|
|
7545
|
+
};
|
|
7546
|
+
}
|
|
7547
|
+
if (parsed.dryRun) {
|
|
7548
|
+
const diff = [
|
|
7549
|
+
{ property: "interface", before: null, after: parsed.interface },
|
|
7550
|
+
{ property: "use-peer-dns", before: null, after: parsed.usePeerDns ? "yes" : "no" },
|
|
7551
|
+
{ property: "use-peer-ntp", before: null, after: parsed.usePeerNtp ? "yes" : "no" },
|
|
7552
|
+
{ property: "add-default-route", before: null, after: parsed.addDefaultRoute ? "yes" : "no" }
|
|
7553
|
+
];
|
|
7554
|
+
return {
|
|
7555
|
+
content: `Dry run: Would add DHCP client on "${parsed.interface}".`,
|
|
7556
|
+
structuredContent: { action: "dry_run", diff }
|
|
7557
|
+
};
|
|
7558
|
+
}
|
|
7559
|
+
const body = {
|
|
7560
|
+
interface: parsed.interface,
|
|
7561
|
+
"use-peer-dns": parsed.usePeerDns ? "yes" : "no",
|
|
7562
|
+
"use-peer-ntp": parsed.usePeerNtp ? "yes" : "no",
|
|
7563
|
+
"add-default-route": parsed.addDefaultRoute ? "yes" : "no"
|
|
7564
|
+
};
|
|
7565
|
+
if (parsed.comment) body.comment = parsed.comment;
|
|
7566
|
+
const created = await context.routerClient.create("ip/dhcp-client", body);
|
|
7567
|
+
log34.info({ interface: parsed.interface, id: created[".id"] }, "DHCP client added");
|
|
7568
|
+
return {
|
|
7569
|
+
content: `Added DHCP client on "${parsed.interface}".`,
|
|
7570
|
+
structuredContent: { action: "created", client: created }
|
|
7571
|
+
};
|
|
7572
|
+
}
|
|
7573
|
+
if (parsed.action === "remove") {
|
|
7574
|
+
if (!existing) {
|
|
7575
|
+
return {
|
|
7576
|
+
content: `No DHCP client found on "${parsed.interface}". Nothing to remove.`,
|
|
7577
|
+
structuredContent: { action: "not_found", interface: parsed.interface }
|
|
7578
|
+
};
|
|
7579
|
+
}
|
|
7580
|
+
if (parsed.dryRun) {
|
|
7581
|
+
return {
|
|
7582
|
+
content: `Dry run: Would remove DHCP client on "${parsed.interface}".`,
|
|
7583
|
+
structuredContent: {
|
|
7584
|
+
action: "dry_run",
|
|
7585
|
+
diff: [{ property: "interface", before: parsed.interface, after: null }]
|
|
7586
|
+
}
|
|
7587
|
+
};
|
|
7588
|
+
}
|
|
7589
|
+
await context.routerClient.remove("ip/dhcp-client", existing[".id"]);
|
|
7590
|
+
log34.info({ interface: parsed.interface }, "DHCP client removed");
|
|
7591
|
+
return {
|
|
7592
|
+
content: `Removed DHCP client on "${parsed.interface}".`,
|
|
7593
|
+
structuredContent: { action: "removed", interface: parsed.interface, id: existing[".id"] }
|
|
7594
|
+
};
|
|
7595
|
+
}
|
|
7596
|
+
if (!existing) {
|
|
7597
|
+
throw new MikroMCPError({
|
|
7598
|
+
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
7599
|
+
code: "DHCP_CLIENT_NOT_FOUND",
|
|
7600
|
+
message: `No DHCP client found on interface "${parsed.interface}".`,
|
|
7601
|
+
details: { interface: parsed.interface },
|
|
7602
|
+
recoverability: {
|
|
7603
|
+
retryable: false,
|
|
7604
|
+
suggestedAction: "Verify with list_dhcp_clients.",
|
|
7605
|
+
alternativeTools: ["list_dhcp_clients"]
|
|
7606
|
+
}
|
|
7607
|
+
});
|
|
7608
|
+
}
|
|
7609
|
+
if (parsed.dryRun) {
|
|
7610
|
+
return {
|
|
7611
|
+
content: `Dry run: Would ${parsed.action} DHCP client on "${parsed.interface}".`,
|
|
7612
|
+
structuredContent: {
|
|
7613
|
+
action: "dry_run",
|
|
7614
|
+
diff: [
|
|
7615
|
+
{
|
|
7616
|
+
property: "disabled",
|
|
7617
|
+
before: existing.disabled,
|
|
7618
|
+
after: parsed.action === "disable" ? "true" : "false"
|
|
7619
|
+
}
|
|
7620
|
+
]
|
|
7621
|
+
}
|
|
7622
|
+
};
|
|
7623
|
+
}
|
|
7624
|
+
const disabledValue = parsed.action === "disable" ? "true" : "false";
|
|
7625
|
+
await context.routerClient.update("ip/dhcp-client", existing[".id"], { disabled: disabledValue });
|
|
7626
|
+
const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
|
|
7627
|
+
log34.info({ interface: parsed.interface, action: resultAction }, "DHCP client updated");
|
|
7628
|
+
return {
|
|
7629
|
+
content: `DHCP client on "${parsed.interface}" ${resultAction}.`,
|
|
7630
|
+
structuredContent: { action: resultAction, interface: parsed.interface, id: existing[".id"] }
|
|
7631
|
+
};
|
|
7632
|
+
} catch (err) {
|
|
7633
|
+
if (err instanceof MikroMCPError) throw err;
|
|
7634
|
+
throw enrichError(err, { routerId: context.routerId, tool: "manage_dhcp_client" });
|
|
7635
|
+
}
|
|
7636
|
+
}
|
|
7637
|
+
};
|
|
7638
|
+
var dhcpClientTools = [listDhcpClientsTool, manageDhcpClientTool];
|
|
7639
|
+
|
|
7640
|
+
// src/domain/tools/ip-service-tools.ts
|
|
7641
|
+
import { z as z34 } from "zod";
|
|
7642
|
+
var log35 = createLogger("ip-service-tools");
|
|
7643
|
+
var SERVICE_NAMES = ["api", "api-ssl", "ssh", "telnet", "www", "www-ssl", "winbox", "ftp"];
|
|
7644
|
+
var listIpServicesInputSchema = z34.object({
|
|
7645
|
+
routerId: z34.string().describe("Target router identifier from the router registry"),
|
|
7646
|
+
name: z34.enum(SERVICE_NAMES).optional().describe("Filter by service name (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp)"),
|
|
7647
|
+
enabled: z34.boolean().optional().describe("When true, return only enabled services; when false, only disabled services")
|
|
7648
|
+
}).strict();
|
|
7649
|
+
var listIpServicesTool = {
|
|
7650
|
+
name: "list_ip_services",
|
|
7651
|
+
title: "List IP Services",
|
|
7652
|
+
description: "List IP services on a MikroTik router (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp) with their port numbers and enabled/disabled status.",
|
|
7653
|
+
inputSchema: listIpServicesInputSchema,
|
|
7654
|
+
annotations: {
|
|
7655
|
+
readOnlyHint: true,
|
|
7656
|
+
destructiveHint: false,
|
|
7657
|
+
idempotentHint: true,
|
|
7658
|
+
openWorldHint: false
|
|
7659
|
+
},
|
|
7660
|
+
async handler(params, context) {
|
|
7661
|
+
const parsed = listIpServicesInputSchema.parse(params);
|
|
7662
|
+
log35.info({ routerId: context.routerId }, "Listing IP services");
|
|
7663
|
+
try {
|
|
7664
|
+
const allServices = await context.routerClient.get("ip/service", {
|
|
7665
|
+
limit: void 0,
|
|
7666
|
+
offset: void 0
|
|
7667
|
+
});
|
|
7668
|
+
let filtered = allServices;
|
|
7669
|
+
if (parsed.name) {
|
|
7670
|
+
filtered = filtered.filter((s) => s.name === parsed.name);
|
|
7671
|
+
}
|
|
7672
|
+
if (parsed.enabled !== void 0) {
|
|
7673
|
+
filtered = filtered.filter((s) => {
|
|
7674
|
+
const isDisabled = s.disabled === true || s.disabled === "true";
|
|
7675
|
+
return parsed.enabled ? !isDisabled : isDisabled;
|
|
7676
|
+
});
|
|
7677
|
+
}
|
|
7678
|
+
const lines = [`IP services on ${context.routerId}: ${filtered.length} returned`];
|
|
7679
|
+
for (const s of filtered) {
|
|
7680
|
+
const rec = s;
|
|
7681
|
+
const isDisabled = rec.disabled === true || rec.disabled === "true";
|
|
7682
|
+
lines.push(` ${rec.name} port:${rec.port ?? "-"} [${isDisabled ? "DISABLED" : "enabled"}]`);
|
|
7683
|
+
}
|
|
7684
|
+
return {
|
|
7685
|
+
content: lines.join("\n"),
|
|
7686
|
+
structuredContent: {
|
|
7687
|
+
routerId: context.routerId,
|
|
7688
|
+
services: filtered,
|
|
7689
|
+
total: filtered.length
|
|
7690
|
+
}
|
|
7691
|
+
};
|
|
7692
|
+
} catch (err) {
|
|
7693
|
+
throw enrichError(err, { routerId: context.routerId, tool: "list_ip_services" });
|
|
7694
|
+
}
|
|
7695
|
+
}
|
|
7696
|
+
};
|
|
7697
|
+
var manageIpServiceInputSchema = z34.object({
|
|
7698
|
+
routerId: z34.string().describe("Target router identifier from the router registry"),
|
|
7699
|
+
action: z34.enum(["enable", "disable"]).describe(
|
|
7700
|
+
"Action to perform \u2014 only enable/disable to prevent accidental lockout from changing ports"
|
|
7701
|
+
),
|
|
7702
|
+
name: z34.enum(SERVICE_NAMES).describe("Service name to manage (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp)"),
|
|
7703
|
+
dryRun: z34.boolean().default(false).describe("Preview changes without applying")
|
|
7704
|
+
}).strict();
|
|
7705
|
+
var manageIpServiceTool = {
|
|
7706
|
+
name: "manage_ip_service",
|
|
7707
|
+
title: "Manage IP Service",
|
|
7708
|
+
description: "Enable or disable a RouterOS IP service (api, api-ssl, ssh, telnet, www, www-ssl, winbox, ftp). Port number changes are intentionally not supported to prevent accidental lockout.",
|
|
7709
|
+
inputSchema: manageIpServiceInputSchema,
|
|
7710
|
+
annotations: {
|
|
7711
|
+
readOnlyHint: false,
|
|
7712
|
+
destructiveHint: true,
|
|
7713
|
+
idempotentHint: true,
|
|
7714
|
+
openWorldHint: false
|
|
7715
|
+
},
|
|
7716
|
+
async handler(params, context) {
|
|
7717
|
+
const parsed = manageIpServiceInputSchema.parse(params);
|
|
7718
|
+
log35.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing IP service");
|
|
7719
|
+
try {
|
|
7720
|
+
const allServices = await context.routerClient.get("ip/service", {
|
|
7721
|
+
limit: void 0,
|
|
7722
|
+
offset: void 0
|
|
7723
|
+
});
|
|
7724
|
+
const existing = allServices.find((s) => s.name === parsed.name);
|
|
7725
|
+
if (!existing) {
|
|
7726
|
+
throw new MikroMCPError({
|
|
7727
|
+
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
7728
|
+
code: "IP_SERVICE_NOT_FOUND",
|
|
7729
|
+
message: `IP service "${parsed.name}" not found on router.`,
|
|
7730
|
+
details: { name: parsed.name },
|
|
7731
|
+
recoverability: {
|
|
7732
|
+
retryable: false,
|
|
7733
|
+
suggestedAction: "Verify service name with list_ip_services.",
|
|
7734
|
+
alternativeTools: ["list_ip_services"]
|
|
7735
|
+
}
|
|
7736
|
+
});
|
|
7737
|
+
}
|
|
7738
|
+
const isCurrentlyDisabled = existing.disabled === "true" || existing.disabled === true;
|
|
7739
|
+
const wouldBeDisabled = parsed.action === "disable";
|
|
7740
|
+
if (isCurrentlyDisabled === wouldBeDisabled) {
|
|
7741
|
+
const alreadyState = parsed.action === "disable" ? "disabled" : "enabled";
|
|
7742
|
+
return {
|
|
7743
|
+
content: `IP service "${parsed.name}" is already ${alreadyState}. No changes made.`,
|
|
7744
|
+
structuredContent: { action: "no_change", name: parsed.name, id: existing[".id"] }
|
|
7745
|
+
};
|
|
7746
|
+
}
|
|
7747
|
+
if (parsed.dryRun) {
|
|
7748
|
+
return {
|
|
7749
|
+
content: `Dry run: Would ${parsed.action} IP service "${parsed.name}".`,
|
|
7750
|
+
structuredContent: {
|
|
7751
|
+
action: "dry_run",
|
|
7752
|
+
diff: [
|
|
7753
|
+
{
|
|
7754
|
+
property: "disabled",
|
|
7755
|
+
before: existing.disabled,
|
|
7756
|
+
after: parsed.action === "disable" ? "true" : "false"
|
|
7757
|
+
}
|
|
7758
|
+
]
|
|
7759
|
+
}
|
|
7760
|
+
};
|
|
7761
|
+
}
|
|
7762
|
+
const disabledValue = parsed.action === "disable" ? "true" : "false";
|
|
7763
|
+
await context.routerClient.update("ip/service", existing[".id"], { disabled: disabledValue });
|
|
7764
|
+
const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
|
|
7765
|
+
log35.info({ name: parsed.name, action: resultAction }, "IP service updated");
|
|
7766
|
+
return {
|
|
7767
|
+
content: `IP service "${parsed.name}" ${resultAction}.`,
|
|
7768
|
+
structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] }
|
|
7769
|
+
};
|
|
7770
|
+
} catch (err) {
|
|
7771
|
+
if (err instanceof MikroMCPError) throw err;
|
|
7772
|
+
throw enrichError(err, { routerId: context.routerId, tool: "manage_ip_service" });
|
|
7773
|
+
}
|
|
7774
|
+
}
|
|
7775
|
+
};
|
|
7776
|
+
var ipServiceTools = [listIpServicesTool, manageIpServiceTool];
|
|
7777
|
+
|
|
7778
|
+
// src/domain/tools/fleet-tools.ts
|
|
7779
|
+
import { z as z35 } from "zod";
|
|
7780
|
+
|
|
7781
|
+
// src/middleware/authz.ts
|
|
7782
|
+
function matchesPattern(name, pattern) {
|
|
7783
|
+
if (!pattern.includes("*")) return name === pattern;
|
|
7784
|
+
const prefix = pattern.slice(0, pattern.indexOf("*"));
|
|
7785
|
+
return name.startsWith(prefix);
|
|
7786
|
+
}
|
|
7787
|
+
function checkAuthz(identity, toolName, routerId) {
|
|
7788
|
+
if (identity.allowedRouters.length > 0 && !identity.allowedRouters.includes(routerId)) {
|
|
7789
|
+
throw new MikroMCPError({
|
|
7790
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7791
|
+
code: "ROUTER_NOT_ALLOWED",
|
|
7792
|
+
message: `Identity "${identity.id}" is not allowed to access router "${routerId}".`,
|
|
7793
|
+
details: { identityId: identity.id, routerId, allowedRouters: identity.allowedRouters },
|
|
7794
|
+
recoverability: {
|
|
7795
|
+
retryable: false,
|
|
7796
|
+
suggestedAction: `Add "${routerId}" to allowedRouters for identity "${identity.id}" in identities.yaml.`
|
|
7797
|
+
}
|
|
7798
|
+
});
|
|
7799
|
+
}
|
|
7800
|
+
if (identity.allowedToolPatterns.length > 0) {
|
|
7801
|
+
const allowed = identity.allowedToolPatterns.some((p) => matchesPattern(toolName, p));
|
|
7802
|
+
if (!allowed) {
|
|
7803
|
+
throw new MikroMCPError({
|
|
7804
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
7805
|
+
code: "TOOL_NOT_ALLOWED",
|
|
7806
|
+
message: `Identity "${identity.id}" is not allowed to call tool "${toolName}".`,
|
|
7807
|
+
details: { identityId: identity.id, toolName, allowedToolPatterns: identity.allowedToolPatterns },
|
|
7808
|
+
recoverability: {
|
|
7809
|
+
retryable: false,
|
|
7810
|
+
suggestedAction: `Add a matching pattern to allowedToolPatterns for identity "${identity.id}" in identities.yaml.`
|
|
7811
|
+
}
|
|
7812
|
+
});
|
|
7813
|
+
}
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
|
|
7817
|
+
// src/config/secrets.ts
|
|
7818
|
+
function getCredentials(router) {
|
|
7819
|
+
const { source, envPrefix, vaultPath } = router.credentials;
|
|
7820
|
+
if (source === "vault") {
|
|
7821
|
+
throw new MikroMCPError({
|
|
7822
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7823
|
+
code: "VAULT_NOT_SUPPORTED",
|
|
7824
|
+
message: `Vault credential source is not supported in v0.1 (vaultPath=${vaultPath ?? "n/a"})`,
|
|
7825
|
+
recoverability: {
|
|
7826
|
+
retryable: false,
|
|
7827
|
+
suggestedAction: 'Switch to source="env" and provide credentials via environment variables.'
|
|
7828
|
+
}
|
|
7829
|
+
});
|
|
7830
|
+
}
|
|
7831
|
+
if (!envPrefix) {
|
|
7832
|
+
throw new MikroMCPError({
|
|
7833
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7834
|
+
code: "MISSING_ENV_PREFIX",
|
|
7835
|
+
message: `Router "${router.id}" uses source="env" but no envPrefix is configured`,
|
|
7836
|
+
recoverability: {
|
|
7837
|
+
retryable: false,
|
|
7838
|
+
suggestedAction: "Set envPrefix in the router configuration."
|
|
7839
|
+
}
|
|
7840
|
+
});
|
|
7841
|
+
}
|
|
7842
|
+
const userKey = `${envPrefix}_USER`;
|
|
7843
|
+
const passKey = `${envPrefix}_PASS`;
|
|
7844
|
+
const username = process.env[userKey];
|
|
7845
|
+
const password = process.env[passKey];
|
|
7846
|
+
if (!username || !password) {
|
|
7847
|
+
const missing = [!username ? userKey : null, !password ? passKey : null].filter(Boolean);
|
|
7848
|
+
throw new MikroMCPError({
|
|
7849
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
7850
|
+
code: "MISSING_CREDENTIALS",
|
|
7851
|
+
message: `Missing environment variable(s) for router "${router.id}": ${missing.join(", ")}`,
|
|
7852
|
+
details: { missing },
|
|
7853
|
+
recoverability: {
|
|
7854
|
+
retryable: false,
|
|
7855
|
+
suggestedAction: `Set the ${missing.join(" and ")} environment variable(s).`
|
|
7856
|
+
}
|
|
7857
|
+
});
|
|
7858
|
+
}
|
|
7859
|
+
return { username, password };
|
|
7860
|
+
}
|
|
7861
|
+
|
|
7862
|
+
// src/adapter/ssh-client.ts
|
|
7863
|
+
import { createHash } from "crypto";
|
|
7864
|
+
import { Client } from "ssh2";
|
|
7865
|
+
var SshClient = class {
|
|
7866
|
+
constructor(config, credentials, options) {
|
|
7867
|
+
this.config = config;
|
|
7293
7868
|
this.credentials = credentials;
|
|
7294
7869
|
this.commandTimeoutMs = options?.commandTimeoutMs ?? 3e4;
|
|
7295
7870
|
this.maxOutputBytes = options?.maxOutputBytes ?? 512 * 1024;
|
|
@@ -7356,8 +7931,8 @@ var SshClient = class {
|
|
|
7356
7931
|
};
|
|
7357
7932
|
if (this.config.sshFingerprint) {
|
|
7358
7933
|
const expected = this.config.sshFingerprint.toLowerCase();
|
|
7359
|
-
connectOptions.hostVerifier = (
|
|
7360
|
-
const actual = createHash("sha256").update(
|
|
7934
|
+
connectOptions.hostVerifier = (key2) => {
|
|
7935
|
+
const actual = createHash("sha256").update(key2).digest("hex");
|
|
7361
7936
|
return actual === expected;
|
|
7362
7937
|
};
|
|
7363
7938
|
}
|
|
@@ -7369,7 +7944,7 @@ var SshClient = class {
|
|
|
7369
7944
|
// src/adapter/ftp-client.ts
|
|
7370
7945
|
import { Client as Client2 } from "basic-ftp";
|
|
7371
7946
|
import { Readable } from "stream";
|
|
7372
|
-
var
|
|
7947
|
+
var log36 = createLogger("ftp-client");
|
|
7373
7948
|
var FtpClient = class {
|
|
7374
7949
|
#host;
|
|
7375
7950
|
#port;
|
|
@@ -7382,7 +7957,7 @@ var FtpClient = class {
|
|
|
7382
7957
|
this.#password = password;
|
|
7383
7958
|
}
|
|
7384
7959
|
async upload(remoteName, content) {
|
|
7385
|
-
|
|
7960
|
+
log36.info({ host: this.#host, remoteName }, "Uploading file via FTP");
|
|
7386
7961
|
const client = new Client2();
|
|
7387
7962
|
try {
|
|
7388
7963
|
await client.access({
|
|
@@ -7392,7 +7967,7 @@ var FtpClient = class {
|
|
|
7392
7967
|
password: this.#password
|
|
7393
7968
|
});
|
|
7394
7969
|
await client.uploadFrom(Readable.from(Buffer.from(content, "utf-8")), remoteName);
|
|
7395
|
-
|
|
7970
|
+
log36.info({ host: this.#host, remoteName }, "FTP upload complete");
|
|
7396
7971
|
} finally {
|
|
7397
7972
|
client.close();
|
|
7398
7973
|
}
|
|
@@ -7413,51 +7988,6 @@ var FtpClient = class {
|
|
|
7413
7988
|
}
|
|
7414
7989
|
};
|
|
7415
7990
|
|
|
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
7991
|
// src/adapter/adapter-factory.ts
|
|
7462
7992
|
function createSshClient(routerConfig, options) {
|
|
7463
7993
|
const creds = getCredentials(routerConfig);
|
|
@@ -7468,17 +7998,96 @@ function createFtpClient(routerConfig) {
|
|
|
7468
7998
|
return new FtpClient(routerConfig.host, 21, creds.username, creds.password);
|
|
7469
7999
|
}
|
|
7470
8000
|
|
|
8001
|
+
// src/mcp/tool-context.ts
|
|
8002
|
+
function buildRouterToolContext(args) {
|
|
8003
|
+
const { routerConfig, correlationId, identity, pool, config, registry } = args;
|
|
8004
|
+
const credentials = getCredentials(routerConfig);
|
|
8005
|
+
return {
|
|
8006
|
+
routerClient: pool.getClient(routerConfig, credentials),
|
|
8007
|
+
routerId: routerConfig.id,
|
|
8008
|
+
correlationId,
|
|
8009
|
+
routerConfig,
|
|
8010
|
+
sshClient: createSshClient(routerConfig, config.ssh),
|
|
8011
|
+
ftpClient: createFtpClient(routerConfig),
|
|
8012
|
+
identity,
|
|
8013
|
+
routerRegistry: registry,
|
|
8014
|
+
connectionPool: pool,
|
|
8015
|
+
appConfig: config
|
|
8016
|
+
};
|
|
8017
|
+
}
|
|
8018
|
+
|
|
8019
|
+
// src/middleware/fleet-confirmation.ts
|
|
8020
|
+
import { createHmac, createHash as createHash2 } from "crypto";
|
|
8021
|
+
var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
|
|
8022
|
+
var pending = /* @__PURE__ */ new Map();
|
|
8023
|
+
function fingerprint(args) {
|
|
8024
|
+
const sortedRouters = [...args.routerIds].sort();
|
|
8025
|
+
const payload = JSON.stringify({
|
|
8026
|
+
toolName: args.toolName,
|
|
8027
|
+
routerIds: sortedRouters,
|
|
8028
|
+
params: args.params,
|
|
8029
|
+
identityId: args.identityId
|
|
8030
|
+
});
|
|
8031
|
+
return createHash2("sha256").update(payload).digest("hex");
|
|
8032
|
+
}
|
|
8033
|
+
function evictExpired() {
|
|
8034
|
+
const now = /* @__PURE__ */ new Date();
|
|
8035
|
+
for (const [token, entry] of pending) {
|
|
8036
|
+
if (entry.expiresAt <= now) pending.delete(token);
|
|
8037
|
+
}
|
|
8038
|
+
}
|
|
8039
|
+
function checkFleetConfirmation(args, secret) {
|
|
8040
|
+
evictExpired();
|
|
8041
|
+
const fp = fingerprint(args);
|
|
8042
|
+
if (args.submittedToken) {
|
|
8043
|
+
const entry = pending.get(args.submittedToken);
|
|
8044
|
+
if (!entry || entry.expiresAt <= /* @__PURE__ */ new Date() || entry.fingerprint !== fp) {
|
|
8045
|
+
throw new MikroMCPError({
|
|
8046
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
8047
|
+
code: "FLEET_CONFIRMATION_MISMATCH",
|
|
8048
|
+
message: "Fleet confirmation token is expired, already used, or does not match the current toolName/routerIds/params.",
|
|
8049
|
+
recoverability: {
|
|
8050
|
+
retryable: true,
|
|
8051
|
+
suggestedAction: "Re-submit bulk_execute without confirmationToken to get a fresh token."
|
|
8052
|
+
}
|
|
8053
|
+
});
|
|
8054
|
+
}
|
|
8055
|
+
pending.delete(args.submittedToken);
|
|
8056
|
+
return;
|
|
8057
|
+
}
|
|
8058
|
+
const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS);
|
|
8059
|
+
const token = createHmac("sha256", secret).update(`${fp}:${expiresAt.toISOString()}`).digest("hex");
|
|
8060
|
+
pending.set(token, { expiresAt, fingerprint: fp });
|
|
8061
|
+
throw new MikroMCPError({
|
|
8062
|
+
category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
|
|
8063
|
+
code: "FLEET_CONFIRMATION_REQUIRED",
|
|
8064
|
+
message: `This will run destructive tool "${args.toolName}" across ${args.routerIds.length} router(s). Re-submit with confirmationToken to proceed.`,
|
|
8065
|
+
details: {
|
|
8066
|
+
confirmationToken: token,
|
|
8067
|
+
expiresAt: expiresAt.toISOString(),
|
|
8068
|
+
routerCount: args.routerIds.length
|
|
8069
|
+
},
|
|
8070
|
+
recoverability: {
|
|
8071
|
+
retryable: true,
|
|
8072
|
+
suggestedAction: "Re-submit the identical bulk_execute call with details.confirmationToken."
|
|
8073
|
+
}
|
|
8074
|
+
});
|
|
8075
|
+
}
|
|
8076
|
+
|
|
7471
8077
|
// src/domain/tools/fleet-tools.ts
|
|
7472
|
-
var
|
|
7473
|
-
var checkHealthInputSchema =
|
|
7474
|
-
routerId:
|
|
8078
|
+
var log37 = createLogger("fleet-tools");
|
|
8079
|
+
var checkHealthInputSchema = z35.object({
|
|
8080
|
+
routerId: z35.string().describe("Target router to health-check")
|
|
7475
8081
|
}).strict();
|
|
7476
|
-
var bulkExecuteInputSchema =
|
|
7477
|
-
toolName:
|
|
7478
|
-
routerIds:
|
|
7479
|
-
tags:
|
|
7480
|
-
params:
|
|
7481
|
-
concurrency:
|
|
8082
|
+
var bulkExecuteInputSchema = z35.object({
|
|
8083
|
+
toolName: z35.string().describe("Name of the tool to fan out (must be a single-router tool)"),
|
|
8084
|
+
routerIds: z35.array(z35.string()).optional().describe("Explicit list of router IDs to target"),
|
|
8085
|
+
tags: z35.array(z35.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
|
|
8086
|
+
params: z35.record(z35.string(), z35.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
|
|
8087
|
+
concurrency: z35.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
|
|
8088
|
+
confirmationToken: z35.string().optional().describe(
|
|
8089
|
+
"Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
|
|
8090
|
+
)
|
|
7482
8091
|
}).strict();
|
|
7483
8092
|
function createFleetTools(baseTools2) {
|
|
7484
8093
|
const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
|
|
@@ -7495,7 +8104,7 @@ function createFleetTools(baseTools2) {
|
|
|
7495
8104
|
},
|
|
7496
8105
|
async handler(params, context) {
|
|
7497
8106
|
checkHealthInputSchema.parse(params);
|
|
7498
|
-
|
|
8107
|
+
log37.info({ routerId: context.routerId }, "Checking router health");
|
|
7499
8108
|
const startMs = Date.now();
|
|
7500
8109
|
try {
|
|
7501
8110
|
const resources = await context.routerClient.get("system/resource");
|
|
@@ -7533,7 +8142,7 @@ function createFleetTools(baseTools2) {
|
|
|
7533
8142
|
const bulkExecuteTool = {
|
|
7534
8143
|
name: "bulk_execute",
|
|
7535
8144
|
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
|
|
8145
|
+
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
8146
|
inputSchema: bulkExecuteInputSchema,
|
|
7538
8147
|
annotations: {
|
|
7539
8148
|
readOnlyHint: false,
|
|
@@ -7557,10 +8166,21 @@ function createFleetTools(baseTools2) {
|
|
|
7557
8166
|
}
|
|
7558
8167
|
});
|
|
7559
8168
|
}
|
|
7560
|
-
|
|
8169
|
+
log37.info(
|
|
7561
8170
|
{ toolName: parsed.toolName, concurrency: parsed.concurrency },
|
|
7562
8171
|
"bulk_execute invoked"
|
|
7563
8172
|
);
|
|
8173
|
+
auditLog({
|
|
8174
|
+
type: "audit",
|
|
8175
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8176
|
+
correlationId: context.correlationId,
|
|
8177
|
+
identityId: context.identity.id,
|
|
8178
|
+
role: context.identity.role,
|
|
8179
|
+
tool: "bulk_execute",
|
|
8180
|
+
routerId: "(fleet)",
|
|
8181
|
+
phase: "attempt",
|
|
8182
|
+
params: { toolName: parsed.toolName, routerIds: parsed.routerIds, tags: parsed.tags }
|
|
8183
|
+
}, context.appConfig.auditLogPath);
|
|
7564
8184
|
if (parsed.toolName === "bulk_execute" || parsed.toolName === "check_router_health") {
|
|
7565
8185
|
throw new MikroMCPError({
|
|
7566
8186
|
category: "VALIDATION" /* VALIDATION */,
|
|
@@ -7585,17 +8205,6 @@ function createFleetTools(baseTools2) {
|
|
|
7585
8205
|
});
|
|
7586
8206
|
}
|
|
7587
8207
|
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
8208
|
const preErrors = [];
|
|
7600
8209
|
let routers;
|
|
7601
8210
|
if (hasRouterIds) {
|
|
@@ -7604,15 +8213,56 @@ function createFleetTools(baseTools2) {
|
|
|
7604
8213
|
try {
|
|
7605
8214
|
resolved.push(context.routerRegistry.getRouter(id));
|
|
7606
8215
|
} catch {
|
|
7607
|
-
|
|
7608
|
-
preErrors.push({
|
|
8216
|
+
log37.warn({ routerId: id }, "bulk_execute: router not found");
|
|
8217
|
+
preErrors.push({
|
|
8218
|
+
routerId: id,
|
|
8219
|
+
status: "error",
|
|
8220
|
+
error: `Router "${id}" not found in registry`,
|
|
8221
|
+
durationMs: 0
|
|
8222
|
+
});
|
|
7609
8223
|
}
|
|
7610
8224
|
}
|
|
7611
8225
|
routers = resolved;
|
|
7612
8226
|
} else {
|
|
7613
8227
|
routers = context.routerRegistry.listRouters(parsed.tags);
|
|
7614
8228
|
}
|
|
8229
|
+
if (targetTool.annotations.destructiveHint) {
|
|
8230
|
+
const secret = context.appConfig.confirmationSecret;
|
|
8231
|
+
if (!secret) {
|
|
8232
|
+
throw new MikroMCPError({
|
|
8233
|
+
category: "CONFIGURATION" /* CONFIGURATION */,
|
|
8234
|
+
code: "FLEET_CONFIRMATION_UNAVAILABLE",
|
|
8235
|
+
message: "Fanning out a destructive tool requires MIKROMCP_CONFIRMATION_SECRET to be configured.",
|
|
8236
|
+
recoverability: {
|
|
8237
|
+
retryable: false,
|
|
8238
|
+
suggestedAction: "Set MIKROMCP_CONFIRMATION_SECRET, or call the tool per-router."
|
|
8239
|
+
}
|
|
8240
|
+
});
|
|
8241
|
+
}
|
|
8242
|
+
const routerIdList = routers.map((r) => r.id);
|
|
8243
|
+
checkFleetConfirmation(
|
|
8244
|
+
{
|
|
8245
|
+
toolName: parsed.toolName,
|
|
8246
|
+
routerIds: routerIdList,
|
|
8247
|
+
params: parsed.params,
|
|
8248
|
+
identityId: context.identity.id,
|
|
8249
|
+
submittedToken: parsed.confirmationToken
|
|
8250
|
+
},
|
|
8251
|
+
secret
|
|
8252
|
+
);
|
|
8253
|
+
}
|
|
7615
8254
|
if (routers.length === 0 && preErrors.length === 0) {
|
|
8255
|
+
auditLog({
|
|
8256
|
+
type: "audit",
|
|
8257
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8258
|
+
correlationId: context.correlationId,
|
|
8259
|
+
identityId: context.identity.id,
|
|
8260
|
+
role: context.identity.role,
|
|
8261
|
+
tool: "bulk_execute",
|
|
8262
|
+
routerId: "(fleet)",
|
|
8263
|
+
phase: "success",
|
|
8264
|
+
params: { toolName: parsed.toolName, succeeded: 0, failed: 0 }
|
|
8265
|
+
}, context.appConfig.auditLogPath);
|
|
7616
8266
|
return {
|
|
7617
8267
|
content: `Executed ${parsed.toolName} on 0 routers: 0 succeeded, 0 failed`,
|
|
7618
8268
|
structuredContent: {
|
|
@@ -7628,28 +8278,50 @@ function createFleetTools(baseTools2) {
|
|
|
7628
8278
|
const start = Date.now();
|
|
7629
8279
|
try {
|
|
7630
8280
|
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,
|
|
8281
|
+
const routerContext = buildRouterToolContext({
|
|
7639
8282
|
routerConfig: router,
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
8283
|
+
correlationId: context.correlationId,
|
|
8284
|
+
identity: context.identity,
|
|
8285
|
+
pool: context.connectionPool,
|
|
8286
|
+
config: context.appConfig,
|
|
8287
|
+
registry: context.routerRegistry
|
|
8288
|
+
});
|
|
7644
8289
|
const toolParams = { ...parsed.params, routerId: router.id };
|
|
7645
8290
|
const result = await targetTool.handler(
|
|
7646
8291
|
toolParams,
|
|
7647
8292
|
routerContext
|
|
7648
8293
|
);
|
|
7649
|
-
|
|
8294
|
+
const elapsed = Date.now() - start;
|
|
8295
|
+
auditLog({
|
|
8296
|
+
type: "audit",
|
|
8297
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8298
|
+
correlationId: context.correlationId,
|
|
8299
|
+
identityId: context.identity.id,
|
|
8300
|
+
role: context.identity.role,
|
|
8301
|
+
tool: parsed.toolName,
|
|
8302
|
+
routerId: router.id,
|
|
8303
|
+
phase: "success",
|
|
8304
|
+
params: parsed.params,
|
|
8305
|
+
durationMs: elapsed
|
|
8306
|
+
}, context.appConfig.auditLogPath);
|
|
8307
|
+
return { routerId: router.id, status: "ok", result, durationMs: elapsed };
|
|
7650
8308
|
} catch (err) {
|
|
8309
|
+
const elapsed = Date.now() - start;
|
|
7651
8310
|
const message = err instanceof Error ? err.message : String(err);
|
|
7652
|
-
|
|
8311
|
+
auditLog({
|
|
8312
|
+
type: "audit",
|
|
8313
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8314
|
+
correlationId: context.correlationId,
|
|
8315
|
+
identityId: context.identity.id,
|
|
8316
|
+
role: context.identity.role,
|
|
8317
|
+
tool: parsed.toolName,
|
|
8318
|
+
routerId: router.id,
|
|
8319
|
+
phase: "failure",
|
|
8320
|
+
params: parsed.params,
|
|
8321
|
+
outcome: message,
|
|
8322
|
+
durationMs: elapsed
|
|
8323
|
+
}, context.appConfig.auditLogPath);
|
|
8324
|
+
return { routerId: router.id, status: "error", error: message, durationMs: elapsed };
|
|
7653
8325
|
}
|
|
7654
8326
|
}
|
|
7655
8327
|
const results2 = [...preErrors];
|
|
@@ -7660,6 +8332,17 @@ function createFleetTools(baseTools2) {
|
|
|
7660
8332
|
}
|
|
7661
8333
|
const succeeded = results2.filter((r) => r.status === "ok").length;
|
|
7662
8334
|
const failed = results2.filter((r) => r.status === "error").length;
|
|
8335
|
+
auditLog({
|
|
8336
|
+
type: "audit",
|
|
8337
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8338
|
+
correlationId: context.correlationId,
|
|
8339
|
+
identityId: context.identity.id,
|
|
8340
|
+
role: context.identity.role,
|
|
8341
|
+
tool: "bulk_execute",
|
|
8342
|
+
routerId: "(fleet)",
|
|
8343
|
+
phase: "success",
|
|
8344
|
+
params: { toolName: parsed.toolName, succeeded, failed }
|
|
8345
|
+
}, context.appConfig.auditLogPath);
|
|
7663
8346
|
return {
|
|
7664
8347
|
content: `Executed ${parsed.toolName} on ${results2.length} routers: ${succeeded} succeeded, ${failed} failed`,
|
|
7665
8348
|
structuredContent: {
|
|
@@ -7702,9 +8385,13 @@ var baseTools = [
|
|
|
7702
8385
|
...certificateTools,
|
|
7703
8386
|
...userTools,
|
|
7704
8387
|
...dhcpServerTools,
|
|
8388
|
+
...ipPoolTools,
|
|
7705
8389
|
...queueTools,
|
|
7706
8390
|
...vrrpTools,
|
|
7707
|
-
...networkServicesTools
|
|
8391
|
+
...networkServicesTools,
|
|
8392
|
+
...vlanTools,
|
|
8393
|
+
...dhcpClientTools,
|
|
8394
|
+
...ipServiceTools
|
|
7708
8395
|
];
|
|
7709
8396
|
var allTools = [
|
|
7710
8397
|
...baseTools,
|
|
@@ -7712,159 +8399,8 @@ var allTools = [
|
|
|
7712
8399
|
...createFleetTools(baseTools)
|
|
7713
8400
|
];
|
|
7714
8401
|
|
|
7715
|
-
// src/
|
|
7716
|
-
|
|
7717
|
-
maxRetries: 3,
|
|
7718
|
-
baseDelayMs: 200,
|
|
7719
|
-
maxDelayMs: 5e3
|
|
7720
|
-
};
|
|
7721
|
-
var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
|
|
7722
|
-
function isRetryable(error) {
|
|
7723
|
-
if (error instanceof Error) {
|
|
7724
|
-
const code = error.code;
|
|
7725
|
-
if (code && RETRYABLE_CODES.has(code)) {
|
|
7726
|
-
return true;
|
|
7727
|
-
}
|
|
7728
|
-
const statusCode = error.statusCode;
|
|
7729
|
-
if (typeof statusCode === "number") {
|
|
7730
|
-
return statusCode >= 500;
|
|
7731
|
-
}
|
|
7732
|
-
}
|
|
7733
|
-
return false;
|
|
7734
|
-
}
|
|
7735
|
-
async function withRetry(fn, options) {
|
|
7736
|
-
const opts = { ...DEFAULTS, ...options };
|
|
7737
|
-
let lastError;
|
|
7738
|
-
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
7739
|
-
try {
|
|
7740
|
-
return await fn();
|
|
7741
|
-
} catch (error) {
|
|
7742
|
-
lastError = error;
|
|
7743
|
-
if (attempt >= opts.maxRetries || !isRetryable(error)) {
|
|
7744
|
-
throw error;
|
|
7745
|
-
}
|
|
7746
|
-
const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
|
|
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
|
-
}
|
|
7751
|
-
}
|
|
7752
|
-
throw lastError;
|
|
7753
|
-
}
|
|
7754
|
-
|
|
7755
|
-
// src/adapter/circuit-breaker.ts
|
|
7756
|
-
var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
|
|
7757
|
-
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
|
|
7758
|
-
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
7759
|
-
"ROUTER_ERROR" /* ROUTER_ERROR */,
|
|
7760
|
-
"ROUTER_BUSY" /* ROUTER_BUSY */
|
|
7761
|
-
]);
|
|
7762
|
-
function isTransientFailure(error) {
|
|
7763
|
-
if (!(error instanceof MikroMCPError)) {
|
|
7764
|
-
return false;
|
|
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
|
-
}
|
|
8402
|
+
// src/mcp/tool-executor.ts
|
|
8403
|
+
import { nanoid as nanoid4 } from "nanoid";
|
|
7868
8404
|
|
|
7869
8405
|
// src/observability/correlation.ts
|
|
7870
8406
|
import { AsyncLocalStorage } from "async_hooks";
|
|
@@ -7880,9 +8416,6 @@ function withContext(ctx, fn) {
|
|
|
7880
8416
|
return requestContext.run(fullContext, fn);
|
|
7881
8417
|
}
|
|
7882
8418
|
|
|
7883
|
-
// src/mcp/tool-registry.ts
|
|
7884
|
-
import { nanoid as nanoid4 } from "nanoid";
|
|
7885
|
-
|
|
7886
8419
|
// src/middleware/auth.ts
|
|
7887
8420
|
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
7888
8421
|
var identityStorage = new AsyncLocalStorage2();
|
|
@@ -7951,19 +8484,19 @@ function getStdioIdentity(stdioIdentityName, registry) {
|
|
|
7951
8484
|
}
|
|
7952
8485
|
|
|
7953
8486
|
// src/middleware/confirmation.ts
|
|
7954
|
-
import { createHmac, createHash as
|
|
7955
|
-
var
|
|
8487
|
+
import { createHmac as createHmac2, createHash as createHash3 } from "crypto";
|
|
8488
|
+
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
7956
8489
|
var BYPASS_ROLES = ["admin", "superadmin"];
|
|
7957
8490
|
var pendingConfirmations = /* @__PURE__ */ new Map();
|
|
7958
8491
|
function computeParamHash(params) {
|
|
7959
8492
|
const { confirmationToken: _, ...rest } = params;
|
|
7960
|
-
return
|
|
8493
|
+
return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
7961
8494
|
}
|
|
7962
8495
|
function computeToken(tool, routerId, identityId, paramHash, expiresAt, secret) {
|
|
7963
8496
|
const payload = JSON.stringify({ tool, routerId, paramHash, identityId, expiresAt });
|
|
7964
|
-
return
|
|
8497
|
+
return createHmac2("sha256", secret).update(payload).digest("hex");
|
|
7965
8498
|
}
|
|
7966
|
-
function
|
|
8499
|
+
function evictExpired2() {
|
|
7967
8500
|
const now = /* @__PURE__ */ new Date();
|
|
7968
8501
|
for (const [token, entry] of pendingConfirmations) {
|
|
7969
8502
|
if (entry.expiresAt <= now) pendingConfirmations.delete(token);
|
|
@@ -7971,7 +8504,7 @@ function evictExpired() {
|
|
|
7971
8504
|
}
|
|
7972
8505
|
async function checkConfirmation(toolName, routerId, params, identity, secret) {
|
|
7973
8506
|
if (BYPASS_ROLES.includes(identity.role)) return;
|
|
7974
|
-
|
|
8507
|
+
evictExpired2();
|
|
7975
8508
|
const submittedToken = typeof params.confirmationToken === "string" ? params.confirmationToken : null;
|
|
7976
8509
|
if (submittedToken) {
|
|
7977
8510
|
const entry = pendingConfirmations.get(submittedToken);
|
|
@@ -7998,34 +8531,77 @@ async function checkConfirmation(toolName, routerId, params, identity, secret) {
|
|
|
7998
8531
|
}
|
|
7999
8532
|
});
|
|
8000
8533
|
}
|
|
8001
|
-
pendingConfirmations.delete(submittedToken);
|
|
8002
|
-
return;
|
|
8534
|
+
pendingConfirmations.delete(submittedToken);
|
|
8535
|
+
return;
|
|
8536
|
+
}
|
|
8537
|
+
const expiresAt = new Date(Date.now() + CONFIRMATION_TTL_MS2).toISOString();
|
|
8538
|
+
const paramHash = computeParamHash(params);
|
|
8539
|
+
const token = computeToken(toolName, routerId, identity.id, paramHash, expiresAt, secret);
|
|
8540
|
+
pendingConfirmations.set(token, {
|
|
8541
|
+
expiresAt: new Date(expiresAt),
|
|
8542
|
+
tool: toolName,
|
|
8543
|
+
routerId,
|
|
8544
|
+
identityId: identity.id,
|
|
8545
|
+
paramHash
|
|
8546
|
+
});
|
|
8547
|
+
throw new MikroMCPError({
|
|
8548
|
+
category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
|
|
8549
|
+
code: "CONFIRMATION_REQUIRED",
|
|
8550
|
+
message: "This action is destructive. Re-submit with confirmationToken to proceed.",
|
|
8551
|
+
details: {
|
|
8552
|
+
confirmationToken: token,
|
|
8553
|
+
expiresAt,
|
|
8554
|
+
tool: toolName,
|
|
8555
|
+
routerId
|
|
8556
|
+
},
|
|
8557
|
+
recoverability: {
|
|
8558
|
+
retryable: true,
|
|
8559
|
+
suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
|
|
8560
|
+
}
|
|
8561
|
+
});
|
|
8562
|
+
}
|
|
8563
|
+
|
|
8564
|
+
// src/adapter/retry-engine.ts
|
|
8565
|
+
var DEFAULTS = {
|
|
8566
|
+
maxRetries: 3,
|
|
8567
|
+
baseDelayMs: 200,
|
|
8568
|
+
maxDelayMs: 5e3
|
|
8569
|
+
};
|
|
8570
|
+
var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"]);
|
|
8571
|
+
function isRetryable(error) {
|
|
8572
|
+
if (error instanceof MikroMCPError) {
|
|
8573
|
+
return error.recoverability.retryable;
|
|
8574
|
+
}
|
|
8575
|
+
if (error instanceof Error) {
|
|
8576
|
+
const code = error.code;
|
|
8577
|
+
if (code && RETRYABLE_CODES.has(code)) {
|
|
8578
|
+
return true;
|
|
8579
|
+
}
|
|
8580
|
+
const statusCode = error.statusCode;
|
|
8581
|
+
if (typeof statusCode === "number") {
|
|
8582
|
+
return statusCode >= 500;
|
|
8583
|
+
}
|
|
8584
|
+
}
|
|
8585
|
+
return false;
|
|
8586
|
+
}
|
|
8587
|
+
async function withRetry(fn, options) {
|
|
8588
|
+
const opts = { ...DEFAULTS, ...options };
|
|
8589
|
+
let lastError;
|
|
8590
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
8591
|
+
try {
|
|
8592
|
+
return await fn();
|
|
8593
|
+
} catch (error) {
|
|
8594
|
+
lastError = error;
|
|
8595
|
+
if (attempt >= opts.maxRetries || !isRetryable(error)) {
|
|
8596
|
+
throw error;
|
|
8597
|
+
}
|
|
8598
|
+
const exponentialDelay = opts.baseDelayMs * 2 ** attempt;
|
|
8599
|
+
const jitter = Math.random() * opts.baseDelayMs;
|
|
8600
|
+
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
|
|
8601
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
8602
|
+
}
|
|
8003
8603
|
}
|
|
8004
|
-
|
|
8005
|
-
const paramHash = computeParamHash(params);
|
|
8006
|
-
const token = computeToken(toolName, routerId, identity.id, paramHash, expiresAt, secret);
|
|
8007
|
-
pendingConfirmations.set(token, {
|
|
8008
|
-
expiresAt: new Date(expiresAt),
|
|
8009
|
-
tool: toolName,
|
|
8010
|
-
routerId,
|
|
8011
|
-
identityId: identity.id,
|
|
8012
|
-
paramHash
|
|
8013
|
-
});
|
|
8014
|
-
throw new MikroMCPError({
|
|
8015
|
-
category: "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */,
|
|
8016
|
-
code: "CONFIRMATION_REQUIRED",
|
|
8017
|
-
message: "This action is destructive. Re-submit with confirmationToken to proceed.",
|
|
8018
|
-
details: {
|
|
8019
|
-
confirmationToken: token,
|
|
8020
|
-
expiresAt,
|
|
8021
|
-
tool: toolName,
|
|
8022
|
-
routerId
|
|
8023
|
-
},
|
|
8024
|
-
recoverability: {
|
|
8025
|
-
retryable: true,
|
|
8026
|
-
suggestedAction: "Re-submit the exact same tool call with the confirmationToken from details.confirmationToken."
|
|
8027
|
-
}
|
|
8028
|
-
});
|
|
8604
|
+
throw lastError;
|
|
8029
8605
|
}
|
|
8030
8606
|
|
|
8031
8607
|
// src/config/maintenance-window.ts
|
|
@@ -8058,12 +8634,355 @@ function isWithinMaintenanceWindow(windows, now) {
|
|
|
8058
8634
|
});
|
|
8059
8635
|
}
|
|
8060
8636
|
|
|
8637
|
+
// src/mcp/response-formatter.ts
|
|
8638
|
+
function formatToolResult(result) {
|
|
8639
|
+
return {
|
|
8640
|
+
content: [{ type: "text", text: result.content }],
|
|
8641
|
+
structuredContent: result.structuredContent,
|
|
8642
|
+
...result.isError ? { isError: true } : {}
|
|
8643
|
+
};
|
|
8644
|
+
}
|
|
8645
|
+
function formatError(error) {
|
|
8646
|
+
const hint = error.recoverability.suggestedAction;
|
|
8647
|
+
const text = `Error [${error.category}]: ${error.message}${hint ? `
|
|
8648
|
+
Suggested action: ${hint}` : ""}`;
|
|
8649
|
+
return {
|
|
8650
|
+
content: [{ type: "text", text }],
|
|
8651
|
+
structuredContent: error.toJSON(),
|
|
8652
|
+
isError: true
|
|
8653
|
+
};
|
|
8654
|
+
}
|
|
8655
|
+
|
|
8656
|
+
// src/observability/metrics.ts
|
|
8657
|
+
var toolCalls = /* @__PURE__ */ new Map();
|
|
8658
|
+
function key(tool, status) {
|
|
8659
|
+
return `${tool}\0${status}`;
|
|
8660
|
+
}
|
|
8661
|
+
function recordToolCall(tool, status) {
|
|
8662
|
+
const k = key(tool, status);
|
|
8663
|
+
toolCalls.set(k, (toolCalls.get(k) ?? 0) + 1);
|
|
8664
|
+
}
|
|
8665
|
+
function renderPrometheus() {
|
|
8666
|
+
const lines = [
|
|
8667
|
+
"# HELP mikromcp_tool_calls_total Total MCP tool calls by tool and status.",
|
|
8668
|
+
"# TYPE mikromcp_tool_calls_total counter"
|
|
8669
|
+
];
|
|
8670
|
+
for (const [k, count] of toolCalls) {
|
|
8671
|
+
const [tool, status] = k.split("\0");
|
|
8672
|
+
lines.push(`mikromcp_tool_calls_total{tool="${tool}",status="${status}"} ${count}`);
|
|
8673
|
+
}
|
|
8674
|
+
return lines.join("\n") + "\n";
|
|
8675
|
+
}
|
|
8676
|
+
|
|
8677
|
+
// src/adapter/circuit-breaker.ts
|
|
8678
|
+
var TRANSIENT_CATEGORIES = /* @__PURE__ */ new Set([
|
|
8679
|
+
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */,
|
|
8680
|
+
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
8681
|
+
"ROUTER_ERROR" /* ROUTER_ERROR */,
|
|
8682
|
+
"ROUTER_BUSY" /* ROUTER_BUSY */
|
|
8683
|
+
]);
|
|
8684
|
+
function isTransientFailure(error) {
|
|
8685
|
+
if (!(error instanceof MikroMCPError)) {
|
|
8686
|
+
return false;
|
|
8687
|
+
}
|
|
8688
|
+
return TRANSIENT_CATEGORIES.has(error.category);
|
|
8689
|
+
}
|
|
8690
|
+
var DEFAULTS2 = {
|
|
8691
|
+
failureThreshold: 5,
|
|
8692
|
+
cooldownMs: 3e4
|
|
8693
|
+
};
|
|
8694
|
+
var CircuitBreaker = class {
|
|
8695
|
+
constructor(routerId, options) {
|
|
8696
|
+
this.routerId = routerId;
|
|
8697
|
+
this.options = { ...DEFAULTS2, ...options };
|
|
8698
|
+
}
|
|
8699
|
+
routerId;
|
|
8700
|
+
options;
|
|
8701
|
+
currentState = "closed";
|
|
8702
|
+
consecutiveFailures = 0;
|
|
8703
|
+
lastFailureTime = 0;
|
|
8704
|
+
probeInFlight = false;
|
|
8705
|
+
get state() {
|
|
8706
|
+
if (this.currentState === "open") {
|
|
8707
|
+
const elapsed = Date.now() - this.lastFailureTime;
|
|
8708
|
+
if (elapsed >= this.options.cooldownMs) {
|
|
8709
|
+
this.currentState = "half-open";
|
|
8710
|
+
}
|
|
8711
|
+
}
|
|
8712
|
+
return this.currentState;
|
|
8713
|
+
}
|
|
8714
|
+
/**
|
|
8715
|
+
* Execute `fn` through the circuit breaker.
|
|
8716
|
+
*
|
|
8717
|
+
* - **closed**: execute normally; track consecutive failures.
|
|
8718
|
+
* - **open**: reject immediately with `ROUTER_BUSY`.
|
|
8719
|
+
* - **half-open**: execute as a probe; success closes, failure reopens.
|
|
8720
|
+
*/
|
|
8721
|
+
async execute(fn) {
|
|
8722
|
+
const currentState = this.state;
|
|
8723
|
+
if (currentState === "open") {
|
|
8724
|
+
throw new MikroMCPError({
|
|
8725
|
+
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
8726
|
+
code: "CIRCUIT_OPEN",
|
|
8727
|
+
message: `Circuit breaker is open for router "${this.routerId}". Try again after cooldown.`,
|
|
8728
|
+
details: {
|
|
8729
|
+
routerId: this.routerId,
|
|
8730
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
8731
|
+
cooldownMs: this.options.cooldownMs
|
|
8732
|
+
},
|
|
8733
|
+
recoverability: {
|
|
8734
|
+
retryable: true,
|
|
8735
|
+
retryAfterMs: this.options.cooldownMs,
|
|
8736
|
+
suggestedAction: "Wait for the circuit breaker cooldown period to expire, then retry."
|
|
8737
|
+
}
|
|
8738
|
+
});
|
|
8739
|
+
}
|
|
8740
|
+
if (currentState === "half-open") {
|
|
8741
|
+
if (this.probeInFlight) {
|
|
8742
|
+
throw new MikroMCPError({
|
|
8743
|
+
category: "ROUTER_BUSY" /* ROUTER_BUSY */,
|
|
8744
|
+
code: "CIRCUIT_PROBE_IN_FLIGHT",
|
|
8745
|
+
message: `Circuit breaker for router "${this.routerId}" is half-open and a recovery probe is already in flight.`,
|
|
8746
|
+
details: { routerId: this.routerId },
|
|
8747
|
+
recoverability: {
|
|
8748
|
+
retryable: true,
|
|
8749
|
+
retryAfterMs: 1e3,
|
|
8750
|
+
suggestedAction: "Wait for the in-flight probe to complete, then retry."
|
|
8751
|
+
}
|
|
8752
|
+
});
|
|
8753
|
+
}
|
|
8754
|
+
this.probeInFlight = true;
|
|
8755
|
+
}
|
|
8756
|
+
try {
|
|
8757
|
+
const result = await fn();
|
|
8758
|
+
this.onSuccess();
|
|
8759
|
+
return result;
|
|
8760
|
+
} catch (error) {
|
|
8761
|
+
if (isTransientFailure(error)) {
|
|
8762
|
+
this.onFailure();
|
|
8763
|
+
}
|
|
8764
|
+
throw error;
|
|
8765
|
+
} finally {
|
|
8766
|
+
this.probeInFlight = false;
|
|
8767
|
+
}
|
|
8768
|
+
}
|
|
8769
|
+
/** Force the circuit back to the closed state. */
|
|
8770
|
+
reset() {
|
|
8771
|
+
this.currentState = "closed";
|
|
8772
|
+
this.consecutiveFailures = 0;
|
|
8773
|
+
this.lastFailureTime = 0;
|
|
8774
|
+
this.probeInFlight = false;
|
|
8775
|
+
}
|
|
8776
|
+
// ---------- internal helpers ----------
|
|
8777
|
+
onSuccess() {
|
|
8778
|
+
this.consecutiveFailures = 0;
|
|
8779
|
+
this.currentState = "closed";
|
|
8780
|
+
}
|
|
8781
|
+
onFailure() {
|
|
8782
|
+
this.consecutiveFailures++;
|
|
8783
|
+
this.lastFailureTime = Date.now();
|
|
8784
|
+
if (this.currentState === "half-open") {
|
|
8785
|
+
this.currentState = "open";
|
|
8786
|
+
} else if (this.consecutiveFailures >= this.options.failureThreshold) {
|
|
8787
|
+
this.currentState = "open";
|
|
8788
|
+
}
|
|
8789
|
+
}
|
|
8790
|
+
};
|
|
8791
|
+
|
|
8792
|
+
// src/mcp/tool-executor.ts
|
|
8793
|
+
var log38 = createLogger("tool-executor");
|
|
8794
|
+
async function executeToolCall(tool, args, deps) {
|
|
8795
|
+
const { registry, pool, circuitBreakers, config, identityRegistry } = deps;
|
|
8796
|
+
const correlationId = nanoid4();
|
|
8797
|
+
return withContext({ correlationId, tool: tool.name }, async () => {
|
|
8798
|
+
const startMs = Date.now();
|
|
8799
|
+
let journalId;
|
|
8800
|
+
try {
|
|
8801
|
+
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8802
|
+
if (tool.skipRouterContext) {
|
|
8803
|
+
const fleetContext = {
|
|
8804
|
+
routerClient: null,
|
|
8805
|
+
routerId: "",
|
|
8806
|
+
correlationId,
|
|
8807
|
+
routerConfig: null,
|
|
8808
|
+
sshClient: null,
|
|
8809
|
+
ftpClient: null,
|
|
8810
|
+
identity,
|
|
8811
|
+
routerRegistry: registry,
|
|
8812
|
+
connectionPool: pool,
|
|
8813
|
+
appConfig: config
|
|
8814
|
+
};
|
|
8815
|
+
const fleetResult = await tool.handler(args, fleetContext);
|
|
8816
|
+
recordToolCall(tool.name, "success");
|
|
8817
|
+
return formatToolResult(fleetResult);
|
|
8818
|
+
}
|
|
8819
|
+
const routerId = args.routerId;
|
|
8820
|
+
if (!routerId) {
|
|
8821
|
+
throw new MikroMCPError({
|
|
8822
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
8823
|
+
code: "MISSING_ROUTER_ID",
|
|
8824
|
+
message: "routerId is required",
|
|
8825
|
+
recoverability: {
|
|
8826
|
+
retryable: false,
|
|
8827
|
+
suggestedAction: "Provide a valid routerId parameter."
|
|
8828
|
+
}
|
|
8829
|
+
});
|
|
8830
|
+
}
|
|
8831
|
+
checkAuthz(identity, tool.name, routerId);
|
|
8832
|
+
const routerConfig = registry.getRouter(routerId);
|
|
8833
|
+
if (tool.annotations.destructiveHint) {
|
|
8834
|
+
const windows = routerConfig.maintenanceWindows;
|
|
8835
|
+
if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, /* @__PURE__ */ new Date())) {
|
|
8836
|
+
throw new MikroMCPError({
|
|
8837
|
+
category: "PERMISSION_DENIED" /* PERMISSION_DENIED */,
|
|
8838
|
+
code: "OUTSIDE_MAINTENANCE_WINDOW",
|
|
8839
|
+
message: `Router "${routerId}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
|
|
8840
|
+
details: { maintenanceWindows: windows },
|
|
8841
|
+
recoverability: {
|
|
8842
|
+
retryable: true,
|
|
8843
|
+
suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access."
|
|
8844
|
+
}
|
|
8845
|
+
});
|
|
8846
|
+
}
|
|
8847
|
+
}
|
|
8848
|
+
if (tool.annotations.destructiveHint && config.confirmationSecret) {
|
|
8849
|
+
await checkConfirmation(tool.name, routerId, args, identity, config.confirmationSecret);
|
|
8850
|
+
}
|
|
8851
|
+
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
8852
|
+
if (shouldAudit) {
|
|
8853
|
+
auditLog({
|
|
8854
|
+
type: "audit",
|
|
8855
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8856
|
+
correlationId,
|
|
8857
|
+
identityId: identity.id,
|
|
8858
|
+
role: identity.role,
|
|
8859
|
+
tool: tool.name,
|
|
8860
|
+
routerId,
|
|
8861
|
+
phase: "attempt",
|
|
8862
|
+
params: args
|
|
8863
|
+
}, config.auditLogPath);
|
|
8864
|
+
}
|
|
8865
|
+
const toolContext = buildRouterToolContext({
|
|
8866
|
+
routerConfig,
|
|
8867
|
+
correlationId,
|
|
8868
|
+
identity,
|
|
8869
|
+
pool,
|
|
8870
|
+
config,
|
|
8871
|
+
registry
|
|
8872
|
+
});
|
|
8873
|
+
let cb = circuitBreakers.get(routerId);
|
|
8874
|
+
if (!cb) {
|
|
8875
|
+
cb = new CircuitBreaker(routerId, {
|
|
8876
|
+
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
8877
|
+
cooldownMs: config.circuitBreaker.cooldownMs
|
|
8878
|
+
});
|
|
8879
|
+
circuitBreakers.set(routerId, cb);
|
|
8880
|
+
}
|
|
8881
|
+
toolContext.circuitBreaker = cb;
|
|
8882
|
+
const { confirmationToken: _ct, ...handlerArgs } = args;
|
|
8883
|
+
const snapshotIds = [];
|
|
8884
|
+
if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
|
|
8885
|
+
for (const path of tool.snapshotPaths) {
|
|
8886
|
+
try {
|
|
8887
|
+
const meta = await takeSnapshot(toolContext.routerClient, routerId, path, config.snapshotDir);
|
|
8888
|
+
snapshotIds.push(meta.id);
|
|
8889
|
+
log38.debug({ snapshotId: meta.id, path }, "Snapshot taken");
|
|
8890
|
+
} catch (err) {
|
|
8891
|
+
log38.warn({ err, path, routerId }, "Snapshot failed \u2014 proceeding without snapshot");
|
|
8892
|
+
}
|
|
8893
|
+
}
|
|
8894
|
+
}
|
|
8895
|
+
if (!tool.annotations.readOnlyHint && config.journalPath) {
|
|
8896
|
+
journalId = recordAttempt({
|
|
8897
|
+
journalPath: config.journalPath,
|
|
8898
|
+
identityId: identity.id,
|
|
8899
|
+
role: identity.role,
|
|
8900
|
+
tool: tool.name,
|
|
8901
|
+
routerId,
|
|
8902
|
+
params: handlerArgs,
|
|
8903
|
+
snapshotIds
|
|
8904
|
+
});
|
|
8905
|
+
}
|
|
8906
|
+
const runHandler = () => tool.handler(handlerArgs, toolContext);
|
|
8907
|
+
const executeHandler = () => cb.execute(
|
|
8908
|
+
tool.annotations.readOnlyHint ? () => withRetry(runHandler, config.retry) : runHandler
|
|
8909
|
+
);
|
|
8910
|
+
const result = await executeHandler();
|
|
8911
|
+
if (journalId) {
|
|
8912
|
+
recordOutcome({
|
|
8913
|
+
journalPath: config.journalPath,
|
|
8914
|
+
journalId,
|
|
8915
|
+
phase: "success",
|
|
8916
|
+
durationMs: Date.now() - startMs
|
|
8917
|
+
});
|
|
8918
|
+
}
|
|
8919
|
+
if (shouldAudit) {
|
|
8920
|
+
auditLog({
|
|
8921
|
+
type: "audit",
|
|
8922
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8923
|
+
correlationId,
|
|
8924
|
+
identityId: identity.id,
|
|
8925
|
+
role: identity.role,
|
|
8926
|
+
tool: tool.name,
|
|
8927
|
+
routerId,
|
|
8928
|
+
phase: "success",
|
|
8929
|
+
params: args,
|
|
8930
|
+
durationMs: Date.now() - startMs
|
|
8931
|
+
}, config.auditLogPath);
|
|
8932
|
+
}
|
|
8933
|
+
log38.info({ tool: tool.name, routerId, correlationId }, "Tool executed successfully");
|
|
8934
|
+
recordToolCall(tool.name, "success");
|
|
8935
|
+
return formatToolResult(result);
|
|
8936
|
+
} catch (err) {
|
|
8937
|
+
const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: tool.name });
|
|
8938
|
+
if (error.category === "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */) {
|
|
8939
|
+
const failedRouterId = args.routerId;
|
|
8940
|
+
if (failedRouterId) {
|
|
8941
|
+
pool.removeClient(failedRouterId);
|
|
8942
|
+
log38.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
|
|
8943
|
+
}
|
|
8944
|
+
}
|
|
8945
|
+
if (journalId) {
|
|
8946
|
+
recordOutcome({
|
|
8947
|
+
journalPath: config.journalPath,
|
|
8948
|
+
journalId,
|
|
8949
|
+
phase: "failure",
|
|
8950
|
+
outcome: error.code,
|
|
8951
|
+
durationMs: Date.now() - startMs
|
|
8952
|
+
});
|
|
8953
|
+
}
|
|
8954
|
+
const shouldAudit = tool.annotations.destructiveHint || !tool.annotations.readOnlyHint;
|
|
8955
|
+
if (shouldAudit && error.category !== "APPROVAL_REQUIRED" /* APPROVAL_REQUIRED */) {
|
|
8956
|
+
const routerId = args.routerId ?? "unknown";
|
|
8957
|
+
const identity = getCurrentIdentity() ?? getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8958
|
+
auditLog({
|
|
8959
|
+
type: "audit",
|
|
8960
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8961
|
+
correlationId,
|
|
8962
|
+
identityId: identity.id,
|
|
8963
|
+
role: identity.role,
|
|
8964
|
+
tool: tool.name,
|
|
8965
|
+
routerId,
|
|
8966
|
+
phase: "failure",
|
|
8967
|
+
params: args,
|
|
8968
|
+
outcome: error.code,
|
|
8969
|
+
durationMs: Date.now() - startMs
|
|
8970
|
+
}, config.auditLogPath);
|
|
8971
|
+
}
|
|
8972
|
+
log38.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
|
|
8973
|
+
recordToolCall(tool.name, "error");
|
|
8974
|
+
return formatError(error);
|
|
8975
|
+
}
|
|
8976
|
+
});
|
|
8977
|
+
}
|
|
8978
|
+
|
|
8061
8979
|
// src/mcp/tool-registry.ts
|
|
8062
|
-
var
|
|
8980
|
+
var log39 = createLogger("tool-registry");
|
|
8063
8981
|
function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
|
|
8982
|
+
const deps = { registry, pool, circuitBreakers, config, identityRegistry };
|
|
8064
8983
|
for (const tool of allTools) {
|
|
8065
8984
|
const registrationSchema = tool.annotations.destructiveHint ? tool.inputSchema.extend({
|
|
8066
|
-
confirmationToken:
|
|
8985
|
+
confirmationToken: z36.string().optional().describe(
|
|
8067
8986
|
"Token from a prior APPROVAL_REQUIRED response. Re-submit the identical call with this token to confirm the destructive action."
|
|
8068
8987
|
)
|
|
8069
8988
|
}) : tool.inputSchema;
|
|
@@ -8075,236 +8994,61 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
|
|
|
8075
8994
|
inputSchema: registrationSchema,
|
|
8076
8995
|
annotations: tool.annotations
|
|
8077
8996
|
},
|
|
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
|
-
}
|
|
8997
|
+
(args) => executeToolCall(tool, args, deps)
|
|
8254
8998
|
);
|
|
8255
|
-
|
|
8999
|
+
log39.debug({ tool: tool.name }, "Registered tool");
|
|
8256
9000
|
}
|
|
8257
|
-
|
|
9001
|
+
log39.info({ count: allTools.length }, "All tools registered");
|
|
8258
9002
|
}
|
|
8259
9003
|
|
|
8260
9004
|
// src/config/router-registry.ts
|
|
8261
|
-
import { readFileSync as
|
|
9005
|
+
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
8262
9006
|
import { parse } from "yaml";
|
|
8263
|
-
import { z as
|
|
8264
|
-
var
|
|
8265
|
-
var RouterConfigSchema =
|
|
8266
|
-
host:
|
|
8267
|
-
port:
|
|
8268
|
-
tls:
|
|
8269
|
-
enabled:
|
|
8270
|
-
rejectUnauthorized:
|
|
8271
|
-
ca:
|
|
8272
|
-
fingerprint:
|
|
9007
|
+
import { z as z37 } from "zod";
|
|
9008
|
+
var log40 = createLogger("router-registry");
|
|
9009
|
+
var RouterConfigSchema = z37.object({
|
|
9010
|
+
host: z37.string().min(1, "host is required"),
|
|
9011
|
+
port: z37.number().int().min(1).max(65535),
|
|
9012
|
+
tls: z37.object({
|
|
9013
|
+
enabled: z37.boolean(),
|
|
9014
|
+
rejectUnauthorized: z37.boolean(),
|
|
9015
|
+
ca: z37.string().optional(),
|
|
9016
|
+
fingerprint: z37.string().optional()
|
|
8273
9017
|
}).strict(),
|
|
8274
|
-
credentials:
|
|
8275
|
-
source:
|
|
8276
|
-
envPrefix:
|
|
8277
|
-
vaultPath:
|
|
9018
|
+
credentials: z37.object({
|
|
9019
|
+
source: z37.enum(["env", "vault"]),
|
|
9020
|
+
envPrefix: z37.string().optional(),
|
|
9021
|
+
vaultPath: z37.string().optional()
|
|
8278
9022
|
}).strict(),
|
|
8279
|
-
tags:
|
|
8280
|
-
rosVersion:
|
|
8281
|
-
sshPort:
|
|
8282
|
-
sshFingerprint:
|
|
8283
|
-
cmdAllow:
|
|
8284
|
-
cmdDeny:
|
|
8285
|
-
maintenanceWindows:
|
|
8286
|
-
|
|
8287
|
-
days:
|
|
8288
|
-
|
|
9023
|
+
tags: z37.array(z37.string()).default([]),
|
|
9024
|
+
rosVersion: z37.string().min(1),
|
|
9025
|
+
sshPort: z37.number().int().min(1).max(65535).optional(),
|
|
9026
|
+
sshFingerprint: z37.string().optional(),
|
|
9027
|
+
cmdAllow: z37.array(z37.string()).optional(),
|
|
9028
|
+
cmdDeny: z37.array(z37.string()).optional(),
|
|
9029
|
+
maintenanceWindows: z37.array(
|
|
9030
|
+
z37.object({
|
|
9031
|
+
days: z37.array(
|
|
9032
|
+
z37.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
|
|
8289
9033
|
),
|
|
8290
|
-
startTime:
|
|
8291
|
-
endTime:
|
|
8292
|
-
timezone:
|
|
9034
|
+
startTime: z37.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
|
|
9035
|
+
endTime: z37.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
|
|
9036
|
+
timezone: z37.string().min(1)
|
|
8293
9037
|
}).strict()
|
|
8294
9038
|
).optional()
|
|
8295
9039
|
}).strict();
|
|
8296
|
-
var ConfigFileSchema =
|
|
8297
|
-
routers:
|
|
9040
|
+
var ConfigFileSchema = z37.object({
|
|
9041
|
+
routers: z37.record(z37.string(), RouterConfigSchema)
|
|
8298
9042
|
}).strict();
|
|
8299
9043
|
var RouterRegistry = class {
|
|
8300
9044
|
routers;
|
|
8301
9045
|
constructor(configPath) {
|
|
8302
9046
|
this.routers = /* @__PURE__ */ new Map();
|
|
8303
9047
|
if (!existsSync(configPath)) {
|
|
8304
|
-
|
|
9048
|
+
log40.warn({ configPath }, "Router config file not found; starting with empty registry");
|
|
8305
9049
|
return;
|
|
8306
9050
|
}
|
|
8307
|
-
const raw =
|
|
9051
|
+
const raw = readFileSync2(configPath, "utf-8");
|
|
8308
9052
|
const parsed = parse(raw);
|
|
8309
9053
|
const result = ConfigFileSchema.safeParse(parsed);
|
|
8310
9054
|
if (!result.success) {
|
|
@@ -8315,14 +9059,14 @@ ${issues}`);
|
|
|
8315
9059
|
for (const [id, config] of Object.entries(result.data.routers)) {
|
|
8316
9060
|
const routerConfig = { ...config, id };
|
|
8317
9061
|
if (routerConfig.tls.enabled && !routerConfig.tls.rejectUnauthorized) {
|
|
8318
|
-
|
|
9062
|
+
log40.warn(
|
|
8319
9063
|
{ routerId: id },
|
|
8320
9064
|
"TLS certificate validation is DISABLED (rejectUnauthorized=false). Set tls.fingerprint to pin the server certificate instead."
|
|
8321
9065
|
);
|
|
8322
9066
|
}
|
|
8323
9067
|
this.routers.set(id, routerConfig);
|
|
8324
9068
|
}
|
|
8325
|
-
|
|
9069
|
+
log40.info({ count: this.routers.size }, "Loaded routers from config");
|
|
8326
9070
|
}
|
|
8327
9071
|
getRouter(id) {
|
|
8328
9072
|
const router = this.routers.get(id);
|
|
@@ -8345,7 +9089,7 @@ ${issues}`);
|
|
|
8345
9089
|
import { request, Agent } from "undici";
|
|
8346
9090
|
|
|
8347
9091
|
// src/adapter/tls-manager.ts
|
|
8348
|
-
import { readFileSync as
|
|
9092
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
8349
9093
|
function buildAgentOptions(tls) {
|
|
8350
9094
|
if (!tls.enabled) {
|
|
8351
9095
|
return {};
|
|
@@ -8354,7 +9098,7 @@ function buildAgentOptions(tls) {
|
|
|
8354
9098
|
rejectUnauthorized: tls.rejectUnauthorized
|
|
8355
9099
|
};
|
|
8356
9100
|
if (tls.ca) {
|
|
8357
|
-
connectOptions.ca =
|
|
9101
|
+
connectOptions.ca = readFileSync3(tls.ca, "utf-8");
|
|
8358
9102
|
}
|
|
8359
9103
|
if (tls.fingerprint) {
|
|
8360
9104
|
const expected = tls.fingerprint.replace(/:/g, "").toLowerCase();
|
|
@@ -8376,8 +9120,8 @@ function buildAgentOptions(tls) {
|
|
|
8376
9120
|
// src/adapter/response-parser.ts
|
|
8377
9121
|
var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
8378
9122
|
var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
|
|
8379
|
-
function parseRouterOSValue(
|
|
8380
|
-
if (
|
|
9123
|
+
function parseRouterOSValue(key2, value) {
|
|
9124
|
+
if (key2 === ".id") {
|
|
8381
9125
|
return value;
|
|
8382
9126
|
}
|
|
8383
9127
|
if (value === "true") return true;
|
|
@@ -8395,8 +9139,8 @@ function parseRouterOSValue(key, value) {
|
|
|
8395
9139
|
}
|
|
8396
9140
|
function parseRecord(raw) {
|
|
8397
9141
|
const parsed = {};
|
|
8398
|
-
for (const [
|
|
8399
|
-
parsed[
|
|
9142
|
+
for (const [key2, value] of Object.entries(raw)) {
|
|
9143
|
+
parsed[key2] = parseRouterOSValue(key2, value);
|
|
8400
9144
|
}
|
|
8401
9145
|
return parsed;
|
|
8402
9146
|
}
|
|
@@ -8418,8 +9162,8 @@ function buildListQuery(options) {
|
|
|
8418
9162
|
}
|
|
8419
9163
|
if (hasFilter) {
|
|
8420
9164
|
const queryParts = [];
|
|
8421
|
-
for (const [
|
|
8422
|
-
queryParts.push(`${
|
|
9165
|
+
for (const [key2, value] of Object.entries(options.filter)) {
|
|
9166
|
+
queryParts.push(`${key2}=${value}`);
|
|
8423
9167
|
}
|
|
8424
9168
|
body[".query"] = queryParts;
|
|
8425
9169
|
}
|
|
@@ -8427,8 +9171,8 @@ function buildListQuery(options) {
|
|
|
8427
9171
|
}
|
|
8428
9172
|
if (hasFilter) {
|
|
8429
9173
|
const queryParams = {};
|
|
8430
|
-
for (const [
|
|
8431
|
-
queryParams[
|
|
9174
|
+
for (const [key2, value] of Object.entries(options.filter)) {
|
|
9175
|
+
queryParams[key2] = value;
|
|
8432
9176
|
}
|
|
8433
9177
|
return { method: "GET", queryParams };
|
|
8434
9178
|
}
|
|
@@ -8606,6 +9350,9 @@ var ConnectionPool = class {
|
|
|
8606
9350
|
}
|
|
8607
9351
|
};
|
|
8608
9352
|
|
|
9353
|
+
// src/version.ts
|
|
9354
|
+
var VERSION = "1.2.0";
|
|
9355
|
+
|
|
8609
9356
|
// src/mcp/server.ts
|
|
8610
9357
|
function createServerFactory(config, identityRegistry) {
|
|
8611
9358
|
const registry = new RouterRegistry(config.configPath);
|
|
@@ -8614,7 +9361,7 @@ function createServerFactory(config, identityRegistry) {
|
|
|
8614
9361
|
const makeServer = () => {
|
|
8615
9362
|
const server = new McpServer({
|
|
8616
9363
|
name: "mikromcp",
|
|
8617
|
-
version:
|
|
9364
|
+
version: VERSION
|
|
8618
9365
|
});
|
|
8619
9366
|
registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry);
|
|
8620
9367
|
return server;
|
|
@@ -8634,17 +9381,17 @@ import { randomUUID } from "crypto";
|
|
|
8634
9381
|
import { createServer } from "http";
|
|
8635
9382
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8636
9383
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
8637
|
-
var
|
|
9384
|
+
var log41 = createLogger("transport-http");
|
|
8638
9385
|
var BodyTooLargeError = class extends Error {
|
|
8639
9386
|
constructor() {
|
|
8640
9387
|
super("Request body exceeds maximum allowed size");
|
|
8641
9388
|
}
|
|
8642
9389
|
};
|
|
8643
9390
|
function createRateLimiter(rpm) {
|
|
8644
|
-
if (rpm === 0) return () => true;
|
|
8645
9391
|
const WINDOW_MS = 6e4;
|
|
8646
9392
|
const windows = /* @__PURE__ */ new Map();
|
|
8647
|
-
|
|
9393
|
+
const check = (ip) => {
|
|
9394
|
+
if (rpm === 0) return true;
|
|
8648
9395
|
const now = Date.now();
|
|
8649
9396
|
const entry = windows.get(ip);
|
|
8650
9397
|
if (!entry || now - entry.windowStart >= WINDOW_MS) {
|
|
@@ -8657,6 +9404,17 @@ function createRateLimiter(rpm) {
|
|
|
8657
9404
|
entry.count++;
|
|
8658
9405
|
return true;
|
|
8659
9406
|
};
|
|
9407
|
+
const limiter = check;
|
|
9408
|
+
limiter.sweep = () => {
|
|
9409
|
+
const now = Date.now();
|
|
9410
|
+
for (const [ip, entry] of windows) {
|
|
9411
|
+
if (now - entry.windowStart >= WINDOW_MS) {
|
|
9412
|
+
windows.delete(ip);
|
|
9413
|
+
}
|
|
9414
|
+
}
|
|
9415
|
+
};
|
|
9416
|
+
limiter.size = () => windows.size;
|
|
9417
|
+
return limiter;
|
|
8660
9418
|
}
|
|
8661
9419
|
async function readBody(req, maxBytes) {
|
|
8662
9420
|
return new Promise((resolve, reject) => {
|
|
@@ -8696,18 +9454,30 @@ async function readBody(req, maxBytes) {
|
|
|
8696
9454
|
async function connectHttp(makeServer, config, identityRegistry) {
|
|
8697
9455
|
const { port, bindHost, maxBodyBytes, rateLimitRpm } = config;
|
|
8698
9456
|
const checkRateLimit = createRateLimiter(rateLimitRpm);
|
|
9457
|
+
const sweepTimer = setInterval(() => checkRateLimit.sweep(), 6e4);
|
|
9458
|
+
sweepTimer.unref();
|
|
8699
9459
|
const streamableSessions = /* @__PURE__ */ new Map();
|
|
8700
9460
|
const sseTransports = /* @__PURE__ */ new Map();
|
|
8701
9461
|
const httpServer = createServer(async (req, res) => {
|
|
8702
9462
|
const ip = req.socket.remoteAddress ?? "unknown";
|
|
9463
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
9464
|
+
const { pathname } = url;
|
|
9465
|
+
if (pathname === "/healthz") {
|
|
9466
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
9467
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
9468
|
+
return;
|
|
9469
|
+
}
|
|
9470
|
+
if (pathname === "/metrics") {
|
|
9471
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
9472
|
+
res.end(renderPrometheus());
|
|
9473
|
+
return;
|
|
9474
|
+
}
|
|
8703
9475
|
if (!checkRateLimit(ip)) {
|
|
8704
|
-
|
|
9476
|
+
log41.warn({ ip }, "Rate limit exceeded");
|
|
8705
9477
|
res.writeHead(429, { "Content-Type": "application/json" });
|
|
8706
9478
|
res.end(JSON.stringify({ error: "Too many requests. Retry after 60 seconds." }));
|
|
8707
9479
|
return;
|
|
8708
9480
|
}
|
|
8709
|
-
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
8710
|
-
const { pathname } = url;
|
|
8711
9481
|
try {
|
|
8712
9482
|
if (pathname === "/mcp" || pathname === "/sse" || pathname === "/messages") {
|
|
8713
9483
|
let identity;
|
|
@@ -8744,16 +9514,17 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
8744
9514
|
}
|
|
8745
9515
|
return;
|
|
8746
9516
|
}
|
|
8747
|
-
|
|
9517
|
+
log41.error({ err }, "HTTP transport error");
|
|
8748
9518
|
if (!res.headersSent) {
|
|
8749
9519
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
8750
9520
|
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
8751
9521
|
}
|
|
8752
9522
|
}
|
|
8753
9523
|
});
|
|
9524
|
+
httpServer.on("close", () => clearInterval(sweepTimer));
|
|
8754
9525
|
await new Promise((resolve, reject) => {
|
|
8755
9526
|
httpServer.listen(port, bindHost, () => {
|
|
8756
|
-
|
|
9527
|
+
log41.info(
|
|
8757
9528
|
{ port, bindHost },
|
|
8758
9529
|
"HTTP transport listening (POST /mcp for Streamable HTTP, GET /sse for legacy SSE)"
|
|
8759
9530
|
);
|
|
@@ -8761,6 +9532,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
8761
9532
|
});
|
|
8762
9533
|
httpServer.on("error", reject);
|
|
8763
9534
|
});
|
|
9535
|
+
return httpServer;
|
|
8764
9536
|
}
|
|
8765
9537
|
async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, makeServer, streamableSessions, sseTransports) {
|
|
8766
9538
|
if (pathname === "/mcp") {
|
|
@@ -8776,13 +9548,13 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
8776
9548
|
sessionIdGenerator: () => randomUUID(),
|
|
8777
9549
|
onsessioninitialized: (id) => {
|
|
8778
9550
|
streamableSessions.set(id, transport2);
|
|
8779
|
-
|
|
9551
|
+
log41.info({ sessionId: id }, "Streamable HTTP session created");
|
|
8780
9552
|
}
|
|
8781
9553
|
});
|
|
8782
9554
|
transport2.onclose = () => {
|
|
8783
9555
|
if (transport2.sessionId) {
|
|
8784
9556
|
streamableSessions.delete(transport2.sessionId);
|
|
8785
|
-
|
|
9557
|
+
log41.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
|
|
8786
9558
|
}
|
|
8787
9559
|
};
|
|
8788
9560
|
await makeServer().connect(transport2);
|
|
@@ -8799,7 +9571,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
8799
9571
|
sseTransport.onclose = () => {
|
|
8800
9572
|
sseTransports.delete(sseTransport.sessionId);
|
|
8801
9573
|
};
|
|
8802
|
-
|
|
9574
|
+
log41.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
|
|
8803
9575
|
await sseTransport.start();
|
|
8804
9576
|
return;
|
|
8805
9577
|
}
|
|
@@ -8818,31 +9590,31 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
8818
9590
|
}
|
|
8819
9591
|
|
|
8820
9592
|
// src/config/identity-registry.ts
|
|
8821
|
-
import { readFileSync as
|
|
9593
|
+
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
|
|
8822
9594
|
import { parse as parse2 } from "yaml";
|
|
8823
|
-
import { z as
|
|
9595
|
+
import { z as z38 } from "zod";
|
|
8824
9596
|
import bcrypt from "bcryptjs";
|
|
8825
|
-
var
|
|
8826
|
-
var IdentityConfigSchema =
|
|
8827
|
-
token:
|
|
8828
|
-
role:
|
|
8829
|
-
allowedRouters:
|
|
8830
|
-
allowedToolPatterns:
|
|
9597
|
+
var log42 = createLogger("identity-registry");
|
|
9598
|
+
var IdentityConfigSchema = z38.object({
|
|
9599
|
+
token: z38.string().min(1, "token hash is required"),
|
|
9600
|
+
role: z38.enum(["readonly", "operator", "admin", "superadmin"]),
|
|
9601
|
+
allowedRouters: z38.array(z38.string()).default([]),
|
|
9602
|
+
allowedToolPatterns: z38.array(z38.string()).default([])
|
|
8831
9603
|
}).strict();
|
|
8832
|
-
var ConfigFileSchema2 =
|
|
8833
|
-
identities:
|
|
9604
|
+
var ConfigFileSchema2 = z38.object({
|
|
9605
|
+
identities: z38.record(z38.string(), IdentityConfigSchema)
|
|
8834
9606
|
}).strict();
|
|
8835
9607
|
var IdentityRegistry = class {
|
|
8836
9608
|
identities = [];
|
|
8837
9609
|
constructor(configPath) {
|
|
8838
9610
|
if (!existsSync2(configPath)) {
|
|
8839
|
-
|
|
9611
|
+
log42.warn(
|
|
8840
9612
|
{ configPath },
|
|
8841
9613
|
"Identity config file not found; starting with empty registry (stdio callers do not require it)"
|
|
8842
9614
|
);
|
|
8843
9615
|
return;
|
|
8844
9616
|
}
|
|
8845
|
-
const raw =
|
|
9617
|
+
const raw = readFileSync4(configPath, "utf-8");
|
|
8846
9618
|
const parsed = parse2(raw);
|
|
8847
9619
|
const result = ConfigFileSchema2.safeParse(parsed);
|
|
8848
9620
|
if (!result.success) {
|
|
@@ -8859,7 +9631,7 @@ ${issues}`);
|
|
|
8859
9631
|
allowedToolPatterns: config.allowedToolPatterns
|
|
8860
9632
|
});
|
|
8861
9633
|
}
|
|
8862
|
-
|
|
9634
|
+
log42.info({ count: this.identities.length }, "Loaded identities from config");
|
|
8863
9635
|
}
|
|
8864
9636
|
getIdentities() {
|
|
8865
9637
|
return this.identities.map(({ id, role, allowedRouters, allowedToolPatterns }) => ({
|
|
@@ -8885,18 +9657,59 @@ ${issues}`);
|
|
|
8885
9657
|
}
|
|
8886
9658
|
};
|
|
8887
9659
|
|
|
9660
|
+
// src/domain/snapshot/retention.ts
|
|
9661
|
+
import { readdir, stat, unlink } from "fs/promises";
|
|
9662
|
+
import { join as join3 } from "path";
|
|
9663
|
+
var log43 = createLogger("snapshot-retention");
|
|
9664
|
+
async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
9665
|
+
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
9666
|
+
let removed = 0;
|
|
9667
|
+
let routerDirs;
|
|
9668
|
+
try {
|
|
9669
|
+
routerDirs = await readdir(snapshotDir);
|
|
9670
|
+
} catch {
|
|
9671
|
+
return 0;
|
|
9672
|
+
}
|
|
9673
|
+
for (const routerDir of routerDirs) {
|
|
9674
|
+
const dirPath = join3(snapshotDir, routerDir);
|
|
9675
|
+
let files;
|
|
9676
|
+
try {
|
|
9677
|
+
files = await readdir(dirPath);
|
|
9678
|
+
} catch {
|
|
9679
|
+
continue;
|
|
9680
|
+
}
|
|
9681
|
+
for (const file of files) {
|
|
9682
|
+
const filePath = join3(dirPath, file);
|
|
9683
|
+
try {
|
|
9684
|
+
const info = await stat(filePath);
|
|
9685
|
+
if (info.isFile() && info.mtimeMs < cutoffMs) {
|
|
9686
|
+
await unlink(filePath);
|
|
9687
|
+
removed++;
|
|
9688
|
+
}
|
|
9689
|
+
} catch (err) {
|
|
9690
|
+
log43.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
|
|
9691
|
+
}
|
|
9692
|
+
}
|
|
9693
|
+
}
|
|
9694
|
+
if (removed > 0) {
|
|
9695
|
+
log43.info({ removed, maxAgeDays }, "Pruned expired snapshots");
|
|
9696
|
+
}
|
|
9697
|
+
return removed;
|
|
9698
|
+
}
|
|
9699
|
+
|
|
8888
9700
|
// src/cli/serve.ts
|
|
8889
|
-
var
|
|
9701
|
+
var log44 = createLogger("main");
|
|
8890
9702
|
async function runServe() {
|
|
8891
9703
|
const config = loadAppConfig();
|
|
8892
|
-
|
|
9704
|
+
log44.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
|
|
9705
|
+
void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
|
|
8893
9706
|
const identityRegistry = new IdentityRegistry(config.identitiesPath);
|
|
8894
9707
|
if (config.transport === "http") {
|
|
8895
9708
|
const hasLimitedRoles = identityRegistry.getIdentities().some(
|
|
8896
9709
|
(i) => i.role === "readonly" || i.role === "operator"
|
|
8897
9710
|
);
|
|
8898
9711
|
if (hasLimitedRoles && !config.confirmationSecret) {
|
|
8899
|
-
|
|
9712
|
+
log44.error(
|
|
8900
9713
|
"MIKROMCP_CONFIRMATION_SECRET is required in HTTP mode when identities with role readonly or operator are configured"
|
|
8901
9714
|
);
|
|
8902
9715
|
process.exit(1);
|
|
@@ -8906,7 +9719,7 @@ async function runServe() {
|
|
|
8906
9719
|
if (config.transport === "stdio") {
|
|
8907
9720
|
const stdioIdentity = getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
8908
9721
|
await withIdentity(stdioIdentity, () => connectStdio(makeServer()));
|
|
8909
|
-
|
|
9722
|
+
log44.info("MikroMCP server running via stdio");
|
|
8910
9723
|
} else if (config.transport === "http") {
|
|
8911
9724
|
await connectHttp(
|
|
8912
9725
|
makeServer,
|
|
@@ -8918,13 +9731,13 @@ async function runServe() {
|
|
|
8918
9731
|
},
|
|
8919
9732
|
identityRegistry
|
|
8920
9733
|
);
|
|
8921
|
-
|
|
9734
|
+
log44.info(
|
|
8922
9735
|
{ port: config.port, bindHost: config.bindHost },
|
|
8923
9736
|
"MikroMCP server running via HTTP/SSE"
|
|
8924
9737
|
);
|
|
8925
9738
|
}
|
|
8926
9739
|
const shutdown = () => {
|
|
8927
|
-
|
|
9740
|
+
log44.info("Shutting down MikroMCP server");
|
|
8928
9741
|
pool.closeAll();
|
|
8929
9742
|
process.exit(0);
|
|
8930
9743
|
};
|
|
@@ -8933,11 +9746,11 @@ async function runServe() {
|
|
|
8933
9746
|
}
|
|
8934
9747
|
|
|
8935
9748
|
// src/cli/doctor.ts
|
|
8936
|
-
import { existsSync as existsSync3, readFileSync as
|
|
8937
|
-
import { homedir as
|
|
9749
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
9750
|
+
import { homedir as homedir2 } from "os";
|
|
8938
9751
|
import * as net from "net";
|
|
8939
9752
|
import chalk from "chalk";
|
|
8940
|
-
var localVersion = "1.0
|
|
9753
|
+
var localVersion = "1.2.0";
|
|
8941
9754
|
var results = [];
|
|
8942
9755
|
function ok(msg) {
|
|
8943
9756
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -9117,8 +9930,8 @@ function checkIdentities(identitiesPath, transport2) {
|
|
|
9117
9930
|
}
|
|
9118
9931
|
function checkClaudeDesktop() {
|
|
9119
9932
|
const candidatePaths = [
|
|
9120
|
-
`${
|
|
9121
|
-
`${
|
|
9933
|
+
`${homedir2()}/Library/Application Support/Claude/claude_desktop_config.json`,
|
|
9934
|
+
`${homedir2()}/.config/Claude/claude_desktop_config.json`
|
|
9122
9935
|
];
|
|
9123
9936
|
const configPath = candidatePaths.find((p) => existsSync3(p));
|
|
9124
9937
|
if (!configPath) {
|
|
@@ -9126,7 +9939,7 @@ function checkClaudeDesktop() {
|
|
|
9126
9939
|
return;
|
|
9127
9940
|
}
|
|
9128
9941
|
try {
|
|
9129
|
-
const raw =
|
|
9942
|
+
const raw = readFileSync5(configPath, "utf-8");
|
|
9130
9943
|
const config = JSON.parse(raw);
|
|
9131
9944
|
const servers = config.mcpServers ?? {};
|
|
9132
9945
|
if ("mikromcp" in servers || "mikrotik-mcp-server" in servers) {
|
|
@@ -9211,18 +10024,18 @@ import { input, confirm, select } from "@inquirer/prompts";
|
|
|
9211
10024
|
import { randomBytes } from "crypto";
|
|
9212
10025
|
import {
|
|
9213
10026
|
existsSync as existsSync4,
|
|
9214
|
-
readFileSync as
|
|
9215
|
-
writeFileSync
|
|
9216
|
-
mkdirSync
|
|
10027
|
+
readFileSync as readFileSync6,
|
|
10028
|
+
writeFileSync,
|
|
10029
|
+
mkdirSync,
|
|
9217
10030
|
copyFileSync
|
|
9218
10031
|
} from "fs";
|
|
9219
|
-
import { homedir as
|
|
10032
|
+
import { homedir as homedir3 } from "os";
|
|
9220
10033
|
import { join as join4 } from "path";
|
|
9221
10034
|
import { parse as yamlParse, Document, visit } from "yaml";
|
|
9222
10035
|
import bcrypt2 from "bcryptjs";
|
|
9223
10036
|
import chalk2 from "chalk";
|
|
9224
10037
|
function mikromcpDir2() {
|
|
9225
|
-
return join4(
|
|
10038
|
+
return join4(homedir3(), ".mikromcp");
|
|
9226
10039
|
}
|
|
9227
10040
|
function defaultAuditLogPath() {
|
|
9228
10041
|
return join4(mikromcpDir2(), "audit.ndjson");
|
|
@@ -9230,8 +10043,8 @@ function defaultAuditLogPath() {
|
|
|
9230
10043
|
function toYaml(obj) {
|
|
9231
10044
|
const doc = new Document(obj);
|
|
9232
10045
|
visit(doc, {
|
|
9233
|
-
Scalar(
|
|
9234
|
-
if (
|
|
10046
|
+
Scalar(key2, node) {
|
|
10047
|
+
if (key2 !== "key" && typeof node.value === "string") {
|
|
9235
10048
|
node.type = "QUOTE_DOUBLE";
|
|
9236
10049
|
}
|
|
9237
10050
|
},
|
|
@@ -9253,8 +10066,8 @@ function splitTags(val) {
|
|
|
9253
10066
|
}
|
|
9254
10067
|
function findClaudeDesktopConfig() {
|
|
9255
10068
|
const candidates = [
|
|
9256
|
-
join4(
|
|
9257
|
-
join4(
|
|
10069
|
+
join4(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
10070
|
+
join4(homedir3(), ".config", "Claude", "claude_desktop_config.json")
|
|
9258
10071
|
];
|
|
9259
10072
|
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
9260
10073
|
}
|
|
@@ -9419,13 +10232,13 @@ function writeRoutersYaml(data) {
|
|
|
9419
10232
|
};
|
|
9420
10233
|
let parsed = { routers: {} };
|
|
9421
10234
|
if (existsSync4(filePath)) {
|
|
9422
|
-
const raw =
|
|
10235
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
9423
10236
|
parsed = yamlParse(raw) ?? { routers: {} };
|
|
9424
10237
|
if (!parsed.routers) parsed.routers = {};
|
|
9425
10238
|
}
|
|
9426
10239
|
parsed.routers[data.routerId] = newEntry;
|
|
9427
|
-
|
|
9428
|
-
|
|
10240
|
+
mkdirSync(data.configDir, { recursive: true });
|
|
10241
|
+
writeFileSync(filePath, toYaml(parsed));
|
|
9429
10242
|
return true;
|
|
9430
10243
|
}
|
|
9431
10244
|
function writeIdentitiesYaml(data) {
|
|
@@ -9433,18 +10246,18 @@ function writeIdentitiesYaml(data) {
|
|
|
9433
10246
|
const newEntry = {
|
|
9434
10247
|
token: data.tokenHash,
|
|
9435
10248
|
role: data.role,
|
|
9436
|
-
allowedRouters: data.allowedRouters === "*" ? [
|
|
9437
|
-
allowedToolPatterns: data.allowedToolPatterns === "*" ? [
|
|
10249
|
+
allowedRouters: data.allowedRouters === "*" ? [] : data.allowedRouters.split(",").map((s) => s.trim()),
|
|
10250
|
+
allowedToolPatterns: data.allowedToolPatterns === "*" ? [] : data.allowedToolPatterns.split(",").map((s) => s.trim())
|
|
9438
10251
|
};
|
|
9439
10252
|
let parsed = { identities: {} };
|
|
9440
10253
|
if (existsSync4(filePath)) {
|
|
9441
|
-
const raw =
|
|
10254
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
9442
10255
|
parsed = yamlParse(raw) ?? { identities: {} };
|
|
9443
10256
|
if (!parsed.identities) parsed.identities = {};
|
|
9444
10257
|
}
|
|
9445
10258
|
parsed.identities[data.identityId] = newEntry;
|
|
9446
|
-
|
|
9447
|
-
|
|
10259
|
+
mkdirSync(data.configDir, { recursive: true });
|
|
10260
|
+
writeFileSync(filePath, toYaml(parsed));
|
|
9448
10261
|
return true;
|
|
9449
10262
|
}
|
|
9450
10263
|
function writeDotEnv(data) {
|
|
@@ -9478,8 +10291,8 @@ function writeDotEnv(data) {
|
|
|
9478
10291
|
"# \u2500\u2500 RBAC (optional, for stdio with identity enforcement) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9479
10292
|
"# MIKROMCP_STDIO_IDENTITY="
|
|
9480
10293
|
];
|
|
9481
|
-
|
|
9482
|
-
|
|
10294
|
+
mkdirSync(data.configDir, { recursive: true });
|
|
10295
|
+
writeFileSync(data.envPath, lines.join("\n") + "\n");
|
|
9483
10296
|
}
|
|
9484
10297
|
function registerClaudeDesktop() {
|
|
9485
10298
|
const configPath = findClaudeDesktopConfig();
|
|
@@ -9500,13 +10313,13 @@ function registerClaudeDesktop() {
|
|
|
9500
10313
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
9501
10314
|
const backupPath = `${configPath}.backup-${timestamp}`;
|
|
9502
10315
|
copyFileSync(configPath, backupPath);
|
|
9503
|
-
const raw =
|
|
10316
|
+
const raw = readFileSync6(configPath, "utf-8");
|
|
9504
10317
|
const config = JSON.parse(raw);
|
|
9505
10318
|
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
9506
10319
|
config.mcpServers = {};
|
|
9507
10320
|
}
|
|
9508
10321
|
config.mcpServers["mikromcp"] = entry;
|
|
9509
|
-
|
|
10322
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
9510
10323
|
console.log(chalk2.dim(` Backed up to ${backupPath}`));
|
|
9511
10324
|
console.log(chalk2.dim(" Restart Claude to apply."));
|
|
9512
10325
|
return { registered: true };
|
|
@@ -9589,7 +10402,7 @@ async function runInit() {
|
|
|
9589
10402
|
}
|
|
9590
10403
|
|
|
9591
10404
|
// src/cli/index.ts
|
|
9592
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.0
|
|
10405
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.2.0");
|
|
9593
10406
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
9594
10407
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
9595
10408
|
program.command("init").description("Interactive setup wizard").action(runInit);
|
|
@@ -9599,6 +10412,6 @@ program.command("update").description("Update mikromcp to the latest version via
|
|
|
9599
10412
|
});
|
|
9600
10413
|
|
|
9601
10414
|
// src/main.ts
|
|
9602
|
-
loadDotenv({ path: join5(
|
|
10415
|
+
loadDotenv({ path: join5(homedir4(), ".mikromcp", ".env"), override: false, quiet: true });
|
|
9603
10416
|
program.parse();
|
|
9604
10417
|
//# sourceMappingURL=main.js.map
|