@tikoci/rosetta 0.3.1 → 0.4.1

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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * extract-test-results.ts — Scrape MikroTik product pages for test results + block diagram URLs.
3
+ *
4
+ * Fetches each product page from mikrotik.com and extracts:
5
+ * - Ethernet test results (bridging/routing throughput at various packet sizes)
6
+ * - IPSec test results (tunnel throughput with various ciphers)
7
+ * - Block diagram PNG URL
8
+ * - Product page URL slug
9
+ *
10
+ * Idempotent: deletes all existing test results, updates device rows.
11
+ * Requires devices table to be populated first (via extract-devices.ts).
12
+ *
13
+ * Usage: bun run src/extract-test-results.ts [--concurrency N] [--delay MS]
14
+ *
15
+ * Product page URL slug discovery: fetches the product matrix page to build
16
+ * a name→slug mapping, then fetches each product page by slug.
17
+ */
18
+
19
+ import { parseHTML } from "linkedom";
20
+ import { db, initDb } from "./db.ts";
21
+
22
+ // ── CLI flags ──
23
+
24
+ const args = process.argv.slice(2);
25
+ function getFlag(name: string, fallback: number): number {
26
+ const idx = args.indexOf(`--${name}`);
27
+ if (idx !== -1 && args[idx + 1]) return Number(args[idx + 1]);
28
+ return fallback;
29
+ }
30
+
31
+ const CONCURRENCY = getFlag("concurrency", 4);
32
+ const DELAY_MS = getFlag("delay", 500);
33
+ const PRODUCT_BASE = "https://mikrotik.com/product/";
34
+
35
+ // ── Types ──
36
+
37
+ interface TestResultRow {
38
+ mode: string;
39
+ configuration: string;
40
+ packet_size: number;
41
+ throughput_kpps: number | null;
42
+ throughput_mbps: number | null;
43
+ }
44
+
45
+ interface ProductPageData {
46
+ slug: string;
47
+ ethernet_results: TestResultRow[];
48
+ ipsec_results: TestResultRow[];
49
+ block_diagram_url: string | null;
50
+ }
51
+
52
+ // ── HTML Parsing ──
53
+
54
+ /** Decode HTML entities like none to text. */
55
+ function decodeEntities(html: string): string {
56
+ const { document } = parseHTML("<div></div>");
57
+ const el = document.createElement("div");
58
+ el.innerHTML = html;
59
+ return el.textContent || "";
60
+ }
61
+
62
+ /** Parse a performance-table element into test result rows. */
63
+ function parsePerformanceTable(table: Element): { testType: string; rows: TestResultRow[] } {
64
+ const rows: TestResultRow[] = [];
65
+
66
+ // Header row: first <tr> in <thead> has [product_code, test_description]
67
+ const thead = table.querySelector("thead");
68
+ if (!thead) return { testType: "unknown", rows };
69
+
70
+ const headerRows = thead.querySelectorAll("tr");
71
+ if (headerRows.length < 2) return { testType: "unknown", rows };
72
+
73
+ // Determine test type from header description
74
+ const headerCells = headerRows[0].querySelectorAll("td");
75
+ const testDesc = headerCells.length >= 2 ? (headerCells[1].textContent || "").trim().toLowerCase() : "";
76
+ const testType = testDesc.includes("ipsec") ? "ipsec" : "ethernet";
77
+
78
+ // Determine packet sizes from the second header row
79
+ // Structure: [Mode, Configuration, (1518|1400) byte, 512 byte, 64 byte]
80
+ // The colspan=2 means each size has kpps + Mbps columns
81
+ const sizeRow = headerRows[1];
82
+ const sizeCells = sizeRow.querySelectorAll("td");
83
+ const packetSizes: number[] = [];
84
+ for (const cell of sizeCells) {
85
+ const text = (cell.textContent || "").trim();
86
+ const match = text.match(/^(\d+)\s*byte/i);
87
+ if (match) packetSizes.push(Number.parseInt(match[1], 10));
88
+ }
89
+
90
+ // If we couldn't find sizes in the header, use defaults
91
+ if (packetSizes.length === 0) {
92
+ if (testType === "ipsec") {
93
+ packetSizes.push(1400, 512, 64);
94
+ } else {
95
+ packetSizes.push(1518, 512, 64);
96
+ }
97
+ }
98
+
99
+ // Parse data rows from <tbody>
100
+ const tbody = table.querySelector("tbody");
101
+ if (!tbody) return { testType, rows };
102
+
103
+ for (const tr of tbody.querySelectorAll("tr")) {
104
+ const cells = tr.querySelectorAll("td");
105
+ if (cells.length < 2) continue;
106
+
107
+ const mode = (cells[0].textContent || "").trim();
108
+ const config = (cells[1].textContent || "").trim();
109
+
110
+ // Each packet size has 2 columns: kpps, Mbps
111
+ for (let i = 0; i < packetSizes.length; i++) {
112
+ const kppsIdx = 2 + i * 2;
113
+ const mbpsIdx = 3 + i * 2;
114
+ if (kppsIdx >= cells.length) break;
115
+
116
+ const kpps = Number.parseFloat((cells[kppsIdx].textContent || "").trim());
117
+ const mbps = mbpsIdx < cells.length
118
+ ? Number.parseFloat((cells[mbpsIdx].textContent || "").trim())
119
+ : null;
120
+
121
+ rows.push({
122
+ mode,
123
+ configuration: config,
124
+ packet_size: packetSizes[i],
125
+ throughput_kpps: Number.isNaN(kpps) ? null : kpps,
126
+ throughput_mbps: mbps !== null && Number.isNaN(mbps) ? null : mbps,
127
+ });
128
+ }
129
+ }
130
+
131
+ return { testType, rows };
132
+ }
133
+
134
+ /** Generate candidate URL slugs for a product.
135
+ * MikroTik slugs are wildly inconsistent — some use lowercased names with underscores,
136
+ * some use product codes with original casing, and + is sometimes "plus", sometimes dropped.
137
+ * Unicode superscripts (², ³) are transliterated to digits.
138
+ * We try multiple variants and use the first that returns 200. */
139
+ function generateSlugs(name: string, code: string | null): string[] {
140
+ const slugs: string[] = [];
141
+ const seen = new Set<string>();
142
+ const add = (s: string) => {
143
+ if (s && !seen.has(s)) {
144
+ seen.add(s);
145
+ slugs.push(s);
146
+ }
147
+ };
148
+
149
+ // Normalize Unicode superscripts to regular digits
150
+ const norm = (s: string) =>
151
+ s.replace(/²/g, "2").replace(/³/g, "3").replace(/¹/g, "1");
152
+
153
+ const cleanName = norm(name);
154
+
155
+ // 1. Lowercased name: + → plus, non-alphanum → _
156
+ add(cleanName.toLowerCase().replace(/\+/g, "plus").replace(/[^a-z0-9plus]+/g, "_").replace(/^_|_$/g, ""));
157
+
158
+ // 2. Lowercased name: drop + entirely
159
+ add(cleanName.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""));
160
+
161
+ if (code) {
162
+ const cleanCode = norm(code);
163
+
164
+ // 3. Product code as-is (original casing, + → plus, strip other specials)
165
+ add(cleanCode.replace(/\+/g, "plus").replace(/[^a-zA-Z0-9plus\-]+/g, "").replace(/^-|-$/g, ""));
166
+
167
+ // 4. Product code as-is (original casing)
168
+ add(cleanCode.replace(/[^a-zA-Z0-9\-]+/g, "").replace(/^-|-$/g, ""));
169
+
170
+ // 5. Lowercased code: + → plus
171
+ add(cleanCode.toLowerCase().replace(/\+/g, "plus").replace(/[^a-z0-9plus]+/g, "_").replace(/^_|_$/g, ""));
172
+
173
+ // 6. Lowercased code: drop +
174
+ add(cleanCode.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""));
175
+ }
176
+
177
+ return slugs;
178
+ }
179
+
180
+ /** Fetch and parse a single product page, trying multiple slug candidates. */
181
+ async function fetchProductPage(slugs: string[]): Promise<ProductPageData | null> {
182
+ for (const slug of slugs) {
183
+ const url = `${PRODUCT_BASE}${slug}`;
184
+ try {
185
+ const resp = await fetch(url);
186
+ if (resp.ok) {
187
+ const html = await resp.text();
188
+ return parseProductHtml(html, slug);
189
+ }
190
+ // Don't warn for intermediary attempts — only the last slug matters
191
+ } catch {
192
+ // network error, try next slug
193
+ }
194
+ }
195
+ console.warn(` [404] ${slugs[0]} (tried ${slugs.length} variants)`);
196
+ return null;
197
+ }
198
+
199
+ /** Parse product page HTML into structured data. */
200
+ function parseProductHtml(html: string, slug: string): ProductPageData | null {
201
+
202
+ const { document } = parseHTML(html);
203
+
204
+ // Parse performance tables
205
+ const tables = document.querySelectorAll("table.performance-table");
206
+ const ethernet_results: TestResultRow[] = [];
207
+ const ipsec_results: TestResultRow[] = [];
208
+
209
+ for (const table of tables) {
210
+ const { testType, rows } = parsePerformanceTable(table);
211
+ if (testType === "ipsec") {
212
+ ipsec_results.push(...rows);
213
+ } else {
214
+ ethernet_results.push(...rows);
215
+ }
216
+ }
217
+
218
+ // Find block diagram URL
219
+ let block_diagram_url: string | null = null;
220
+ const links = document.querySelectorAll("a");
221
+ for (const a of links) {
222
+ const text = (a.textContent || "").trim();
223
+ if (text === "Block Diagram") {
224
+ const href = a.getAttribute("href");
225
+ if (href) {
226
+ block_diagram_url = href.startsWith("http")
227
+ ? href
228
+ : `https://cdn.mikrotik.com${href}`;
229
+ }
230
+ break;
231
+ }
232
+ }
233
+
234
+ return { slug, ethernet_results, ipsec_results, block_diagram_url };
235
+ }
236
+
237
+ /** Sleep helper for rate limiting. */
238
+ function sleep(ms: number): Promise<void> {
239
+ return new Promise((resolve) => setTimeout(resolve, ms));
240
+ }
241
+
242
+ // ── Main ──
243
+
244
+ initDb();
245
+
246
+ // Get all devices from DB
247
+ const devices = db.prepare("SELECT id, product_name, product_code FROM devices ORDER BY product_name").all() as Array<{
248
+ id: number;
249
+ product_name: string;
250
+ product_code: string | null;
251
+ }>;
252
+
253
+ if (devices.length === 0) {
254
+ console.error("No devices in database. Run extract-devices.ts first.");
255
+ process.exit(1);
256
+ }
257
+
258
+ console.log(`Found ${devices.length} devices in database`);
259
+
260
+ // Build device → candidate slugs mapping
261
+ const deviceSlugs: Array<{ id: number; name: string; slugs: string[] }> = [];
262
+ for (const dev of devices) {
263
+ const slugs = generateSlugs(dev.product_name, dev.product_code);
264
+ deviceSlugs.push({ id: dev.id, name: dev.product_name, slugs });
265
+ }
266
+
267
+ // Idempotent: clear existing test results
268
+ db.run("DELETE FROM device_test_results");
269
+
270
+ // Prepare statements
271
+ const insertTest = db.prepare(`INSERT OR IGNORE INTO device_test_results (
272
+ device_id, test_type, mode, configuration, packet_size,
273
+ throughput_kpps, throughput_mbps
274
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
275
+
276
+ const updateDevice = db.prepare(`UPDATE devices
277
+ SET product_url = ?, block_diagram_url = ?
278
+ WHERE id = ?`);
279
+
280
+ console.log(`Fetching ${deviceSlugs.length} product pages (concurrency=${CONCURRENCY}, delay=${DELAY_MS}ms)...`);
281
+
282
+ let totalTests = 0;
283
+ let devicesWithTests = 0;
284
+ let devicesWithDiagrams = 0;
285
+ let fetchErrors = 0;
286
+
287
+ const insertAll = db.transaction(
288
+ (results: Array<{ deviceId: number; data: ProductPageData | null }>) => {
289
+ for (const { deviceId, data } of results) {
290
+ if (!data) {
291
+ fetchErrors++;
292
+ continue;
293
+ }
294
+
295
+ // Update device with URL and block diagram
296
+ updateDevice.run(
297
+ `https://mikrotik.com/product/${data.slug}`,
298
+ data.block_diagram_url,
299
+ deviceId,
300
+ );
301
+
302
+ if (data.block_diagram_url) devicesWithDiagrams++;
303
+
304
+ // Insert test results
305
+ const allResults = [
306
+ ...data.ethernet_results.map((r) => ({ ...r, test_type: "ethernet" as const })),
307
+ ...data.ipsec_results.map((r) => ({ ...r, test_type: "ipsec" as const })),
308
+ ];
309
+
310
+ if (allResults.length > 0) devicesWithTests++;
311
+
312
+ for (const r of allResults) {
313
+ insertTest.run(
314
+ deviceId,
315
+ r.test_type,
316
+ r.mode,
317
+ r.configuration,
318
+ r.packet_size,
319
+ r.throughput_kpps,
320
+ r.throughput_mbps,
321
+ );
322
+ totalTests++;
323
+ }
324
+ }
325
+ },
326
+ );
327
+
328
+ // Fetch all products with rate limiting
329
+ const allResults: Array<{ deviceId: number; data: ProductPageData | null }> = [];
330
+ let processed = 0;
331
+
332
+ for (let i = 0; i < deviceSlugs.length; i += CONCURRENCY) {
333
+ const batch = deviceSlugs.slice(i, i + CONCURRENCY);
334
+ const batchResults = await Promise.all(
335
+ batch.map(async (dev) => {
336
+ const data = await fetchProductPage(dev.slugs);
337
+ return { deviceId: dev.id, data };
338
+ }),
339
+ );
340
+ allResults.push(...batchResults);
341
+ processed += batch.length;
342
+
343
+ const pct = Math.round((processed / deviceSlugs.length) * 100);
344
+ process.stdout.write(`\r ${processed}/${deviceSlugs.length} (${pct}%)`);
345
+
346
+ if (i + CONCURRENCY < deviceSlugs.length) {
347
+ await sleep(DELAY_MS);
348
+ }
349
+ }
350
+ console.log(""); // newline after progress
351
+
352
+ // Insert all results in one transaction
353
+ insertAll(allResults);
354
+
355
+ console.log(`Test results: ${totalTests} rows for ${devicesWithTests} devices`);
356
+ console.log(`Block diagrams: ${devicesWithDiagrams} devices`);
357
+ if (fetchErrors > 0) {
358
+ console.warn(`Fetch errors: ${fetchErrors} products`);
359
+ }
@@ -212,7 +212,7 @@ describe("HTTP transport: session lifecycle", () => {
212
212
 
213
213
  const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
214
214
  const tools = result.tools as Array<{ name: string }>;
215
- expect(tools.length).toBe(11);
215
+ expect(tools.length).toBe(12);
216
216
 
217
217
  const toolNames = tools.map((t) => t.name).sort();
218
218
  expect(toolNames).toContain("routeros_search");
@@ -402,8 +402,8 @@ describe("HTTP transport: multi-session", () => {
402
402
  const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
403
403
  const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
404
404
 
405
- expect(tools1.length).toBe(11);
406
- expect(tools2.length).toBe(11);
405
+ expect(tools1.length).toBe(12);
406
+ expect(tools2.length).toBe(12);
407
407
  });
408
408
 
409
409
  test("deleting one session does not affect another", async () => {
@@ -425,7 +425,7 @@ describe("HTTP transport: multi-session", () => {
425
425
  // Client2 still works
426
426
  const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
427
427
  const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
428
- expect(tools.length).toBe(11);
428
+ expect(tools.length).toBe(12);
429
429
 
430
430
  // Client1 is gone
431
431
  const resp = await fetch(server.url, {
package/src/mcp.ts CHANGED
@@ -38,6 +38,12 @@ function getArg(name: string): string | undefined {
38
38
  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
39
39
  }
40
40
 
41
+ /** Format a clickable terminal hyperlink using OSC 8 escape sequences.
42
+ * Falls back to plain URL in terminals that don't support OSC 8. */
43
+ function link(url: string, display?: string): string {
44
+ return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
45
+ }
46
+
41
47
  if (args.includes("--version") || args.includes("-v")) {
42
48
  console.log(`rosetta ${RESOLVED_VERSION}`);
43
49
  process.exit(0);
@@ -64,8 +70,10 @@ if (args.includes("--help") || args.includes("-h")) {
64
70
  console.log(" DB_PATH Absolute path to ros-help.db (optional)");
65
71
  console.log(" PORT HTTP listen port (lower precedence than --port)");
66
72
  console.log(" HOST HTTP bind address (lower precedence than --host)");
67
- console.log(" TLS_CERT_PATH TLS certificate path (lower precedence than --tls-cert)");
68
- console.log(" TLS_KEY_PATH TLS private key path (lower precedence than --tls-key)");
73
+ console.log();
74
+ console.log(`Quick start: bunx @tikoci/rosetta --setup`);
75
+ console.log(`Project: ${link("https://github.com/tikoci/rosetta")}`);
76
+ console.log(`Docs: ${link("https://help.mikrotik.com/docs/spaces/ROS/overview", "help.mikrotik.com")}`);
69
77
  process.exit(0);
70
78
  }
71
79
 
@@ -130,6 +138,8 @@ const {
130
138
  searchCallouts,
131
139
  searchChangelogs,
132
140
  searchDevices,
141
+ searchDeviceTests,
142
+ getTestResultMeta,
133
143
  searchPages,
134
144
  searchProperties,
135
145
  } = await import("./query.ts");
@@ -630,16 +640,26 @@ Examples:
630
640
  server.registerTool(
631
641
  "routeros_device_lookup",
632
642
  {
633
- description: `Look up MikroTik hardware specs or search for devices matching criteria.
643
+ description: `Look up MikroTik hardware specs, performance benchmarks, or search for devices matching criteria.
634
644
 
635
- 144 products from mikrotik.com/products/matrix (March 2026). Returns hardware specs
636
- including CPU, RAM, storage, ports, PoE, wireless, license level, and price.
645
+ 144 products from mikrotik.com (March 2026). Returns hardware specs, official test results,
646
+ block diagram URLs, and pricing.
637
647
 
638
648
  **How it works:**
639
- - If query matches a product name or code exactly → returns full specs for that device
640
- - Otherwise → FTS search + optional structured filters → returns matching devices
649
+ - If query matches a product name or code exactly → returns full specs + test results + block diagram
650
+ - Otherwise → FTS search + optional structured filters → returns matching devices (compact)
641
651
  - Filters can be used alone (no query) to find devices by capability
642
652
 
653
+ **Test results** (from mikrotik.com per-product pages):
654
+ - Ethernet: bridging/routing throughput at 64/512/1518 byte packets (kpps + Mbps)
655
+ - IPSec: tunnel throughput with various AES/SHA configurations
656
+ - Key metric: "Routing 25 ip filter rules @ 512 byte" is a common routing performance gauge
657
+ - Devices with L3HW offload show additional hardware-accelerated routing rows
658
+ - Included automatically for exact/single-device lookups — no extra call needed
659
+
660
+ **Block diagram**: internal switch/CPU/PHY architecture diagram URL (PNG).
661
+ Shows bus topology and per-port bandwidth limits — useful for understanding SoC bottlenecks.
662
+
643
663
  **License levels** determine feature availability:
644
664
  - L3: CPE/home (no routing protocols, limited queues)
645
665
  - L4: standard (OSPF, BGP, all firewall features)
@@ -654,6 +674,7 @@ including CPU, RAM, storage, ports, PoE, wireless, license level, and price.
654
674
  - SMIPS: lowest-end (hAP lite)
655
675
 
656
676
  Workflow — combine with other tools:
677
+ → routeros_search_tests: cross-device performance ranking (all 125 devices at once, e.g., 512B routing benchmark)
657
678
  → routeros_search: find documentation for features relevant to a device
658
679
  → routeros_command_tree: check commands available for a feature
659
680
  → routeros_current_versions: check latest firmware for the device
@@ -739,6 +760,115 @@ Data: 144 products, March 2026 snapshot. Not all MikroTik products ever made —
739
760
  },
740
761
  );
741
762
 
763
+ // ---- routeros_search_tests ----
764
+
765
+ server.registerTool(
766
+ "routeros_search_tests",
767
+ {
768
+ description: `Query device performance test results across all devices.
769
+
770
+ Returns throughput benchmarks from mikrotik.com product pages — one call replaces
771
+ what would otherwise require 125+ individual device lookups.
772
+
773
+ **Data:** 2,874 test results across 125 devices (March 2026).
774
+ - Ethernet: bridging/routing throughput at 64/512/1518 byte packets
775
+ - IPSec: tunnel throughput with AES/SHA cipher configurations
776
+ - Results include kpps (packets/sec) and Mbps
777
+
778
+ **Common queries:**
779
+ - Routing performance ranking: test_type="ethernet", mode="Routing", configuration="25 ip filter rules", packet_size=512
780
+ - Bridge performance: test_type="ethernet", mode="Bridging", configuration="25 bridge filter"
781
+ - IPSec throughput: test_type="ipsec", mode="Single tunnel", configuration="AES-128-CBC"
782
+
783
+ **Configuration matching:** Uses LIKE (substring) — "25 ip filter" matches "25 ip filter rules".
784
+ Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25 bridge filter rules").
785
+
786
+ **Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
787
+
788
+ Workflow:
789
+ → routeros_device_lookup: get full specs + block diagram for a specific device from results
790
+ → routeros_search: find documentation about features relevant to the test type`,
791
+ inputSchema: {
792
+ test_type: z
793
+ .string()
794
+ .optional()
795
+ .describe("Filter: 'ethernet' or 'ipsec'"),
796
+ mode: z
797
+ .string()
798
+ .optional()
799
+ .describe("Filter: e.g., 'Routing', 'Bridging', 'Single tunnel', '256 tunnels'"),
800
+ configuration: z
801
+ .string()
802
+ .optional()
803
+ .describe("Filter (substring match): e.g., '25 ip filter rules', 'AES-128-CBC + SHA1', 'none (fast path)'"),
804
+ packet_size: z
805
+ .number()
806
+ .int()
807
+ .optional()
808
+ .describe("Filter: packet size in bytes (64, 512, 1400, 1518)"),
809
+ sort_by: z
810
+ .enum(["mbps", "kpps"])
811
+ .optional()
812
+ .default("mbps")
813
+ .describe("Sort results by throughput metric (default: mbps)"),
814
+ limit: z
815
+ .number()
816
+ .int()
817
+ .min(1)
818
+ .max(200)
819
+ .optional()
820
+ .default(50)
821
+ .describe("Max results (default 50, max 200)"),
822
+ },
823
+ },
824
+ async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
825
+ const hasFilters = test_type || mode || configuration || packet_size;
826
+
827
+ if (!hasFilters) {
828
+ // Discovery mode: return available filter values
829
+ const meta = getTestResultMeta();
830
+ return {
831
+ content: [{
832
+ type: "text",
833
+ text: JSON.stringify({
834
+ message: "No filters provided. Here are the available values — use these to build your query:",
835
+ ...meta,
836
+ hint: "Common query: test_type='ethernet', mode='Routing', configuration='25 ip filter rules', packet_size=512",
837
+ }, null, 2),
838
+ }],
839
+ };
840
+ }
841
+
842
+ const result = searchDeviceTests(
843
+ { test_type, mode, configuration, packet_size, sort_by },
844
+ limit,
845
+ );
846
+
847
+ if (result.results.length === 0) {
848
+ const hints = [
849
+ "Call with no filters to see available test types, modes, and configurations",
850
+ configuration ? `Try a shorter configuration substring (e.g., "25 ip filter" instead of the full string)` : null,
851
+ ].filter(Boolean);
852
+ return {
853
+ content: [{
854
+ type: "text",
855
+ text: `No test results matched the filters.\n\nTry:\n${hints.map((h) => `- ${h}`).join("\n")}`,
856
+ }],
857
+ };
858
+ }
859
+
860
+ return {
861
+ content: [{
862
+ type: "text",
863
+ text: JSON.stringify({
864
+ ...result,
865
+ has_more: result.total > result.results.length,
866
+ }, null, 2),
867
+ }],
868
+ };
869
+ },
870
+ );
871
+
742
872
  // ---- routeros_current_versions ----
743
873
 
744
874
  server.registerTool(