@tikoci/rosetta 0.4.1 → 0.4.2

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
@@ -17,6 +17,7 @@ Instead of vector embeddings, rosetta uses **SQLite [FTS5](https://www.sqlite.or
17
17
  - **144 hardware products** — CPU, RAM, storage, ports, PoE, wireless, license level, pricing
18
18
  - **2,874 performance benchmarks** — ethernet and IPSec throughput test results for 125 devices (64/512/1518-byte packets, multiple routing/bridging modes), plus block diagrams for 110
19
19
  - **46 RouterOS versions tracked** (7.9 through 7.23beta2) for command history
20
+ - **2 MCP CSV resources** for bulk reporting workflows: full benchmark dataset and full device catalog
20
21
  - Direct links to help.mikrotik.com for every page and section
21
22
 
22
23
  ---
@@ -212,6 +213,15 @@ powershell -c "irm bun.sh/install.ps1 | iex"
212
213
 
213
214
  > **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. The database in `~/.rosetta/ros-help.db` persists across updates.
214
215
 
216
+ ### MCP Resources for Reporting
217
+
218
+ If your MCP client supports resources, rosetta also exposes two read-only CSV datasets for bulk analysis and reporting:
219
+
220
+ - `rosetta://datasets/device-test-results.csv`
221
+ - `rosetta://datasets/devices.csv`
222
+
223
+ In VS Code Copilot, attach them via **Add Context > MCP Resources** or **MCP: Browse Resources**. Use tools for normal search and drill-down; use resources when you explicitly want the whole dataset as CSV.
224
+
215
225
  ---
216
226
 
217
227
  ## Install from Binary
@@ -245,10 +255,11 @@ Ask your AI assistant questions like:
245
255
  - *"Show me warnings about hardware offloading"*
246
256
  - *"Which MikroTik routers have L3HW offload, and more than 8 ports of 48V PoE? Include cost."*
247
257
  - *"Compare the RB5009 and CCR2004 IPSec throughput at 1518-byte packets."*
258
+ - *"My BGP routes stopped working after upgrading from 7.15 to 7.22 — what changed in the routing commands?"*
248
259
 
249
260
  ## MCP Tools
250
261
 
251
- The server provides 11 tools, designed to work together:
262
+ The server provides 13 tools, designed to work together:
252
263
 
253
264
  | Tool | What it does |
254
265
  |------|-------------|
@@ -260,6 +271,7 @@ The server provides 11 tools, designed to work together:
260
271
  | `routeros_search_callouts` | Search warnings, notes, and tips across all pages |
261
272
  | `routeros_search_changelogs` | Search parsed changelog entries — filter by version range, category, breaking changes |
262
273
  | `routeros_command_version_check` | Check which RouterOS versions include a command |
274
+ | `routeros_command_diff` | Diff two RouterOS versions — which command paths were added or removed between them |
263
275
  | `routeros_device_lookup` | Hardware specs for 144 MikroTik products — filter by architecture, RAM, storage, PoE, wireless, LTE. Includes ethernet/IPSec benchmarks and block diagrams for most devices |
264
276
  | `routeros_stats` | Database health: page/property/command counts, coverage stats |
265
277
  | `routeros_current_versions` | Fetch current RouterOS versions from MikroTik (live) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/db.ts CHANGED
@@ -20,7 +20,9 @@
20
20
  */
21
21
 
22
22
  import sqlite from "bun:sqlite";
23
- import { resolveDbPath } from "./paths.ts";
23
+ import { resolveDbPath, SCHEMA_VERSION } from "./paths.ts";
24
+
25
+ export { SCHEMA_VERSION };
24
26
 
25
27
  export const DB_PATH = resolveDbPath(import.meta.dirname);
26
28
 
@@ -29,7 +31,11 @@ export const db = new sqlite(DB_PATH);
29
31
  export function initDb() {
30
32
  db.run("PRAGMA journal_mode=WAL;");
31
33
  db.run("PRAGMA foreign_keys=ON;");
32
-
34
+ // Stamp schema version unconditionally — initDb() is only called by extractors
35
+ // (which produce a current-schema DB) and by the MCP server after the version
36
+ // check in mcp.ts. If you ever need to open a DB read-only without touching
37
+ // user_version, call `db.run("PRAGMA foreign_keys=ON;")` directly and skip initDb().
38
+ db.run(`PRAGMA user_version = ${SCHEMA_VERSION};`);
33
39
  db.run(`CREATE TABLE IF NOT EXISTS schema_migrations (
34
40
  version TEXT PRIMARY KEY,
35
41
  applied_at TEXT NOT NULL
@@ -342,6 +348,16 @@ export function initDb() {
342
348
  END;`);
343
349
  }
344
350
 
351
+ /**
352
+ * Verify the open DB was built with the expected schema version.
353
+ * Only meaningful after initDb() — initDb() itself stamps the version,
354
+ * so this is mainly a regression guard and for the test suite.
355
+ */
356
+ export function checkSchemaVersion(): { ok: boolean; actual: number; expected: number } {
357
+ const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
358
+ return { ok: row.user_version === SCHEMA_VERSION, actual: row.user_version, expected: SCHEMA_VERSION };
359
+ }
360
+
345
361
  export function getDbStats() {
346
362
  const count = (sql: string) =>
347
363
  Number((db.prepare(sql).get() as { c: number }).c ?? 0);
@@ -12,8 +12,10 @@
12
12
  *
13
13
  * Usage: bun run src/extract-test-results.ts [--concurrency N] [--delay MS]
14
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.
15
+ * Product page URL slug discovery: fetches sitemap.xml once to build a validated
16
+ * slug set, then applies (1) a hardcoded override table for the 15 products with
17
+ * opaque/unpredictable slugs, (2) heuristic candidates validated against the sitemap,
18
+ * (3) raw heuristics as a fallback for new products not yet in the sitemap.
17
19
  */
18
20
 
19
21
  import { parseHTML } from "linkedom";
@@ -31,6 +33,28 @@ function getFlag(name: string, fallback: number): number {
31
33
  const CONCURRENCY = getFlag("concurrency", 4);
32
34
  const DELAY_MS = getFlag("delay", 500);
33
35
  const PRODUCT_BASE = "https://mikrotik.com/product/";
36
+ const SITEMAP_URL = "https://mikrotik.com/sitemap.xml";
37
+
38
+ // ── Slug overrides ──
39
+ // Products whose URL slugs are undiscoverable by heuristics alone.
40
+ // Derived from sitemap.xml research (2026-04-04 — all 15 missing devices resolved).
41
+ const SLUG_OVERRIDES: Record<string, string> = {
42
+ "ATL LTE18 kit": "atl18",
43
+ "CRS418-8P-8G-2S+5axQ2axQ-RM": "crs418_8p_8g_2s_wifi",
44
+ "CRS518-16XS-2XQ-RM": "crs518_16xs_2xq",
45
+ "Chateau LTE18 ax": "chateaulte18_ax",
46
+ "FiberBox Plus": "fiberboxplus",
47
+ "KNOT Embedded LTE4 Global": "knot_emb_lte4_global",
48
+ "LHG LTE18 kit": "lhg_lte18",
49
+ "LHG XL 5 ax": "lhg_5_ax_xl",
50
+ "LHGG LTE7 kit": "lhgg_lte7",
51
+ "RB5009UPr+S+OUT": "rb5009_out",
52
+ "ROSE Data server (RDS)": "rds2216",
53
+ "SXTsq 5 ax": "sxtsq_5ax",
54
+ "cAP lite": "RBcAPL-2nD-307",
55
+ "hEX refresh": "hex_2024",
56
+ "wAP ax LTE7 kit": "wap_ax_lte7",
57
+ };
34
58
 
35
59
  // ── Types ──
36
60
 
@@ -51,14 +75,6 @@ interface ProductPageData {
51
75
 
52
76
  // ── HTML Parsing ──
53
77
 
54
- /** Decode HTML entities like &#110;&#111;&#110;&#101; 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
78
  /** Parse a performance-table element into test result rows. */
63
79
  function parsePerformanceTable(table: Element): { testType: string; rows: TestResultRow[] } {
64
80
  const rows: TestResultRow[] = [];
@@ -162,10 +178,10 @@ function generateSlugs(name: string, code: string | null): string[] {
162
178
  const cleanCode = norm(code);
163
179
 
164
180
  // 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, ""));
181
+ add(cleanCode.replace(/\+/g, "plus").replace(/[^a-zA-Z0-9plus-]+/g, "").replace(/^-|-$/g, ""));
166
182
 
167
183
  // 4. Product code as-is (original casing)
168
- add(cleanCode.replace(/[^a-zA-Z0-9\-]+/g, "").replace(/^-|-$/g, ""));
184
+ add(cleanCode.replace(/[^a-zA-Z0-9-]+/g, "").replace(/^-|-$/g, ""));
169
185
 
170
186
  // 5. Lowercased code: + → plus
171
187
  add(cleanCode.toLowerCase().replace(/\+/g, "plus").replace(/[^a-z0-9plus]+/g, "_").replace(/^_|_$/g, ""));
@@ -177,6 +193,59 @@ function generateSlugs(name: string, code: string | null): string[] {
177
193
  return slugs;
178
194
  }
179
195
 
196
+ /** Fetch sitemap.xml and return the set of all valid product slugs.
197
+ * Returns an empty set on error (callers fall back to heuristics). */
198
+ async function fetchSitemap(): Promise<Set<string>> {
199
+ try {
200
+ const resp = await fetch(SITEMAP_URL);
201
+ if (!resp.ok) {
202
+ console.warn(` [sitemap] HTTP ${resp.status} — falling back to heuristics`);
203
+ return new Set();
204
+ }
205
+ const xml = await resp.text();
206
+ const slugs = new Set<string>();
207
+ const re = /\/product\/([^<"]+)/g;
208
+ while (true) {
209
+ const m = re.exec(xml);
210
+ if (m === null) break;
211
+ slugs.add(m[1]);
212
+ }
213
+ console.log(` [sitemap] ${slugs.size} product slugs loaded`);
214
+ return slugs;
215
+ } catch {
216
+ console.warn(" [sitemap] fetch failed — falling back to heuristics");
217
+ return new Set();
218
+ }
219
+ }
220
+
221
+ /** Build ordered slug candidates for a product.
222
+ * Priority: (1) override table, (2) generated slugs that appear in sitemap,
223
+ * (3) remaining generated slugs as fallback. */
224
+ function buildSlugCandidates(name: string, code: string | null, sitemapSlugs: Set<string>): string[] {
225
+ const slugs: string[] = [];
226
+ const seen = new Set<string>();
227
+ const add = (s: string) => {
228
+ if (s && !seen.has(s)) { seen.add(s); slugs.push(s); }
229
+ };
230
+
231
+ // 1. Override table (highest priority — known-opaque slugs)
232
+ const override = SLUG_OVERRIDES[name];
233
+ if (override) add(override);
234
+
235
+ // 2. Heuristic candidates validated against sitemap
236
+ const generated = generateSlugs(name, code);
237
+ if (sitemapSlugs.size > 0) {
238
+ for (const s of generated) {
239
+ if (sitemapSlugs.has(s)) add(s);
240
+ }
241
+ }
242
+
243
+ // 3. All remaining heuristic candidates (fallback for new products not yet in sitemap)
244
+ for (const s of generated) add(s);
245
+
246
+ return slugs;
247
+ }
248
+
180
249
  /** Fetch and parse a single product page, trying multiple slug candidates. */
181
250
  async function fetchProductPage(slugs: string[]): Promise<ProductPageData | null> {
182
251
  for (const slug of slugs) {
@@ -257,10 +326,14 @@ if (devices.length === 0) {
257
326
 
258
327
  console.log(`Found ${devices.length} devices in database`);
259
328
 
329
+ // Fetch sitemap once to validate slugs and prioritize correct candidates
330
+ console.log("Loading product sitemap...");
331
+ const sitemapSlugs = await fetchSitemap();
332
+
260
333
  // Build device → candidate slugs mapping
261
334
  const deviceSlugs: Array<{ id: number; name: string; slugs: string[] }> = [];
262
335
  for (const dev of devices) {
263
- const slugs = generateSlugs(dev.product_name, dev.product_code);
336
+ const slugs = buildSlugCandidates(dev.product_name, dev.product_code, sitemapSlugs);
264
337
  deviceSlugs.push({ id: dev.id, name: dev.product_name, slugs });
265
338
  }
266
339
 
@@ -13,7 +13,11 @@
13
13
  *
14
14
  * Each test gets an isolated server on a fresh port.
15
15
  */
16
+ import sqlite from "bun:sqlite";
16
17
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
17
21
  import type { Subprocess } from "bun";
18
22
 
19
23
  // ── Helpers ──
@@ -31,42 +35,146 @@ interface ServerHandle {
31
35
  proc: Subprocess;
32
36
  }
33
37
 
38
+ interface ProcessLogs {
39
+ stdout: string[];
40
+ stderr: string[];
41
+ }
42
+
43
+ /** Continuously consume a subprocess stream and append decoded chunks. */
44
+ async function collectStream(stream: ReadableStream<Uint8Array>, sink: string[]): Promise<void> {
45
+ const reader = stream.getReader();
46
+ const decoder = new TextDecoder();
47
+ try {
48
+ while (true) {
49
+ const { value, done } = await reader.read();
50
+ if (done) break;
51
+ sink.push(decoder.decode(value));
52
+ }
53
+ } finally {
54
+ reader.releaseLock();
55
+ }
56
+ }
57
+
58
+ /** Keep only the last N characters from aggregated process logs for error messages. */
59
+ function tailLogs(logs: ProcessLogs, maxChars = 4000): string {
60
+ const merged = `--- stdout ---\n${logs.stdout.join("")}\n--- stderr ---\n${logs.stderr.join("")}`;
61
+ return merged.length > maxChars ? merged.slice(-maxChars) : merged;
62
+ }
63
+
34
64
  /** Start the MCP server on a random port, wait for it to be ready. */
35
- async function startServer(): Promise<ServerHandle> {
65
+ function createFixtureDb(dbPath: string): void {
66
+ const fixture = new sqlite(dbPath);
67
+ fixture.run(`CREATE TABLE pages (
68
+ id INTEGER PRIMARY KEY,
69
+ slug TEXT NOT NULL,
70
+ title TEXT NOT NULL,
71
+ path TEXT NOT NULL,
72
+ depth INTEGER NOT NULL,
73
+ parent_id INTEGER REFERENCES pages(id),
74
+ url TEXT NOT NULL,
75
+ text TEXT NOT NULL,
76
+ code TEXT NOT NULL,
77
+ code_lang TEXT,
78
+ author TEXT,
79
+ last_updated TEXT,
80
+ word_count INTEGER NOT NULL,
81
+ code_lines INTEGER NOT NULL,
82
+ html_file TEXT NOT NULL
83
+ );`);
84
+
85
+ fixture.run(`CREATE TABLE commands (
86
+ id INTEGER PRIMARY KEY,
87
+ path TEXT NOT NULL UNIQUE,
88
+ name TEXT NOT NULL,
89
+ type TEXT NOT NULL,
90
+ parent_path TEXT,
91
+ page_id INTEGER REFERENCES pages(id),
92
+ description TEXT,
93
+ ros_version TEXT
94
+ );`);
95
+
96
+ fixture.run(`INSERT INTO pages (
97
+ id, slug, title, path, depth, parent_id, url, text, code, code_lang,
98
+ author, last_updated, word_count, code_lines, html_file
99
+ ) VALUES (
100
+ 1, 'fixture', 'Fixture Page', 'RouterOS > Fixture', 1, NULL,
101
+ 'https://help.mikrotik.com/docs/spaces/ROS/pages/1/Fixture',
102
+ 'fixture text', '', NULL, 'test', NULL, 2, 0, 'fixture.html'
103
+ );`);
104
+
105
+ fixture.run(`INSERT INTO commands (
106
+ id, path, name, type, parent_path, page_id, description, ros_version
107
+ ) VALUES (
108
+ 1, '/system', 'system', 'dir', NULL, 1, 'fixture command', '7.22'
109
+ );`);
110
+
111
+ fixture.close();
112
+ }
113
+
114
+ async function startServer(dbPath: string): Promise<ServerHandle> {
36
115
  const port = nextPort();
37
116
  const proc = Bun.spawn(["bun", "run", "src/mcp.ts", "--http", "--port", String(port)], {
38
117
  cwd: `${import.meta.dirname}/..`,
39
118
  stdout: "pipe",
40
119
  stderr: "pipe",
41
- // Explicitly set DB_PATH so query.test.ts's process.env.DB_PATH=":memory:" override
42
- // is not inherited by the server subprocess.
43
- env: { ...process.env, HOST: "127.0.0.1", DB_PATH: `${import.meta.dirname}/../ros-help.db` },
120
+ // Use an isolated fixture DB and avoid network-dependent auto-downloads.
121
+ env: { ...process.env, HOST: "127.0.0.1", DB_PATH: dbPath },
122
+ });
123
+
124
+ const logs: ProcessLogs = { stdout: [], stderr: [] };
125
+ const stdoutCollector = collectStream(proc.stdout, logs.stdout);
126
+ const stderrCollector = collectStream(proc.stderr, logs.stderr);
127
+
128
+ let exitCode: number | null = null;
129
+ void proc.exited.then((code) => {
130
+ exitCode = code;
44
131
  });
45
132
 
46
- // Wait for server to be ready (reads stderr for the startup message)
47
- // 30s: 3 servers start in parallel during full test suite, contention extends startup time
48
- const deadline = Date.now() + 30_000;
133
+ // Wait for server readiness by polling the actual HTTP endpoint.
134
+ // Use a generous timeout because first boot may auto-download the DB.
135
+ const deadline = Date.now() + 120_000;
49
136
  let ready = false;
50
- const decoder = new TextDecoder();
51
- const reader = proc.stderr.getReader();
52
137
 
53
138
  while (Date.now() < deadline) {
54
- const { value, done } = await reader.read();
55
- if (done) break;
56
- const text = decoder.decode(value);
57
- if (text.includes(`/mcp`)) {
58
- ready = true;
139
+ if (exitCode !== null) {
59
140
  break;
60
141
  }
142
+
143
+ try {
144
+ const resp = await fetch(`http://127.0.0.1:${port}/mcp`, {
145
+ method: "GET",
146
+ headers: { Accept: "text/event-stream" },
147
+ });
148
+
149
+ // Endpoint is alive; missing session ID should return 400.
150
+ if (resp.status === 400) {
151
+ ready = true;
152
+ break;
153
+ }
154
+ } catch {
155
+ // Connection refused while server is still starting.
156
+ }
157
+
158
+ await Bun.sleep(200);
61
159
  }
62
- // Release the reader lock so the stream can be consumed later or cleaned up
63
- reader.releaseLock();
64
160
 
65
161
  if (!ready) {
66
162
  proc.kill();
67
- throw new Error(`Server failed to start on port ${port} within 10s`);
163
+ await proc.exited.catch(() => undefined);
164
+ await Promise.all([stdoutCollector, stderrCollector]);
165
+
166
+ const reason = exitCode !== null
167
+ ? `exited early with code ${exitCode}`
168
+ : "did not become ready before timeout";
169
+ throw new Error(
170
+ `Server failed to start on port ${port}: ${reason}\n${tailLogs(logs)}`,
171
+ );
68
172
  }
69
173
 
174
+ // Keep collectors running in background; they naturally finish when process exits.
175
+ void stdoutCollector;
176
+ void stderrCollector;
177
+
70
178
  return { port, url: `http://127.0.0.1:${port}/mcp`, proc };
71
179
  }
72
180
 
@@ -178,13 +286,19 @@ async function mcpNotification(
178
286
  // so sharing a server does not affect test independence. Starting one server instead
179
287
  // of three avoids startup-time contention when all test files run in parallel.
180
288
  let server: ServerHandle;
289
+ let fixtureDir: string;
290
+ let fixtureDbPath: string;
181
291
 
182
292
  beforeAll(async () => {
183
- server = await startServer();
184
- }, 30_000);
293
+ fixtureDir = mkdtempSync(join(tmpdir(), "rosetta-http-test-"));
294
+ fixtureDbPath = join(fixtureDir, "ros-help.db");
295
+ createFixtureDb(fixtureDbPath);
296
+ server = await startServer(fixtureDbPath);
297
+ }, 130_000);
185
298
 
186
299
  afterAll(async () => {
187
300
  await killServer(server);
301
+ rmSync(fixtureDir, { recursive: true, force: true });
188
302
  }, 15_000);
189
303
 
190
304
  describe("HTTP transport: session lifecycle", () => {
@@ -212,7 +326,7 @@ describe("HTTP transport: session lifecycle", () => {
212
326
 
213
327
  const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
214
328
  const tools = result.tools as Array<{ name: string }>;
215
- expect(tools.length).toBe(12);
329
+ expect(tools.length).toBe(13);
216
330
 
217
331
  const toolNames = tools.map((t) => t.name).sort();
218
332
  expect(toolNames).toContain("routeros_search");
@@ -228,6 +342,37 @@ describe("HTTP transport: session lifecycle", () => {
228
342
  expect(toolNames).toContain("routeros_current_versions");
229
343
  });
230
344
 
345
+ test("resources/list returns dataset resources after initialization", async () => {
346
+ const { sessionId } = await mcpInitialize(server.url);
347
+ await mcpNotification(server.url, sessionId, "notifications/initialized");
348
+
349
+ const messages = await mcpRequest(server.url, sessionId, "resources/list", 21);
350
+ expect(messages.length).toBe(1);
351
+
352
+ const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
353
+ const resources = result.resources as Array<{ uri: string; mimeType?: string }>;
354
+
355
+ expect(resources.some((resource) => resource.uri === "rosetta://datasets/device-test-results.csv")).toBe(true);
356
+ expect(resources.some((resource) => resource.uri === "rosetta://datasets/devices.csv")).toBe(true);
357
+ });
358
+
359
+ test("resources/read returns CSV content for device test dataset", async () => {
360
+ const { sessionId } = await mcpInitialize(server.url);
361
+ await mcpNotification(server.url, sessionId, "notifications/initialized");
362
+
363
+ const messages = await mcpRequest(server.url, sessionId, "resources/read", 22, {
364
+ uri: "rosetta://datasets/device-test-results.csv",
365
+ });
366
+ expect(messages.length).toBe(1);
367
+
368
+ const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
369
+ const contents = result.contents as Array<{ uri: string; mimeType?: string; text?: string }>;
370
+
371
+ expect(contents[0].uri).toBe("rosetta://datasets/device-test-results.csv");
372
+ expect(contents[0].mimeType).toBe("text/csv");
373
+ expect(contents[0].text).toContain("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency");
374
+ });
375
+
231
376
  test("tools/call works for routeros_stats", async () => {
232
377
  const { sessionId } = await mcpInitialize(server.url);
233
378
  await mcpNotification(server.url, sessionId, "notifications/initialized");
@@ -402,8 +547,8 @@ describe("HTTP transport: multi-session", () => {
402
547
  const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
403
548
  const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
404
549
 
405
- expect(tools1.length).toBe(12);
406
- expect(tools2.length).toBe(12);
550
+ expect(tools1.length).toBe(13);
551
+ expect(tools2.length).toBe(13);
407
552
  });
408
553
 
409
554
  test("deleting one session does not affect another", async () => {
@@ -425,7 +570,7 @@ describe("HTTP transport: multi-session", () => {
425
570
  // Client2 still works
426
571
  const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
427
572
  const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
428
- expect(tools.length).toBe(12);
573
+ expect(tools.length).toBe(13);
429
574
 
430
575
  // Client1 is gone
431
576
  const resp = await fetch(server.url, {
package/src/mcp.ts CHANGED
@@ -99,7 +99,7 @@ const { z } = await import("zod/v3");
99
99
  //
100
100
  // Check if DB has data BEFORE importing db.ts. If empty/missing,
101
101
  // auto-download so db.ts opens the real database.
102
- const { resolveDbPath } = await import("./paths.ts");
102
+ const { resolveDbPath, SCHEMA_VERSION } = await import("./paths.ts");
103
103
  const _dbPath = resolveDbPath(import.meta.dirname);
104
104
 
105
105
  const _pageCount = (() => {
@@ -126,12 +126,42 @@ if (_pageCount === 0) {
126
126
  }
127
127
  }
128
128
 
129
+ // Check schema version — a bunx auto-update may bring a new code version whose
130
+ // schema is incompatible with the existing ~/.rosetta/ros-help.db.
131
+ // MUST be checked before importing db.ts, because initDb() stamps user_version.
132
+ const _dbSchemaVersion = (() => {
133
+ try {
134
+ const check = new (require("bun:sqlite").default)(_dbPath, { readonly: true });
135
+ const row = check.prepare("PRAGMA user_version").get() as { user_version: number };
136
+ check.close();
137
+ return row.user_version;
138
+ } catch {
139
+ return SCHEMA_VERSION; // unreadable — assume ok, initDb() will stamp it
140
+ }
141
+ })();
142
+
143
+ if (_dbSchemaVersion !== SCHEMA_VERSION) {
144
+ const { downloadDb } = await import("./setup.ts");
145
+ const log = (msg: string) => process.stderr.write(`${msg}\n`);
146
+ log(`DB schema version mismatch (DB=${_dbSchemaVersion}, expected=${SCHEMA_VERSION}) — re-downloading updated database...`);
147
+ try {
148
+ await downloadDb(_dbPath, log);
149
+ log("Database updated successfully.");
150
+ } catch (e) {
151
+ log(`Auto-download failed: ${e}`);
152
+ log(`Run: ${process.argv[0]} --setup --force`);
153
+ }
154
+ }
155
+
129
156
  // Now import db.ts (opens the DB) and query.ts
130
157
  const { getDbStats, initDb } = await import("./db.ts");
131
158
  const {
132
159
  browseCommands,
133
160
  browseCommandsAtVersion,
134
161
  checkCommandVersions,
162
+ diffCommandVersions,
163
+ exportDevicesCsv,
164
+ exportDeviceTestsCsv,
135
165
  fetchCurrentVersions,
136
166
  getPage,
137
167
  lookupProperty,
@@ -155,6 +185,40 @@ const server = new McpServer({
155
185
  version: RESOLVED_VERSION,
156
186
  });
157
187
 
188
+ server.registerResource(
189
+ "device-test-results-csv",
190
+ "rosetta://datasets/device-test-results.csv",
191
+ {
192
+ title: "Device Test Results CSV",
193
+ description: "Full joined benchmark dataset as CSV for reporting and bulk export. Attach explicitly in clients that support MCP resources.",
194
+ mimeType: "text/csv",
195
+ },
196
+ async () => ({
197
+ contents: [{
198
+ uri: "rosetta://datasets/device-test-results.csv",
199
+ mimeType: "text/csv",
200
+ text: exportDeviceTestsCsv(),
201
+ }],
202
+ }),
203
+ );
204
+
205
+ server.registerResource(
206
+ "devices-csv",
207
+ "rosetta://datasets/devices.csv",
208
+ {
209
+ title: "Devices CSV",
210
+ description: "Full device catalog as CSV, including normalized RAM and storage fields plus product and block diagram URLs.",
211
+ mimeType: "text/csv",
212
+ },
213
+ async () => ({
214
+ contents: [{
215
+ uri: "rosetta://datasets/devices.csv",
216
+ mimeType: "text/csv",
217
+ text: exportDevicesCsv(),
218
+ }],
219
+ }),
220
+ );
221
+
158
222
  // ---- routeros_search ----
159
223
 
160
224
  server.registerTool(
@@ -635,6 +699,67 @@ Examples:
635
699
  },
636
700
  );
637
701
 
702
+ // ---- routeros_command_diff ----
703
+
704
+ server.registerTool(
705
+ "routeros_command_diff",
706
+ {
707
+ description: `Diff two RouterOS versions — which command paths were added or removed between them.
708
+
709
+ The most common RouterOS support query is "something broke after I upgraded." This tool
710
+ directly answers it by comparing the command tree between any two tracked versions.
711
+
712
+ Returns added[] (new in to_version) and removed[] (gone from to_version) with counts.
713
+ Use path_prefix to scope the diff to a subsystem (e.g., '/ip/firewall' or '/routing/bgp').
714
+
715
+ Command data covers 7.9–7.23beta2. Both versions must be in this range for complete results;
716
+ if a version is outside the range, a note warns that results may be incomplete.
717
+
718
+ **Typical workflow for upgrade breakage:**
719
+ 1. routeros_command_diff from_version="7.15" to_version="7.22" path_prefix="/ip/firewall"
720
+ → see which filter/mangle/nat commands changed
721
+ 2. routeros_search_changelogs from_version="7.15" to_version="7.22" category="firewall"
722
+ → read human-readable changelog entries for that subsystem
723
+ 3. routeros_command_version_check for a specific path that looks suspicious
724
+ → confirm exact version range for that command
725
+
726
+ **path_prefix tip:** Start broad (e.g., '/routing/bgp'), then narrow if the diff is large.
727
+ Without a prefix, a major-version diff can list hundreds of added paths.
728
+
729
+ → routeros_search_changelogs: read what changed (descriptions, breaking flags)
730
+ → routeros_command_version_check: check a specific command's full version history
731
+ → routeros_command_tree: browse the current command hierarchy at a path`,
732
+ inputSchema: {
733
+ from_version: z
734
+ .string()
735
+ .describe("The older RouterOS version to diff from (e.g., '7.15', '7.9')"),
736
+ to_version: z
737
+ .string()
738
+ .describe("The newer RouterOS version to diff to (e.g., '7.22', '7.23beta2')"),
739
+ path_prefix: z
740
+ .string()
741
+ .optional()
742
+ .describe("Optional: scope the diff to a command subtree (e.g., '/ip/firewall', '/routing/bgp', '/interface/bridge')"),
743
+ },
744
+ },
745
+ async ({ from_version, to_version, path_prefix }) => {
746
+ const result = diffCommandVersions(from_version, to_version, path_prefix);
747
+ if (result.added_count === 0 && result.removed_count === 0) {
748
+ const hint = [
749
+ result.note ?? null,
750
+ "No differences found. Possible reasons:",
751
+ "- Both versions have identical command trees for this path",
752
+ "- One or both versions may not be in our tracked range (7.9–7.23beta2)",
753
+ "Use routeros_stats to see available version range, or try a different path_prefix.",
754
+ ].filter(Boolean).join("\n");
755
+ return { content: [{ type: "text", text: hint }] };
756
+ }
757
+ return {
758
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
759
+ };
760
+ },
761
+ );
762
+
638
763
  // ---- routeros_device_lookup ----
639
764
 
640
765
  server.registerTool(
@@ -785,8 +910,11 @@ Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25
785
910
 
786
911
  **Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
787
912
 
913
+ Results include product_name, product_code, architecture — use routeros_device_lookup for full specs (CPU, RAM, ports, etc.).
914
+ For bulk export/reporting, attach the MCP resource rosetta://datasets/device-test-results.csv in clients that support MCP resources.
915
+
788
916
  Workflow:
789
- → routeros_device_lookup: get full specs + block diagram for a specific device from results
917
+ → routeros_device_lookup: get full specs (CPU, RAM, pricing) + block diagram for a specific device
790
918
  → routeros_search: find documentation about features relevant to the test type`,
791
919
  inputSchema: {
792
920
  test_type: z