@usex/mikrotik-mcp 4.3.0 → 4.5.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 CHANGED
@@ -119,6 +119,34 @@ bun run start # serve from source
119
119
  bun run build # bundle to dist/
120
120
  ```
121
121
 
122
+ ### As an MCP Bundle (`.mcpb`)
123
+
124
+ A one-click install for Claude Desktop and other MCPB hosts — no Bun, Node or npm
125
+ needed on the machine, and the router credentials are entered in the host's UI
126
+ rather than a shell.
127
+
128
+ ```bash
129
+ bun run build:mcp # bundle for this machine
130
+ bun run build:mcp --target linux-x64 # or one specific target
131
+ bun run build:mcp:all # every target
132
+ ```
133
+
134
+ Bundles land in `dist-mcpb/` as `mikrotik-mcp-<version>-<platform>-<arch>.mcpb`.
135
+ Open one with your MCPB host (on Claude Desktop, drag it onto Settings →
136
+ Extensions) and fill in the router address and credentials it prompts for.
137
+
138
+ Because the server is Bun-native — `bun:sqlite`, `Bun.serve`, `Bun.S3Client`, and
139
+ `@tikoci/centrs`, which ships raw TypeScript — it cannot run on the Node runtime an
140
+ MCPB host provides. Each bundle therefore **vendors the Bun binary it was built
141
+ against** and runs `runtime/bun dist/cli.js serve`, so every subsystem (MAC-Telnet,
142
+ the dashboard, S3 backups) behaves exactly as it does from source. That costs about
143
+ 60 MB per bundle and makes each one platform-specific — hence one artifact per
144
+ platform/arch rather than a single universal file.
145
+
146
+ The build stages the bundle, validates the manifest, and drives the staged server
147
+ over stdio (`initialize` + `tools/list`, asserting the tool count matches the
148
+ manifest) before packing. Pass `--no-smoke` to skip that last check.
149
+
122
150
  ## The tool catalog
123
151
 
124
152
  **706 tools across 111 modules.** Full, always-current reference (parameters +
package/dist/cli.js CHANGED
@@ -106,7 +106,7 @@ import {
106
106
  updateAaaEntity,
107
107
  updateSummaryLine,
108
108
  writeBackup
109
- } from "./shared/cli-mm90zmj3.js";
109
+ } from "./shared/cli-hfhmh1qe.js";
110
110
 
111
111
  // src/cli.ts
112
112
  import { existsSync as existsSync2 } from "fs";
@@ -650,6 +650,52 @@ function stopHealthChecks() {
650
650
  }
651
651
  }
652
652
 
653
+ // src/observability/traffic-hub.ts
654
+ var POLL_MS = 1000;
655
+ var hubs = new Map;
656
+ function subscribeTraffic(device, fn) {
657
+ let hub = hubs.get(device);
658
+ if (!hub) {
659
+ const broadcast = (sample) => {
660
+ const h = hubs.get(device);
661
+ if (!h)
662
+ return;
663
+ h.last = sample;
664
+ for (const l of h.listeners)
665
+ l(sample);
666
+ };
667
+ const poll = async () => {
668
+ try {
669
+ broadcast(await sampleAllTraffic(createContext(undefined, device || undefined)));
670
+ } catch (e) {
671
+ broadcast({
672
+ ts: Date.now(),
673
+ source: "none",
674
+ note: e instanceof Error ? e.message : String(e),
675
+ hosts: {},
676
+ limits: {}
677
+ });
678
+ }
679
+ };
680
+ hub = { listeners: new Set, timer: setInterval(() => void poll(), POLL_MS) };
681
+ hubs.set(device, hub);
682
+ poll();
683
+ }
684
+ hub.listeners.add(fn);
685
+ if (hub.last)
686
+ fn(hub.last);
687
+ return () => {
688
+ const h = hubs.get(device);
689
+ if (!h)
690
+ return;
691
+ h.listeners.delete(fn);
692
+ if (h.listeners.size === 0) {
693
+ clearInterval(h.timer);
694
+ hubs.delete(device);
695
+ }
696
+ };
697
+ }
698
+
653
699
  // src/observability/store.ts
654
700
  import { mkdirSync as mkdirSync3 } from "fs";
655
701
  import { dirname as dirname2 } from "path";
@@ -1595,6 +1641,45 @@ data: ${JSON.stringify(e)}
1595
1641
  `));
1596
1642
  ping = setInterval(() => send(`: ping
1597
1643
 
1644
+ `), 15000);
1645
+ },
1646
+ cancel() {
1647
+ unsub?.();
1648
+ if (ping)
1649
+ clearInterval(ping);
1650
+ }
1651
+ });
1652
+ return new Response(stream, {
1653
+ headers: {
1654
+ "content-type": "text/event-stream; charset=utf-8",
1655
+ "cache-control": "no-cache, no-transform",
1656
+ connection: "keep-alive"
1657
+ }
1658
+ });
1659
+ }
1660
+ function sseTrafficResponse(device, transportLabel) {
1661
+ let unsub;
1662
+ let ping;
1663
+ const stream = new ReadableStream({
1664
+ start(controller) {
1665
+ const enc = new TextEncoder;
1666
+ const send = (text) => {
1667
+ try {
1668
+ controller.enqueue(enc.encode(text));
1669
+ } catch {}
1670
+ };
1671
+ send(`event: hello
1672
+ data: ${JSON.stringify({ transport: transportLabel })}
1673
+
1674
+ `);
1675
+ unsub = subscribeTraffic(device, (sample) => {
1676
+ send(`event: traffic
1677
+ data: ${JSON.stringify(sample)}
1678
+
1679
+ `);
1680
+ });
1681
+ ping = setInterval(() => send(`: ping
1682
+
1598
1683
  `), 15000);
1599
1684
  },
1600
1685
  cancel() {
@@ -1775,7 +1860,12 @@ async function modulesRoutes(req, url) {
1775
1860
  if (p === "/api/modules" && req.method === "GET") {
1776
1861
  const cfg = getConfig();
1777
1862
  const src = getConfigSource();
1778
- return json3({ ...moduleSurface(cfg.tools), filter: cfg.tools, source: src, appViews: cfg.mcp.appViews });
1863
+ return json3({
1864
+ ...moduleSurface(cfg.tools),
1865
+ filter: cfg.tools,
1866
+ source: src,
1867
+ appViews: cfg.mcp.appViews
1868
+ });
1779
1869
  }
1780
1870
  if (p === "/api/modules/toggle" && req.method === "POST") {
1781
1871
  const b = await readJson(req);
@@ -2295,12 +2385,14 @@ async function runDashboard(cfg, transportLabel) {
2295
2385
  };
2296
2386
  async function dashboardRoute(req, url, srv, ca, tl) {
2297
2387
  if (url.pathname === "/api/stream") {
2298
- if (srv.upgrade(req, { data: {} }))
2388
+ const traffic = url.searchParams.has("traffic") ? url.searchParams.get("traffic") ?? "" : undefined;
2389
+ if (srv.upgrade(req, { data: { traffic } }))
2299
2390
  return;
2300
2391
  return new Response("WebSocket upgrade failed", { status: 400 });
2301
2392
  }
2302
2393
  if (url.pathname === "/api/sse") {
2303
- return sseResponse(tl);
2394
+ const traffic = url.searchParams.has("traffic") ? url.searchParams.get("traffic") ?? "" : undefined;
2395
+ return traffic === undefined ? sseResponse(tl) : sseTrafficResponse(traffic, tl);
2304
2396
  }
2305
2397
  const configResp = await configRoutes(req, url, ca);
2306
2398
  if (configResp)
@@ -2471,11 +2563,19 @@ async function runDashboard(cfg, transportLabel) {
2471
2563
  },
2472
2564
  websocket: {
2473
2565
  open(ws) {
2474
- ws.data.unsub = subscribe((e) => {
2475
- try {
2476
- ws.send(JSON.stringify({ type: "event", event: e }));
2477
- } catch {}
2478
- });
2566
+ if (ws.data.traffic !== undefined) {
2567
+ ws.data.unsub = subscribeTraffic(ws.data.traffic, (sample) => {
2568
+ try {
2569
+ ws.send(JSON.stringify({ type: "traffic", sample }));
2570
+ } catch {}
2571
+ });
2572
+ } else {
2573
+ ws.data.unsub = subscribe((e) => {
2574
+ try {
2575
+ ws.send(JSON.stringify({ type: "event", event: e }));
2576
+ } catch {}
2577
+ });
2578
+ }
2479
2579
  ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
2480
2580
  },
2481
2581
  message() {},
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  selectToolModules,
30
30
  setConfig,
31
31
  updateSummaryLine
32
- } from "./shared/library-t0cfgfyj.js";
32
+ } from "./shared/library-399v2azd.js";
33
33
  // src/server.ts
34
34
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
35
35
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./cli-mm90zmj3.js";
7
+ } from "./cli-hfhmh1qe.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,
@@ -359,7 +359,8 @@ var DashboardConfigSchema = z.object({
359
359
  captureBody: z.boolean().default(true),
360
360
  redactInput: z.boolean().default(false),
361
361
  maxBodyBytes: z.coerce.number().int().nonnegative().default(16384),
362
- token: z.string().optional()
362
+ token: z.string().optional(),
363
+ feedLimit: z.coerce.number().int().positive().max(1e4).default(200)
363
364
  });
364
365
  var MemoryConfigSchema = z.object({
365
366
  enabled: z.boolean().default(true),
@@ -572,7 +573,8 @@ function loadConfig(argv = process.argv.slice(2)) {
572
573
  captureBody: boolOpt(pick("dashboard-capture-body", "MIKROTIK_DASHBOARD__CAPTURE_BODY")),
573
574
  redactInput: boolOpt(pick("dashboard-redact-input", "MIKROTIK_DASHBOARD__REDACT_INPUT")),
574
575
  maxBodyBytes: pick("dashboard-max-body-bytes", "MIKROTIK_DASHBOARD__MAX_BODY_BYTES"),
575
- token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN")
576
+ token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN"),
577
+ feedLimit: pick("dashboard-feed-limit", "MIKROTIK_DASHBOARD__FEED_LIMIT")
576
578
  });
577
579
  const ssh = overlay(fileSsh, {
578
580
  keepAlive: boolOpt(pick("ssh-keep-alive", "MIKROTIK_SSH__KEEP_ALIVE")),
@@ -2761,6 +2763,43 @@ var BLOCK_TAG = "mcp-blocked";
2761
2763
  function normalizeMac(mac) {
2762
2764
  return mac.trim().toUpperCase().replace(/-/g, ":");
2763
2765
  }
2766
+ var resolvedSource = new Map;
2767
+ var firstLine = (s) => s.trim().split(`
2768
+ `)[0].trim();
2769
+ async function enableAccounting(ctx) {
2770
+ const out = await executeMikrotikCommand("/ip accounting set enabled=yes threshold=2000", ctx);
2771
+ return looksLikeError(out) ? firstLine(out) : null;
2772
+ }
2773
+ async function enableKidControl(ctx) {
2774
+ const count = await executeMikrotikCommand("/ip kid-control print count-only", ctx);
2775
+ if (looksLikeError(count))
2776
+ return firstLine(count);
2777
+ if ((parseLeadingNumber(count.trim()) ?? 0) > 0)
2778
+ return null;
2779
+ const days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((d) => `${d}=0s-1d`).join(" ");
2780
+ const add = await executeMikrotikCommand(`/ip kid-control add name=mcp-monitor ${days}`, ctx);
2781
+ return looksLikeError(add) ? firstLine(add) : null;
2782
+ }
2783
+ async function resolveSource(ctx) {
2784
+ const key = ctx.device ?? "";
2785
+ const cached = resolvedSource.get(key);
2786
+ if (cached)
2787
+ return { source: cached };
2788
+ const accErr = await enableAccounting(ctx);
2789
+ if (!accErr) {
2790
+ resolvedSource.set(key, "accounting");
2791
+ return { source: "accounting" };
2792
+ }
2793
+ const kidErr = await enableKidControl(ctx);
2794
+ if (!kidErr) {
2795
+ resolvedSource.set(key, "kid-control");
2796
+ return { source: "kid-control" };
2797
+ }
2798
+ return {
2799
+ source: "none",
2800
+ note: `No per-host traffic source. /ip accounting (v6): ${accErr}. Kid Control (v7): ${kidErr}.`
2801
+ };
2802
+ }
2764
2803
  async function fetchDevices(ctx) {
2765
2804
  const leaseOut = await executeMikrotikCommand("/ip dhcp-server lease print detail", ctx);
2766
2805
  const arpOut = await executeMikrotikCommand("/ip arp print detail", ctx);
@@ -2840,27 +2879,113 @@ async function sampleDeviceTraffic(ctx, ip) {
2840
2879
  uploadLimit: limit(upLim)
2841
2880
  };
2842
2881
  }
2843
- async function sampleAllTraffic(ctx) {
2844
- const ts = Date.now();
2845
- const out = await executeMikrotikCommand("/queue simple print stats detail", ctx);
2882
+ async function readQueueLimits(ctx) {
2883
+ const limits = {};
2884
+ const out = await executeMikrotikCommand("/queue simple print detail", ctx);
2846
2885
  if (isEmpty(out) || looksLikeError(out))
2847
- return { ts, queues: {} };
2848
- const queues = {};
2849
- const limit = (v) => v && v !== "0" ? v : "";
2886
+ return limits;
2887
+ const val = (v) => v && v !== "0" ? v : "";
2850
2888
  for (const row of parseRecords(out).rows) {
2851
2889
  const ip = (row.target ?? "").split("/")[0]?.trim();
2852
2890
  if (!ip)
2853
2891
  continue;
2854
- const [txB, rxB] = (row.bytes ?? "0/0").split("/");
2855
2892
  const [upLim, downLim] = (row["max-limit"] ?? "0/0").split("/");
2856
- queues[ip] = {
2857
- txBytes: parseLeadingNumber(txB) ?? 0,
2858
- rxBytes: parseLeadingNumber(rxB) ?? 0,
2859
- downloadLimit: limit(downLim),
2860
- uploadLimit: limit(upLim)
2893
+ limits[ip] = { download: val(downLim), upload: val(upLim) };
2894
+ }
2895
+ return limits;
2896
+ }
2897
+ function parseMagnitude(v) {
2898
+ const m = (v ?? "").trim().match(/^(-?\d+(?:\.\d+)?)\s*([kMGT])?/i);
2899
+ if (!m)
2900
+ return 0;
2901
+ const u = (m[2] ?? "").toLowerCase();
2902
+ const factor = u === "k" ? 1000 : u === "m" ? 1e6 : u === "g" ? 1e9 : u === "t" ? 1000000000000 : 1;
2903
+ return Number(m[1]) * factor;
2904
+ }
2905
+ var acctState = new Map;
2906
+ function aggregateHostTraffic(rows) {
2907
+ const hosts = {};
2908
+ const bump = (ip, key, b) => {
2909
+ if (!ip)
2910
+ return;
2911
+ (hosts[ip] ??= { rxBytes: 0, txBytes: 0 })[key] += b;
2912
+ };
2913
+ for (const row of rows) {
2914
+ const bytes = parseLeadingNumber(row.bytes) ?? 0;
2915
+ if (bytes <= 0)
2916
+ continue;
2917
+ bump((row["dst-address"] ?? "").trim(), "rxBytes", bytes);
2918
+ bump((row["src-address"] ?? "").trim(), "txBytes", bytes);
2919
+ }
2920
+ return hosts;
2921
+ }
2922
+ async function sampleAccounting(ctx, ts) {
2923
+ const key = ctx.device ?? "";
2924
+ await executeMikrotikCommand("/ip accounting snapshot take", ctx);
2925
+ const out = await executeMikrotikCommand("/ip accounting snapshot print", ctx);
2926
+ if (looksLikeError(out))
2927
+ throw new Error(firstLine(out));
2928
+ const deltas = aggregateHostTraffic(parseRecords(out).rows);
2929
+ const st = acctState.get(key) ?? { lastTs: 0, cum: new Map };
2930
+ const dtSec = st.lastTs ? Math.max(0.1, (ts - st.lastTs) / 1000) : 0;
2931
+ const hosts = {};
2932
+ for (const [ip, d] of Object.entries(deltas)) {
2933
+ const c = st.cum.get(ip) ?? { rx: 0, tx: 0 };
2934
+ c.rx += d.rxBytes;
2935
+ c.tx += d.txBytes;
2936
+ st.cum.set(ip, c);
2937
+ hosts[ip] = {
2938
+ rxRate: dtSec ? d.rxBytes / dtSec * 8 : 0,
2939
+ txRate: dtSec ? d.txBytes / dtSec * 8 : 0,
2940
+ rxBytes: c.rx,
2941
+ txBytes: c.tx
2942
+ };
2943
+ }
2944
+ st.lastTs = ts;
2945
+ acctState.set(key, st);
2946
+ return hosts;
2947
+ }
2948
+ function parseKidDevices(rows) {
2949
+ const hosts = {};
2950
+ for (const row of rows) {
2951
+ const ip = (row["ip-address"] ?? "").trim();
2952
+ if (!ip)
2953
+ continue;
2954
+ hosts[ip] = {
2955
+ rxRate: parseMagnitude(row["rate-down"]),
2956
+ txRate: parseMagnitude(row["rate-up"]),
2957
+ rxBytes: parseMagnitude(row["bytes-down"]),
2958
+ txBytes: parseMagnitude(row["bytes-up"])
2959
+ };
2960
+ }
2961
+ return hosts;
2962
+ }
2963
+ async function sampleKidControl(ctx) {
2964
+ const out = await executeMikrotikCommand("/ip kid-control device print detail", ctx);
2965
+ if (looksLikeError(out))
2966
+ throw new Error(firstLine(out));
2967
+ if (isEmpty(out))
2968
+ return {};
2969
+ return parseKidDevices(parseRecords(out).rows);
2970
+ }
2971
+ async function sampleAllTraffic(ctx) {
2972
+ const ts = Date.now();
2973
+ const limits = await readQueueLimits(ctx);
2974
+ const { source, note } = await resolveSource(ctx);
2975
+ if (source === "none")
2976
+ return { ts, source, note, hosts: {}, limits };
2977
+ try {
2978
+ const hosts = source === "accounting" ? await sampleAccounting(ctx, ts) : await sampleKidControl(ctx);
2979
+ return { ts, source, hosts, limits };
2980
+ } catch (e) {
2981
+ return {
2982
+ ts,
2983
+ source: "none",
2984
+ note: e instanceof Error ? e.message : String(e),
2985
+ hosts: {},
2986
+ limits
2861
2987
  };
2862
2988
  }
2863
- return { ts, queues };
2864
2989
  }
2865
2990
  async function setDeviceLimits(ctx, ip, opts) {
2866
2991
  const rate = (v) => {
@@ -8364,7 +8489,7 @@ var cache = null;
8364
8489
  async function gateway() {
8365
8490
  if (cache)
8366
8491
  return cache;
8367
- const { moduleCatalog } = await import("./cli-k18dkv4w.js");
8492
+ const { moduleCatalog } = await import("./cli-6xcbh1kn.js");
8368
8493
  const forIndex = [];
8369
8494
  const byName = new Map;
8370
8495
  for (const mod of moduleCatalog) {
@@ -359,7 +359,8 @@ var DashboardConfigSchema = z.object({
359
359
  captureBody: z.boolean().default(true),
360
360
  redactInput: z.boolean().default(false),
361
361
  maxBodyBytes: z.coerce.number().int().nonnegative().default(16384),
362
- token: z.string().optional()
362
+ token: z.string().optional(),
363
+ feedLimit: z.coerce.number().int().positive().max(1e4).default(200)
363
364
  });
364
365
  var MemoryConfigSchema = z.object({
365
366
  enabled: z.boolean().default(true),
@@ -569,7 +570,8 @@ function loadConfig(argv = process.argv.slice(2)) {
569
570
  captureBody: boolOpt(pick("dashboard-capture-body", "MIKROTIK_DASHBOARD__CAPTURE_BODY")),
570
571
  redactInput: boolOpt(pick("dashboard-redact-input", "MIKROTIK_DASHBOARD__REDACT_INPUT")),
571
572
  maxBodyBytes: pick("dashboard-max-body-bytes", "MIKROTIK_DASHBOARD__MAX_BODY_BYTES"),
572
- token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN")
573
+ token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN"),
574
+ feedLimit: pick("dashboard-feed-limit", "MIKROTIK_DASHBOARD__FEED_LIMIT")
573
575
  });
574
576
  const ssh = overlay(fileSsh, {
575
577
  keepAlive: boolOpt(pick("ssh-keep-alive", "MIKROTIK_SSH__KEEP_ALIVE")),
@@ -2713,6 +2715,7 @@ var BLOCK_TAG = "mcp-blocked";
2713
2715
  function normalizeMac(mac) {
2714
2716
  return mac.trim().toUpperCase().replace(/-/g, ":");
2715
2717
  }
2718
+ var resolvedSource = new Map;
2716
2719
  async function fetchDevices(ctx) {
2717
2720
  const leaseOut = await executeMikrotikCommand("/ip dhcp-server lease print detail", ctx);
2718
2721
  const arpOut = await executeMikrotikCommand("/ip arp print detail", ctx);
@@ -2792,6 +2795,7 @@ async function sampleDeviceTraffic(ctx, ip) {
2792
2795
  uploadLimit: limit(upLim)
2793
2796
  };
2794
2797
  }
2798
+ var acctState = new Map;
2795
2799
  async function setDeviceLimits(ctx, ip, opts) {
2796
2800
  const rate = (v) => {
2797
2801
  const t = (v ?? "").trim();
@@ -8294,7 +8298,7 @@ var cache = null;
8294
8298
  async function gateway() {
8295
8299
  if (cache)
8296
8300
  return cache;
8297
- const { moduleCatalog } = await import("./library-tmmvpwjf.js");
8301
+ const { moduleCatalog } = await import("./library-8hq0qqwm.js");
8298
8302
  const forIndex = [];
8299
8303
  const byName = new Map;
8300
8304
  for (const mod of moduleCatalog) {
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./library-t0cfgfyj.js";
7
+ } from "./library-399v2azd.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,