@usex/mikrotik-mcp 3.25.0 → 3.27.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/dist/cli.js +444 -25
- package/dist/index.d.ts +35 -18
- package/dist/index.js +130 -20
- package/dist/ui/observability.html +41 -41
- package/package.json +1 -1
- package/schemas/README.md +1 -1
- package/schemas/config.schema.json +17 -2
- package/schemas/tool-catalog.json +51 -11
- package/schemas/tools/disable_sstp_client.json +12 -0
- package/schemas/tools/enable_sstp_client.json +12 -0
- package/schemas/tools/sign_certificate.json +2 -6
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,8 @@ var McpServerSettingsSchema = z.object({
|
|
|
22
22
|
port: z.coerce.number().int().positive().default(8000),
|
|
23
23
|
allowedHosts: z.string().default(""),
|
|
24
24
|
allowedOrigins: z.string().default(""),
|
|
25
|
-
corsOrigins: z.string().default("")
|
|
25
|
+
corsOrigins: z.string().default(""),
|
|
26
|
+
toolPageSize: z.coerce.number().int().min(0).default(0)
|
|
26
27
|
});
|
|
27
28
|
var DeviceConfigSchema = z.object({
|
|
28
29
|
host: z.string().default("127.0.0.1"),
|
|
@@ -185,7 +186,8 @@ function loadConfig(argv = process.argv.slice(2)) {
|
|
|
185
186
|
port: pick("mcp-port", "MIKROTIK_MCP__PORT"),
|
|
186
187
|
allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
|
|
187
188
|
allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
|
|
188
|
-
corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS")
|
|
189
|
+
corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
|
|
190
|
+
toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE")
|
|
189
191
|
};
|
|
190
192
|
const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
|
|
191
193
|
const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
|
|
@@ -269,6 +271,24 @@ function resolveDeviceName(name) {
|
|
|
269
271
|
}
|
|
270
272
|
return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
|
|
271
273
|
}
|
|
274
|
+
function deviceTarget(dc) {
|
|
275
|
+
if (!dc)
|
|
276
|
+
return "?";
|
|
277
|
+
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
278
|
+
}
|
|
279
|
+
function deviceDirectory() {
|
|
280
|
+
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
281
|
+
key,
|
|
282
|
+
label: dc.description?.trim() || undefined,
|
|
283
|
+
target: deviceTarget(dc),
|
|
284
|
+
isDefault: key === active.defaultDevice
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
function resolvedTarget(name) {
|
|
288
|
+
const key = resolveDeviceName(name);
|
|
289
|
+
const dc = active.devices[key];
|
|
290
|
+
return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
|
|
291
|
+
}
|
|
272
292
|
function getDevice(name) {
|
|
273
293
|
if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
|
|
274
294
|
throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
|
|
@@ -1681,6 +1701,16 @@ function effectiveUi(def) {
|
|
|
1681
1701
|
}
|
|
1682
1702
|
return { ui: undefined, auto: false };
|
|
1683
1703
|
}
|
|
1704
|
+
function deviceSelectorDescription(selectorNames, directory) {
|
|
1705
|
+
if (directory && directory.length > 0) {
|
|
1706
|
+
const rows = directory.map((d) => `\u2022 ${d.key}${d.label && d.label !== d.key ? ` ("${d.label}")` : ""} \u2192 ${d.target}${d.isDefault ? " [default]" : ""}`).join(`
|
|
1707
|
+
`);
|
|
1708
|
+
return "Which configured MikroTik device to run this on. Pass the EXACT config key (or its label) " + "that matches the user's wording \u2014 these are different physical routers, so never substitute " + `one for another (e.g. "Ali Home" is NOT "home"). Configured devices:
|
|
1709
|
+
` + `${rows}
|
|
1710
|
+
` + "Omit only when the user did not name a device (uses the default).";
|
|
1711
|
+
}
|
|
1712
|
+
return `Which configured MikroTik device to run this on. One of: ${selectorNames.join(", ")} ` + "(a config key or its label). Omit to use the default device.";
|
|
1713
|
+
}
|
|
1684
1714
|
var READ = {
|
|
1685
1715
|
readOnlyHint: true,
|
|
1686
1716
|
idempotentHint: true,
|
|
@@ -1713,19 +1743,25 @@ function defineTool(def) {
|
|
|
1713
1743
|
inputSchema: def.inputSchema,
|
|
1714
1744
|
ui: def.ui,
|
|
1715
1745
|
register(server, opts = {}) {
|
|
1716
|
-
const { sendLog, deviceNames, deviceAliases } = opts;
|
|
1746
|
+
const { sendLog, deviceNames, deviceAliases, deviceDirectory: deviceDirectory2 } = opts;
|
|
1717
1747
|
const multiDevice = !!deviceNames && deviceNames.length > 1;
|
|
1718
1748
|
const { ui, auto } = effectiveUi(def);
|
|
1719
1749
|
const selectorNames = multiDevice && deviceNames ? [...new Set([...deviceNames, ...deviceAliases ?? []])] : [];
|
|
1720
1750
|
const inputSchema = multiDevice ? {
|
|
1721
1751
|
...def.inputSchema,
|
|
1722
|
-
device: z2.enum(selectorNames).optional().describe(
|
|
1752
|
+
device: z2.enum(selectorNames).optional().describe(deviceSelectorDescription(selectorNames, deviceDirectory2))
|
|
1723
1753
|
} : def.inputSchema;
|
|
1724
1754
|
const risk = riskOf(def.annotations);
|
|
1725
1755
|
const callback = async (args) => {
|
|
1726
1756
|
const { device, ...rest } = args;
|
|
1727
1757
|
const deviceName = typeof device === "string" ? device : undefined;
|
|
1728
1758
|
const ctx = createContext(sendLog, deviceName);
|
|
1759
|
+
const deviceStamp = multiDevice && risk !== "READ" ? (() => {
|
|
1760
|
+
const t = resolvedTarget(deviceName);
|
|
1761
|
+
const label = t.label && t.label !== t.key ? ` "${t.label}"` : "";
|
|
1762
|
+
const how = deviceName === undefined ? " \u2014 DEFAULT (no device specified)" : "";
|
|
1763
|
+
return `\u21B3 executed on device: ${t.key}${label}${how} \u2192 ${t.target}`;
|
|
1764
|
+
})() : null;
|
|
1729
1765
|
const startedAt = Date.now();
|
|
1730
1766
|
let outText = "";
|
|
1731
1767
|
let isErr = false;
|
|
@@ -1754,6 +1790,8 @@ function defineTool(def) {
|
|
|
1754
1790
|
result.structuredContent = out.structuredContent;
|
|
1755
1791
|
hasStructured = true;
|
|
1756
1792
|
}
|
|
1793
|
+
if (deviceStamp)
|
|
1794
|
+
result.content.push({ type: "text", text: deviceStamp });
|
|
1757
1795
|
return result;
|
|
1758
1796
|
} catch (e) {
|
|
1759
1797
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -1762,7 +1800,10 @@ function defineTool(def) {
|
|
|
1762
1800
|
errMsg = msg;
|
|
1763
1801
|
outText = `Error: ${msg}`;
|
|
1764
1802
|
return {
|
|
1765
|
-
content: [
|
|
1803
|
+
content: [
|
|
1804
|
+
{ type: "text", text: `Error: ${msg}` },
|
|
1805
|
+
...deviceStamp ? [{ type: "text", text: deviceStamp }] : []
|
|
1806
|
+
],
|
|
1766
1807
|
isError: true
|
|
1767
1808
|
};
|
|
1768
1809
|
} finally {
|
|
@@ -3815,19 +3856,28 @@ ${details}` : "Certificate creation completed but unable to verify.";
|
|
|
3815
3856
|
name: "sign_certificate",
|
|
3816
3857
|
title: "Sign Certificate",
|
|
3817
3858
|
annotations: WRITE,
|
|
3818
|
-
description: "Signs an existing certificate template (`/certificate sign`) to produce a valid certificate. " + "Omit `ca` to produce a self-signed certificate; supply the name of an existing CA certificate in `ca` " + "to issue it under that CA. " + "
|
|
3859
|
+
description: "Signs an existing certificate template (`/certificate sign`) to produce a valid certificate. " + "Omit `ca` to produce a self-signed certificate; supply the name of an existing CA certificate in `ca` " + "to issue it under that CA. " + "IMPORTANT: certificate stores are PER-DEVICE \u2014 the template (and the `ca`, if given) must live on the " + "SAME router this runs on. A 'CA not found' error almost always means the template or CA was created on a " + "different device; verify with list_certificates on this device first (and check the targeting). " + "Set the common name (CN) when you create the template (create_certificate), not here \u2014 `/certificate sign` " + "has no CN option. " + "May run for several seconds while the device generates key material. " + "Returns the signing operation output from the device.",
|
|
3819
3860
|
inputSchema: {
|
|
3820
|
-
name: z9.string().describe("Name of the certificate to sign"),
|
|
3821
|
-
ca: z9.string().optional().describe("Name of the CA certificate to sign with (omit to self-sign)")
|
|
3822
|
-
common_name: z9.string().optional().describe("Override the common name when signing")
|
|
3861
|
+
name: z9.string().describe("Name of the certificate template to sign"),
|
|
3862
|
+
ca: z9.string().optional().describe("Name of the CA certificate to sign with (must exist on THIS device; omit to self-sign)")
|
|
3823
3863
|
},
|
|
3824
3864
|
async handler(a, ctx) {
|
|
3825
|
-
ctx.info(`Signing certificate: name=${a.name}`);
|
|
3826
|
-
const
|
|
3865
|
+
ctx.info(`Signing certificate: name=${a.name}${a.ca ? ` ca=${a.ca}` : " (self-signed)"}`);
|
|
3866
|
+
const templateCount = await executeMikrotikCommand(`/certificate print count-only where name="${a.name}"`, ctx);
|
|
3867
|
+
if (templateCount.trim() === "0") {
|
|
3868
|
+
return `Certificate template '${a.name}' was not found on this device. Certificate stores are per-device \u2014 ` + `create the template here first (create_certificate), and make sure you are targeting the router that holds it ` + `(use list_certificates / list_mikrotik_devices to confirm).`;
|
|
3869
|
+
}
|
|
3870
|
+
if (a.ca) {
|
|
3871
|
+
const caCount = await executeMikrotikCommand(`/certificate print count-only where name="${a.ca}"`, ctx);
|
|
3872
|
+
if (caCount.trim() === "0") {
|
|
3873
|
+
return `CA certificate '${a.ca}' was not found on this device. The signing CA must exist on the SAME router as ` + `the template (certificate stores are per-device). Omit 'ca' to self-sign, or create/import the CA on this device first.`;
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
const cmd = new Cmd("/certificate sign").raw(a.name).opt("ca", a.ca).build();
|
|
3827
3877
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
3828
3878
|
if (looksLikeError(result))
|
|
3829
3879
|
return `Failed to sign certificate: ${result}`;
|
|
3830
|
-
return `Signing certificate '${a.name}'
|
|
3880
|
+
return `Signing certificate '${a.name}'${a.ca ? ` with CA '${a.ca}'` : " (self-signed)"}\u2026
|
|
3831
3881
|
|
|
3832
3882
|
${result}`;
|
|
3833
3883
|
}
|
|
@@ -4698,7 +4748,7 @@ var deviceTools = [
|
|
|
4698
4748
|
name: "list_mikrotik_devices",
|
|
4699
4749
|
title: "List Configured MikroTik Devices",
|
|
4700
4750
|
annotations: READ,
|
|
4701
|
-
description: "List all MikroTik devices registered in this server's configuration (server-side metadata \u2014 no RouterOS command is run, no SSH connection is opened). " + "Use this to discover the exact device name strings required by the `device` argument on every other tool, especially when working across multiple routers (e.g. building a tunnel between two devices). " + "Returns each device's name, username, host, port, auth method (key / password / none), optional description, and which entry is the default. " + "Credentials and private-key material are never included in the output.",
|
|
4751
|
+
description: "List all MikroTik devices registered in this server's configuration (server-side metadata \u2014 no RouterOS command is run, no SSH connection is opened). " + "Use this to discover the exact device name strings required by the `device` argument on every other tool, especially when working across multiple routers (e.g. building a tunnel between two devices). " + "Returns each device's name, username, host, port, auth method (key / password / none), optional description, and which entry is the default. " + "This name \u2192 host:port mapping is FIXED for the life of the server process \u2014 it does NOT change mid-session, so a given device name always reaches the same physical router. " + "Call this to verify the exact target before any write/destructive change when several routers are configured. " + "Credentials and private-key material are never included in the output.",
|
|
4702
4752
|
handler(_a, ctx) {
|
|
4703
4753
|
ctx.info("Listing configured MikroTik devices");
|
|
4704
4754
|
const { names, default: def } = listDevices();
|
|
@@ -12048,7 +12098,7 @@ ${details}`;
|
|
|
12048
12098
|
},
|
|
12049
12099
|
async handler(a, ctx) {
|
|
12050
12100
|
ctx.info(`Creating L2TP client: name=${a.name}, connect_to=${a.connect_to}`);
|
|
12051
|
-
const cmd = new Cmd("/interface l2tp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("use-ipsec", a.use_ipsec).opt("ipsec-secret", a.ipsec_secret).opt("allow", a.allow).bool("use-peer-dns", a.use_peer_dns).bool("dial-on-demand", a.dial_on_demand).opt("keepalive-timeout", a.keepalive_timeout).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("comment", a.comment).
|
|
12101
|
+
const cmd = new Cmd("/interface l2tp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("use-ipsec", a.use_ipsec).opt("ipsec-secret", a.ipsec_secret).opt("allow", a.allow).bool("use-peer-dns", a.use_peer_dns).bool("dial-on-demand", a.dial_on_demand).opt("keepalive-timeout", a.keepalive_timeout).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("comment", a.comment).bool("disabled", a.disabled).build();
|
|
12052
12102
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
12053
12103
|
if (looksLikeError(result))
|
|
12054
12104
|
return `Failed to create L2TP client: ${redactSecrets(result)}`;
|
|
@@ -14511,7 +14561,7 @@ ${details}`;
|
|
|
14511
14561
|
},
|
|
14512
14562
|
async handler(a, ctx) {
|
|
14513
14563
|
ctx.info(`Adding OpenVPN server: name=${a.name}`);
|
|
14514
|
-
const cmd = new Cmd("/interface ovpn-server server add").set("name", a.name).opt("port", a.port).opt("protocol", a.protocol).opt("mode", a.mode).opt("netmask", a.netmask).opt("mac-address", a.mac_address).opt("certificate", a.certificate).opt("auth", a.auth).opt("cipher", a.cipher).opt("tls-version", a.tls_version).opt("max-mtu", a.max_mtu).opt("default-profile", a.default_profile).bool("require-client-certificate", a.require_client_certificate).opt("comment", a.comment).
|
|
14564
|
+
const cmd = new Cmd("/interface ovpn-server server add").set("name", a.name).opt("port", a.port).opt("protocol", a.protocol).opt("mode", a.mode).opt("netmask", a.netmask).opt("mac-address", a.mac_address).opt("certificate", a.certificate).opt("auth", a.auth).opt("cipher", a.cipher).opt("tls-version", a.tls_version).opt("max-mtu", a.max_mtu).opt("default-profile", a.default_profile).bool("require-client-certificate", a.require_client_certificate).opt("comment", a.comment).bool("disabled", a.disabled).build();
|
|
14515
14565
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
14516
14566
|
if (containsRawParserError(result)) {
|
|
14517
14567
|
return "Failed to add OpenVPN server: this RouterOS build does not support named OpenVPN" + " server instances (the multi-server model requires RouterOS 7.17+). Use set_ovpn_server" + ` to configure the single legacy server instead.
|
|
@@ -14664,7 +14714,7 @@ ${details}`;
|
|
|
14664
14714
|
},
|
|
14665
14715
|
async handler(a, ctx) {
|
|
14666
14716
|
ctx.info(`Creating OpenVPN client: name=${a.name}, connect_to=${a.connect_to}`);
|
|
14667
|
-
const cmd = new Cmd("/interface ovpn-client add").set("name", a.name).set("connect-to", a.connect_to).opt("port", a.port).opt("user", a.user).opt("password", a.password).opt("certificate", a.certificate).opt("cipher", a.cipher).opt("auth", a.auth).opt("tls-version", a.tls_version).opt("mode", a.mode).opt("protocol", a.protocol).opt("mac-address", a.mac_address).opt("max-mtu", a.max_mtu).opt("profile", a.profile).bool("add-default-route", a.add_default_route).bool("route-nopull", a.route_nopull).bool("use-peer-dns", a.use_peer_dns).bool("verify-server-certificate", a.verify_server_certificate).opt("comment", a.comment).
|
|
14717
|
+
const cmd = new Cmd("/interface ovpn-client add").set("name", a.name).set("connect-to", a.connect_to).opt("port", a.port).opt("user", a.user).opt("password", a.password).opt("certificate", a.certificate).opt("cipher", a.cipher).opt("auth", a.auth).opt("tls-version", a.tls_version).opt("mode", a.mode).opt("protocol", a.protocol).opt("mac-address", a.mac_address).opt("max-mtu", a.max_mtu).opt("profile", a.profile).bool("add-default-route", a.add_default_route).bool("route-nopull", a.route_nopull).bool("use-peer-dns", a.use_peer_dns).bool("verify-server-certificate", a.verify_server_certificate).opt("comment", a.comment).bool("disabled", a.disabled).build();
|
|
14668
14718
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
14669
14719
|
if (looksLikeError(result))
|
|
14670
14720
|
return `Failed to create OpenVPN client: ${redactSecrets(result)}`;
|
|
@@ -15203,7 +15253,7 @@ ${details}`;
|
|
|
15203
15253
|
},
|
|
15204
15254
|
async handler(a, ctx) {
|
|
15205
15255
|
ctx.info(`Creating PPTP client: name=${a.name}, connect_to=${a.connect_to}`);
|
|
15206
|
-
const cmd = new Cmd("/interface pptp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("allow", a.allow).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("comment", a.comment).
|
|
15256
|
+
const cmd = new Cmd("/interface pptp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("allow", a.allow).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("comment", a.comment).bool("disabled", a.disabled).build();
|
|
15207
15257
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
15208
15258
|
if (looksLikeError(result))
|
|
15209
15259
|
return `Failed to create PPTP client: ${redactSecrets(result)}`;
|
|
@@ -19773,7 +19823,7 @@ ${details}`;
|
|
|
19773
19823
|
async handler(a, ctx) {
|
|
19774
19824
|
ctx.info(`Creating SSTP client: name=${a.name}, connect_to=${a.connect_to}`);
|
|
19775
19825
|
const { host, port } = splitHostPort(a.connect_to);
|
|
19776
|
-
const cmd = new Cmd("/interface sstp-client add").set("name", a.name).set("connect-to", host).opt("port", a.port ?? port).set("user", a.user).set("password", a.password).opt("profile", a.profile).opt("certificate", a.certificate).bool("verify-server-certificate", a.verify_server_certificate).opt("authentication", a.authentication).opt("tls-version", a.tls_version).bool("pfs", a.pfs).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("http-proxy", a.http_proxy).opt("comment", a.comment).
|
|
19826
|
+
const cmd = new Cmd("/interface sstp-client add").set("name", a.name).set("connect-to", host).opt("port", a.port ?? port).set("user", a.user).set("password", a.password).opt("profile", a.profile).opt("certificate", a.certificate).bool("verify-server-certificate", a.verify_server_certificate).opt("authentication", a.authentication).opt("tls-version", a.tls_version).bool("pfs", a.pfs).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("http-proxy", a.http_proxy).opt("comment", a.comment).bool("disabled", a.disabled).build();
|
|
19777
19827
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
19778
19828
|
if (looksLikeError(result))
|
|
19779
19829
|
return `Failed to create SSTP client: ${redactSecrets(result)}`;
|
|
@@ -19820,7 +19870,7 @@ ${redactSecrets(result)}`;
|
|
|
19820
19870
|
name: "remove_sstp_client",
|
|
19821
19871
|
title: "Remove SSTP Client Interface",
|
|
19822
19872
|
annotations: DESTRUCTIVE,
|
|
19823
|
-
description: "Permanently delete an SSTP client tunnel interface (`/interface sstp-client remove [find name=...]`) by interface name. " + "First verifies the interface exists (count-only check), then removes it; the tunnel is torn down immediately and the action is irreversible. " + "Use `list_sstp_clients` to confirm the interface name before calling this tool. " + "For L2TP, OpenVPN, or PPTP client interfaces see their respective tool scopes (`create_l2tp_client`, `create_ovpn_client`, `create_pptp_client`). " + "To
|
|
19873
|
+
description: "Permanently delete an SSTP client tunnel interface (`/interface sstp-client remove [find name=...]`) by interface name. " + "First verifies the interface exists (count-only check), then removes it; the tunnel is torn down immediately and the action is irreversible. " + "Use `list_sstp_clients` to confirm the interface name before calling this tool. " + "For L2TP, OpenVPN, or PPTP client interfaces see their respective tool scopes (`create_l2tp_client`, `create_ovpn_client`, `create_pptp_client`). " + "To deactivate the interface without deleting it use `disable_sstp_client` (re-activate with `enable_sstp_client`). " + "Returns a confirmation message on success or a not-found message if the name does not exist.",
|
|
19824
19874
|
inputSchema: { name: z97.string() },
|
|
19825
19875
|
async handler(a, ctx) {
|
|
19826
19876
|
ctx.info(`Removing SSTP client: name=${a.name}`);
|
|
@@ -19832,6 +19882,40 @@ ${redactSecrets(result)}`;
|
|
|
19832
19882
|
return `Failed to remove SSTP client: ${result}`;
|
|
19833
19883
|
return `SSTP client '${a.name}' removed successfully.`;
|
|
19834
19884
|
}
|
|
19885
|
+
}),
|
|
19886
|
+
defineTool({
|
|
19887
|
+
name: "enable_sstp_client",
|
|
19888
|
+
title: "Enable SSTP Client Interface",
|
|
19889
|
+
annotations: WRITE_IDEMPOTENT,
|
|
19890
|
+
description: "Enables a disabled SSTP client interface (`/interface sstp-client enable [find name=...]`), " + "causing the router to dial out and attempt the TLS connection to the remote SSTP server. " + "Use to activate a tunnel that was created disabled or stopped with `disable_sstp_client`. " + "For L2TP/PPTP/OpenVPN tunnels use their own enable tools. " + "Identifies the interface by name \u2014 create one with `create_sstp_client`.",
|
|
19891
|
+
inputSchema: { name: z97.string() },
|
|
19892
|
+
async handler(a, ctx) {
|
|
19893
|
+
ctx.info(`Enabling SSTP client: name=${a.name}`);
|
|
19894
|
+
const count = await executeMikrotikCommand(`/interface sstp-client print count-only where name="${a.name}"`, ctx);
|
|
19895
|
+
if (count.trim() === "0")
|
|
19896
|
+
return `SSTP client '${a.name}' not found.`;
|
|
19897
|
+
const result = await executeMikrotikCommand(`/interface sstp-client enable [find name="${a.name}"]`, ctx);
|
|
19898
|
+
if (looksLikeError(result))
|
|
19899
|
+
return `Failed to enable SSTP client: ${result}`;
|
|
19900
|
+
return `SSTP client '${a.name}' enabled successfully.`;
|
|
19901
|
+
}
|
|
19902
|
+
}),
|
|
19903
|
+
defineTool({
|
|
19904
|
+
name: "disable_sstp_client",
|
|
19905
|
+
title: "Disable SSTP Client Interface",
|
|
19906
|
+
annotations: WRITE_IDEMPOTENT,
|
|
19907
|
+
description: "Disables an active SSTP client interface (`/interface sstp-client disable [find name=...]`), " + "tearing down the tunnel without removing its configuration. " + "Use to temporarily stop a tunnel while preserving its settings for later reuse. " + "To re-enable use `enable_sstp_client`; to permanently remove use `remove_sstp_client`.",
|
|
19908
|
+
inputSchema: { name: z97.string() },
|
|
19909
|
+
async handler(a, ctx) {
|
|
19910
|
+
ctx.info(`Disabling SSTP client: name=${a.name}`);
|
|
19911
|
+
const count = await executeMikrotikCommand(`/interface sstp-client print count-only where name="${a.name}"`, ctx);
|
|
19912
|
+
if (count.trim() === "0")
|
|
19913
|
+
return `SSTP client '${a.name}' not found.`;
|
|
19914
|
+
const result = await executeMikrotikCommand(`/interface sstp-client disable [find name="${a.name}"]`, ctx);
|
|
19915
|
+
if (looksLikeError(result))
|
|
19916
|
+
return `Failed to disable SSTP client: ${result}`;
|
|
19917
|
+
return `SSTP client '${a.name}' disabled successfully.`;
|
|
19918
|
+
}
|
|
19835
19919
|
})
|
|
19836
19920
|
];
|
|
19837
19921
|
|
|
@@ -24328,7 +24412,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
|
|
|
24328
24412
|
// src/observability/dashboard.ts
|
|
24329
24413
|
import { readFileSync as readFileSync6 } from "fs";
|
|
24330
24414
|
import { homedir as homedir2, networkInterfaces } from "os";
|
|
24331
|
-
import { join as join6 } from "path";
|
|
24415
|
+
import { dirname as dirname6, join as join6 } from "path";
|
|
24332
24416
|
var {serve } = globalThis.Bun;
|
|
24333
24417
|
import { z as z114 } from "zod";
|
|
24334
24418
|
|
|
@@ -24975,6 +25059,245 @@ async function openSqliteStore(path) {
|
|
|
24975
25059
|
return new SqliteEventStore(db);
|
|
24976
25060
|
}
|
|
24977
25061
|
|
|
25062
|
+
// src/observability/usage-store.ts
|
|
25063
|
+
import { mkdirSync as mkdirSync6 } from "fs";
|
|
25064
|
+
import { dirname as dirname5 } from "path";
|
|
25065
|
+
function dayOf(ts) {
|
|
25066
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
25067
|
+
}
|
|
25068
|
+
function dailyUsageFromSamples(samples) {
|
|
25069
|
+
const byDay = new Map;
|
|
25070
|
+
for (let i = 1;i < samples.length; i++) {
|
|
25071
|
+
const prev = samples[i - 1];
|
|
25072
|
+
const cur = samples[i];
|
|
25073
|
+
const dRx = cur.rx >= prev.rx ? cur.rx - prev.rx : cur.rx;
|
|
25074
|
+
const dTx = cur.tx >= prev.tx ? cur.tx - prev.tx : cur.tx;
|
|
25075
|
+
const day = dayOf(cur.ts);
|
|
25076
|
+
const acc = byDay.get(day) ?? { rx: 0, tx: 0 };
|
|
25077
|
+
acc.rx += dRx;
|
|
25078
|
+
acc.tx += dTx;
|
|
25079
|
+
byDay.set(day, acc);
|
|
25080
|
+
}
|
|
25081
|
+
return [...byDay.entries()].map(([day, v]) => ({ day, rx: v.rx, tx: v.tx })).sort((a, b) => a.day.localeCompare(b.day));
|
|
25082
|
+
}
|
|
25083
|
+
var SCHEMA_STATEMENTS3 = [
|
|
25084
|
+
`CREATE TABLE IF NOT EXISTS usage_samples (
|
|
25085
|
+
device TEXT NOT NULL,
|
|
25086
|
+
subject TEXT NOT NULL,
|
|
25087
|
+
ts INTEGER NOT NULL,
|
|
25088
|
+
rx INTEGER NOT NULL,
|
|
25089
|
+
tx INTEGER NOT NULL
|
|
25090
|
+
)`,
|
|
25091
|
+
"CREATE INDEX IF NOT EXISTS idx_usage_sub ON usage_samples(device, subject, ts)",
|
|
25092
|
+
"CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_samples(ts)",
|
|
25093
|
+
`CREATE TABLE IF NOT EXISTS vpn_sessions (
|
|
25094
|
+
device TEXT NOT NULL,
|
|
25095
|
+
session_id TEXT NOT NULL,
|
|
25096
|
+
user TEXT NOT NULL,
|
|
25097
|
+
service TEXT,
|
|
25098
|
+
nas TEXT,
|
|
25099
|
+
started INTEGER NOT NULL,
|
|
25100
|
+
day TEXT NOT NULL,
|
|
25101
|
+
rx INTEGER NOT NULL,
|
|
25102
|
+
tx INTEGER NOT NULL,
|
|
25103
|
+
PRIMARY KEY (device, session_id)
|
|
25104
|
+
)`,
|
|
25105
|
+
"CREATE INDEX IF NOT EXISTS idx_sess_user ON vpn_sessions(device, user, day)",
|
|
25106
|
+
"CREATE INDEX IF NOT EXISTS idx_sess_day ON vpn_sessions(device, day)"
|
|
25107
|
+
];
|
|
25108
|
+
|
|
25109
|
+
class SqliteUsageStore {
|
|
25110
|
+
db;
|
|
25111
|
+
constructor(db) {
|
|
25112
|
+
this.db = db;
|
|
25113
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
25114
|
+
db.run("PRAGMA synchronous = NORMAL");
|
|
25115
|
+
for (const stmt of SCHEMA_STATEMENTS3)
|
|
25116
|
+
db.run(stmt);
|
|
25117
|
+
}
|
|
25118
|
+
recordClientSamples(device, ts, samples) {
|
|
25119
|
+
if (samples.length === 0)
|
|
25120
|
+
return;
|
|
25121
|
+
const insert = this.db.query("INSERT INTO usage_samples (device, subject, ts, rx, tx) VALUES ($d,$s,$ts,$rx,$tx)");
|
|
25122
|
+
const tx = this.db.transaction((rows) => {
|
|
25123
|
+
for (const r of rows) {
|
|
25124
|
+
insert.run({ $d: device, $s: r.ip, $ts: ts, $rx: r.rx, $tx: r.tx });
|
|
25125
|
+
}
|
|
25126
|
+
});
|
|
25127
|
+
tx(samples);
|
|
25128
|
+
}
|
|
25129
|
+
upsertSessions(device, sessions) {
|
|
25130
|
+
if (sessions.length === 0)
|
|
25131
|
+
return 0;
|
|
25132
|
+
const stmt = this.db.query(`INSERT INTO vpn_sessions (device, session_id, user, service, nas, started, day, rx, tx)
|
|
25133
|
+
VALUES ($d,$id,$u,$svc,$nas,$st,$day,$rx,$tx)
|
|
25134
|
+
ON CONFLICT(device, session_id) DO UPDATE SET rx=excluded.rx, tx=excluded.tx`);
|
|
25135
|
+
let n = 0;
|
|
25136
|
+
const tx = this.db.transaction((rows) => {
|
|
25137
|
+
for (const s of rows) {
|
|
25138
|
+
stmt.run({
|
|
25139
|
+
$d: device,
|
|
25140
|
+
$id: s.sessionId,
|
|
25141
|
+
$u: s.user,
|
|
25142
|
+
$svc: s.service ?? null,
|
|
25143
|
+
$nas: s.nas ?? null,
|
|
25144
|
+
$st: s.started,
|
|
25145
|
+
$day: dayOf(s.started),
|
|
25146
|
+
$rx: s.rx,
|
|
25147
|
+
$tx: s.tx
|
|
25148
|
+
});
|
|
25149
|
+
n++;
|
|
25150
|
+
}
|
|
25151
|
+
});
|
|
25152
|
+
tx(sessions);
|
|
25153
|
+
return n;
|
|
25154
|
+
}
|
|
25155
|
+
clientDailyUsage(device, ip, sinceTs) {
|
|
25156
|
+
const rows = this.db.query("SELECT ts, rx, tx FROM usage_samples WHERE device=$d AND subject=$s AND ts>=$since ORDER BY ts ASC").all({ $d: device, $s: ip, $since: sinceTs });
|
|
25157
|
+
return dailyUsageFromSamples(rows);
|
|
25158
|
+
}
|
|
25159
|
+
umUserDailyUsage(device, user, sinceTs) {
|
|
25160
|
+
const rows = this.db.query(`SELECT day, SUM(rx) AS rx, SUM(tx) AS tx FROM vpn_sessions
|
|
25161
|
+
WHERE device=$d AND user=$u AND started>=$since GROUP BY day ORDER BY day ASC`).all({ $d: device, $u: user, $since: sinceTs });
|
|
25162
|
+
return rows.map((r) => ({ day: r.day, rx: Number(r.rx), tx: Number(r.tx) }));
|
|
25163
|
+
}
|
|
25164
|
+
umUsers(device) {
|
|
25165
|
+
const rows = this.db.query("SELECT DISTINCT user FROM vpn_sessions WHERE device=$d ORDER BY user ASC").all({ $d: device });
|
|
25166
|
+
return rows.map((r) => r.user);
|
|
25167
|
+
}
|
|
25168
|
+
heatmap(device, user, sinceTs) {
|
|
25169
|
+
const sinceDay = dayOf(sinceTs);
|
|
25170
|
+
const rows = user ? this.db.query("SELECT day, COUNT(*) AS count FROM vpn_sessions WHERE device=$d AND user=$u AND day>=$since GROUP BY day").all({ $d: device, $u: user, $since: sinceDay }) : this.db.query("SELECT day, COUNT(*) AS count FROM vpn_sessions WHERE device=$d AND day>=$since GROUP BY day").all({ $d: device, $since: sinceDay });
|
|
25171
|
+
return rows.map((r) => ({ day: r.day, count: Number(r.count) }));
|
|
25172
|
+
}
|
|
25173
|
+
pruneSamples(olderThanTs) {
|
|
25174
|
+
const res = this.db.query("DELETE FROM usage_samples WHERE ts < $t").run({ $t: olderThanTs });
|
|
25175
|
+
return Number(res.changes ?? 0);
|
|
25176
|
+
}
|
|
25177
|
+
close() {
|
|
25178
|
+
this.db.close();
|
|
25179
|
+
}
|
|
25180
|
+
}
|
|
25181
|
+
async function openUsageStore(path) {
|
|
25182
|
+
if (path !== ":memory:") {
|
|
25183
|
+
try {
|
|
25184
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
25185
|
+
} catch {}
|
|
25186
|
+
}
|
|
25187
|
+
const { Database } = await import("bun:sqlite");
|
|
25188
|
+
const db = new Database(path, { create: true });
|
|
25189
|
+
return new SqliteUsageStore(db);
|
|
25190
|
+
}
|
|
25191
|
+
|
|
25192
|
+
// src/observability/usage-sampler.ts
|
|
25193
|
+
var SERVER_TAG = "mikrotik-mcp";
|
|
25194
|
+
var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
|
|
25195
|
+
var DEFAULT_USAGE_INTERVAL_MS = 60000;
|
|
25196
|
+
var MIN_USAGE_INTERVAL_MS = 30000;
|
|
25197
|
+
var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
|
|
25198
|
+
var timer2 = null;
|
|
25199
|
+
var inFlight2 = false;
|
|
25200
|
+
var currentStore = null;
|
|
25201
|
+
var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
|
|
25202
|
+
function clampInterval(ms) {
|
|
25203
|
+
if (!Number.isFinite(ms))
|
|
25204
|
+
return DEFAULT_USAGE_INTERVAL_MS;
|
|
25205
|
+
return Math.max(MIN_USAGE_INTERVAL_MS, Math.min(MAX_USAGE_INTERVAL_MS, Math.round(ms)));
|
|
25206
|
+
}
|
|
25207
|
+
function bytesOf(v) {
|
|
25208
|
+
return parseSize(v) ?? parseLeadingNumber(v) ?? 0;
|
|
25209
|
+
}
|
|
25210
|
+
function ipOf(target) {
|
|
25211
|
+
return (target ?? "").split("/")[0]?.trim() ?? "";
|
|
25212
|
+
}
|
|
25213
|
+
async function sampleClients(store2, device, ts) {
|
|
25214
|
+
const ctx = createContext(undefined, device);
|
|
25215
|
+
const out = await executeMikrotikCommand("/queue simple print stats detail", ctx);
|
|
25216
|
+
if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
|
|
25217
|
+
return;
|
|
25218
|
+
const samples = [];
|
|
25219
|
+
for (const row of parseRecords(out).rows) {
|
|
25220
|
+
const ip = ipOf(row.target ?? "");
|
|
25221
|
+
if (!ip)
|
|
25222
|
+
continue;
|
|
25223
|
+
const [tx, rx] = (row.bytes ?? "0/0").split("/");
|
|
25224
|
+
samples.push({ ip, rx: bytesOf(rx), tx: bytesOf(tx) });
|
|
25225
|
+
}
|
|
25226
|
+
store2.recordClientSamples(device, ts, samples);
|
|
25227
|
+
}
|
|
25228
|
+
async function ingestSessions(store2, device) {
|
|
25229
|
+
const ctx = createContext(undefined, device);
|
|
25230
|
+
const out = await executeMikrotikCommand("/user-manager session print detail", ctx);
|
|
25231
|
+
if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
|
|
25232
|
+
return;
|
|
25233
|
+
const sessions = [];
|
|
25234
|
+
for (const row of parseRecords(out).rows) {
|
|
25235
|
+
const user = row.user ?? "";
|
|
25236
|
+
const started = parseRouterosDate(row.started ?? row["start-time"]);
|
|
25237
|
+
if (!user || started == null)
|
|
25238
|
+
continue;
|
|
25239
|
+
const sessionId = row["acct-session-id"] || `${user}|${started}|${row["calling-station-id"] ?? row["nas-port-id"] ?? ""}`;
|
|
25240
|
+
sessions.push({
|
|
25241
|
+
sessionId,
|
|
25242
|
+
user,
|
|
25243
|
+
service: row.service,
|
|
25244
|
+
nas: row["nas-ip-address"] ?? row["nas-port-id"],
|
|
25245
|
+
started,
|
|
25246
|
+
rx: bytesOf(row.download),
|
|
25247
|
+
tx: bytesOf(row.upload)
|
|
25248
|
+
});
|
|
25249
|
+
}
|
|
25250
|
+
store2.upsertSessions(device, sessions);
|
|
25251
|
+
}
|
|
25252
|
+
async function sampleUsageOnce(store2) {
|
|
25253
|
+
if (inFlight2)
|
|
25254
|
+
return;
|
|
25255
|
+
inFlight2 = true;
|
|
25256
|
+
const ts = Date.now();
|
|
25257
|
+
try {
|
|
25258
|
+
const cfg = getConfig();
|
|
25259
|
+
await Promise.all(Object.entries(cfg.devices).map(async ([name, dc]) => {
|
|
25260
|
+
if (dc.mac)
|
|
25261
|
+
return;
|
|
25262
|
+
try {
|
|
25263
|
+
await sampleClients(store2, name, ts);
|
|
25264
|
+
await ingestSessions(store2, name);
|
|
25265
|
+
} catch (e) {
|
|
25266
|
+
logger.warn(`[${SERVER_TAG}] usage sample failed for '${name}': ${String(e)}`);
|
|
25267
|
+
}
|
|
25268
|
+
}));
|
|
25269
|
+
store2.pruneSamples(ts - USAGE_RETENTION_MS);
|
|
25270
|
+
} finally {
|
|
25271
|
+
inFlight2 = false;
|
|
25272
|
+
}
|
|
25273
|
+
}
|
|
25274
|
+
function startUsageSampler(store2, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
|
|
25275
|
+
currentStore = store2;
|
|
25276
|
+
currentIntervalMs = clampInterval(intervalMs);
|
|
25277
|
+
sampleUsageOnce(store2);
|
|
25278
|
+
timer2 = setInterval(() => void sampleUsageOnce(store2), currentIntervalMs);
|
|
25279
|
+
}
|
|
25280
|
+
function getUsageSamplerInterval() {
|
|
25281
|
+
return currentIntervalMs;
|
|
25282
|
+
}
|
|
25283
|
+
function setUsageSamplerInterval(intervalMs) {
|
|
25284
|
+
currentIntervalMs = clampInterval(intervalMs);
|
|
25285
|
+
if (timer2)
|
|
25286
|
+
clearInterval(timer2);
|
|
25287
|
+
if (currentStore) {
|
|
25288
|
+
const store2 = currentStore;
|
|
25289
|
+
timer2 = setInterval(() => void sampleUsageOnce(store2), currentIntervalMs);
|
|
25290
|
+
}
|
|
25291
|
+
return currentIntervalMs;
|
|
25292
|
+
}
|
|
25293
|
+
function stopUsageSampler() {
|
|
25294
|
+
if (timer2) {
|
|
25295
|
+
clearInterval(timer2);
|
|
25296
|
+
timer2 = null;
|
|
25297
|
+
}
|
|
25298
|
+
currentStore = null;
|
|
25299
|
+
}
|
|
25300
|
+
|
|
24978
25301
|
// src/observability/stats.ts
|
|
24979
25302
|
function percentile(values, p) {
|
|
24980
25303
|
if (values.length === 0)
|
|
@@ -25069,7 +25392,7 @@ function computeStats(events, opts) {
|
|
|
25069
25392
|
}
|
|
25070
25393
|
|
|
25071
25394
|
// src/observability/dashboard.ts
|
|
25072
|
-
var
|
|
25395
|
+
var SERVER_TAG2 = "mikrotik-mcp";
|
|
25073
25396
|
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
25074
25397
|
function json(body, status = 200) {
|
|
25075
25398
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
@@ -25170,7 +25493,7 @@ function devicesPayload(store2) {
|
|
|
25170
25493
|
avgMs: 0
|
|
25171
25494
|
}
|
|
25172
25495
|
}));
|
|
25173
|
-
return { server:
|
|
25496
|
+
return { server: SERVER_TAG2, defaultDevice: cfg.defaultDevice, devices };
|
|
25174
25497
|
}
|
|
25175
25498
|
function topologyPayload() {
|
|
25176
25499
|
const cfg = getConfig();
|
|
@@ -25183,7 +25506,7 @@ function topologyPayload() {
|
|
|
25183
25506
|
for (const { name } of devices)
|
|
25184
25507
|
neighborsByDevice[name] = getDeviceNeighbors(name);
|
|
25185
25508
|
return {
|
|
25186
|
-
server:
|
|
25509
|
+
server: SERVER_TAG2,
|
|
25187
25510
|
defaultDevice: cfg.defaultDevice,
|
|
25188
25511
|
generatedAt: Date.now(),
|
|
25189
25512
|
...buildTopology({ devices, neighborsByDevice })
|
|
@@ -25528,6 +25851,64 @@ async function aaaRoutes(req, url) {
|
|
|
25528
25851
|
}
|
|
25529
25852
|
return null;
|
|
25530
25853
|
}
|
|
25854
|
+
var usageStore = null;
|
|
25855
|
+
function daysParam(url, fallback, max) {
|
|
25856
|
+
const n = Number(url.searchParams.get("days"));
|
|
25857
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
|
|
25858
|
+
}
|
|
25859
|
+
async function usageRoutes(req, url) {
|
|
25860
|
+
const p = url.pathname;
|
|
25861
|
+
if (!p.startsWith("/api/usage"))
|
|
25862
|
+
return null;
|
|
25863
|
+
if (p === "/api/usage/sampler") {
|
|
25864
|
+
if (req.method === "GET")
|
|
25865
|
+
return json({ intervalMs: getUsageSamplerInterval() });
|
|
25866
|
+
if (req.method === "POST") {
|
|
25867
|
+
const b = await readJson(req);
|
|
25868
|
+
const applied = setUsageSamplerInterval(Number(b?.intervalMs));
|
|
25869
|
+
return json({ intervalMs: applied });
|
|
25870
|
+
}
|
|
25871
|
+
return null;
|
|
25872
|
+
}
|
|
25873
|
+
if (req.method !== "GET")
|
|
25874
|
+
return null;
|
|
25875
|
+
if (!usageStore)
|
|
25876
|
+
return json({ error: "usage store not active" }, 503);
|
|
25877
|
+
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
25878
|
+
const sinceTs = (days) => Date.now() - days * 86400000;
|
|
25879
|
+
if (p === "/api/usage/um-users") {
|
|
25880
|
+
return json({ users: usageStore.umUsers(device) });
|
|
25881
|
+
}
|
|
25882
|
+
if (p === "/api/usage/client") {
|
|
25883
|
+
const ip = url.searchParams.get("ip");
|
|
25884
|
+
if (!ip)
|
|
25885
|
+
return json({ error: "ip required" }, 400);
|
|
25886
|
+
const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
|
|
25887
|
+
return json(withTotals(series));
|
|
25888
|
+
}
|
|
25889
|
+
if (p === "/api/usage/um-user") {
|
|
25890
|
+
const user = url.searchParams.get("user");
|
|
25891
|
+
if (!user)
|
|
25892
|
+
return json({ error: "user required" }, 400);
|
|
25893
|
+
const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
|
|
25894
|
+
return json(withTotals(series));
|
|
25895
|
+
}
|
|
25896
|
+
if (p === "/api/usage/heatmap") {
|
|
25897
|
+
const user = url.searchParams.get("user");
|
|
25898
|
+
const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
|
|
25899
|
+
const total = days.reduce((s, d) => s + d.count, 0);
|
|
25900
|
+
const max = days.reduce((m, d) => Math.max(m, d.count), 0);
|
|
25901
|
+
return json({ days, total, max });
|
|
25902
|
+
}
|
|
25903
|
+
return null;
|
|
25904
|
+
}
|
|
25905
|
+
function withTotals(series) {
|
|
25906
|
+
return {
|
|
25907
|
+
series,
|
|
25908
|
+
totalRx: series.reduce((s, d) => s + d.rx, 0),
|
|
25909
|
+
totalTx: series.reduce((s, d) => s + d.tx, 0)
|
|
25910
|
+
};
|
|
25911
|
+
}
|
|
25531
25912
|
var snapStorePromise = null;
|
|
25532
25913
|
function snapStore() {
|
|
25533
25914
|
if (!snapStorePromise)
|
|
@@ -25746,11 +26127,17 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
25746
26127
|
transport: transportLabel
|
|
25747
26128
|
});
|
|
25748
26129
|
startHealthChecks(30000);
|
|
26130
|
+
try {
|
|
26131
|
+
usageStore = await openUsageStore(join6(dirname6(cfg.dbPath), "usage.db"));
|
|
26132
|
+
startUsageSampler(usageStore);
|
|
26133
|
+
} catch (e) {
|
|
26134
|
+
logger.warn(`[${SERVER_TAG2}] usage history disabled: ${String(e)}`);
|
|
26135
|
+
}
|
|
25749
26136
|
try {
|
|
25750
26137
|
if (isEmpty2())
|
|
25751
26138
|
recordVersion(getConfig(), "auto", Date.now(), "baseline");
|
|
25752
26139
|
} catch (e) {
|
|
25753
|
-
logger.warn(`[${
|
|
26140
|
+
logger.warn(`[${SERVER_TAG2}] could not seed config history baseline: ${String(e)}`);
|
|
25754
26141
|
}
|
|
25755
26142
|
const configAdmin = createConfigAdmin({
|
|
25756
26143
|
getConfig,
|
|
@@ -25808,6 +26195,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
25808
26195
|
const aaaResp = await aaaRoutes(req, url);
|
|
25809
26196
|
if (aaaResp)
|
|
25810
26197
|
return aaaResp;
|
|
26198
|
+
const usageResp = await usageRoutes(req, url);
|
|
26199
|
+
if (usageResp)
|
|
26200
|
+
return usageResp;
|
|
25811
26201
|
const featureResp = await featureRoutes(req, url);
|
|
25812
26202
|
if (featureResp)
|
|
25813
26203
|
return featureResp;
|
|
@@ -25894,8 +26284,11 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
25894
26284
|
store: store2,
|
|
25895
26285
|
stop() {
|
|
25896
26286
|
stopHealthChecks();
|
|
26287
|
+
stopUsageSampler();
|
|
25897
26288
|
server.stop(true);
|
|
25898
26289
|
store2.close();
|
|
26290
|
+
usageStore?.close();
|
|
26291
|
+
usageStore = null;
|
|
25899
26292
|
}
|
|
25900
26293
|
};
|
|
25901
26294
|
}
|
|
@@ -25946,6 +26339,7 @@ function corsHeaders(origin, configured) {
|
|
|
25946
26339
|
|
|
25947
26340
|
// src/server.ts
|
|
25948
26341
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26342
|
+
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
25949
26343
|
|
|
25950
26344
|
// src/prompts/index.ts
|
|
25951
26345
|
import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
|
|
@@ -26052,7 +26446,7 @@ function registerPrompts(server) {
|
|
|
26052
26446
|
// package.json
|
|
26053
26447
|
var package_default = {
|
|
26054
26448
|
name: "@usex/mikrotik-mcp",
|
|
26055
|
-
version: "3.
|
|
26449
|
+
version: "3.27.0",
|
|
26056
26450
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
26057
26451
|
keywords: [
|
|
26058
26452
|
"ai",
|
|
@@ -26257,12 +26651,37 @@ function createServer(opts = {}) {
|
|
|
26257
26651
|
sendLog,
|
|
26258
26652
|
deviceNames: names,
|
|
26259
26653
|
deviceAliases: deviceLabels(),
|
|
26654
|
+
deviceDirectory: deviceDirectory(),
|
|
26260
26655
|
readOnly
|
|
26261
26656
|
});
|
|
26262
26657
|
const promptCount = registerPrompts(server);
|
|
26263
26658
|
const uiViewCount = registerUiResources(server);
|
|
26659
|
+
installToolPagination(server, getConfig().mcp.toolPageSize);
|
|
26264
26660
|
return { server, toolCount, promptCount, uiViewCount, readOnly };
|
|
26265
26661
|
}
|
|
26662
|
+
function installToolPagination(server, pageSize) {
|
|
26663
|
+
if (pageSize <= 0)
|
|
26664
|
+
return;
|
|
26665
|
+
const low = server.server;
|
|
26666
|
+
const sdkHandler = low._requestHandlers?.get("tools/list");
|
|
26667
|
+
if (typeof sdkHandler !== "function")
|
|
26668
|
+
return;
|
|
26669
|
+
let cache = null;
|
|
26670
|
+
server.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
|
|
26671
|
+
if (!cache)
|
|
26672
|
+
cache = (await sdkHandler(request, extra)).tools ?? [];
|
|
26673
|
+
const total = cache.length;
|
|
26674
|
+
const cursor = request.params?.cursor;
|
|
26675
|
+
let start = 0;
|
|
26676
|
+
if (typeof cursor === "string") {
|
|
26677
|
+
const n = Number.parseInt(cursor, 10);
|
|
26678
|
+
start = Number.isFinite(n) && n > 0 ? Math.min(n, total) : 0;
|
|
26679
|
+
}
|
|
26680
|
+
const tools = cache.slice(start, start + pageSize);
|
|
26681
|
+
const end = start + tools.length;
|
|
26682
|
+
return end < total ? { tools, nextCursor: String(end) } : { tools };
|
|
26683
|
+
});
|
|
26684
|
+
}
|
|
26266
26685
|
|
|
26267
26686
|
// src/transport/http.ts
|
|
26268
26687
|
var LOCALHOST = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0"]);
|