@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.
- package/README.md +135 -197
- package/package.json +1 -1
- package/src/db.ts +52 -7
- package/src/extract-test-results.ts +359 -0
- package/src/mcp-http.test.ts +4 -4
- package/src/mcp.ts +137 -7
- package/src/query.test.ts +156 -6
- package/src/query.ts +147 -13
- package/src/release.test.ts +1 -0
- package/src/setup.ts +22 -0
|
@@ -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
|
+
}
|
package/src/mcp-http.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
406
|
-
expect(tools2.length).toBe(
|
|
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(
|
|
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(
|
|
68
|
-
console.log(
|
|
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
|
|
636
|
-
|
|
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
|
|
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(
|