@tikoci/rosetta 0.2.0 → 0.3.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP server for searching [MikroTik RouterOS documentation](https://help.mikrotik.com/docs/spaces/ROS/overview). Gives your AI assistant searchable access to 317 documentation pages, 4,860 property definitions, 40,000-entry command tree, and 144 hardware product specs — with direct links to help.mikrotik.com.
4
4
 
5
- Tested with **Claude Desktop**, **Claude Code**, **VS Code Copilot** (including Copilot CLI), and **VS Code** on macOS and Linux.
5
+ Tested with **Claude Desktop**, **Claude Code**, **VS Code Copilot** (including Copilot CLI), **Cursor**, and **OpenAI Codex** on macOS, Linux, and Windows.
6
6
 
7
7
  ## What is SQL-as-RAG?
8
8
 
@@ -10,7 +10,7 @@ Most retrieval-augmented generation (RAG) systems use vector embeddings to searc
10
10
 
11
11
  For structured technical documentation like RouterOS, full-text search with [BM25 ranking](https://www.sqlite.org/fts5.html#the_bm25_function) beats vector similarity. Technical terms like "dhcp-snooping" or "/ip/firewall/filter" are exact tokens — [porter stemming](https://www.sqlite.org/fts5.html#porter_tokenizer) and proximity matching handle the rest. No embedding pipeline, no vector database, no API keys. Just a single SQLite file that searches in milliseconds.
12
12
 
13
- The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools → your AI assistant.** The database is built once from MikroTik's official Confluence documentation export, then the MCP server exposes 10 search tools over stdio transport.
13
+ The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools → your AI assistant.** The database is built once from MikroTik's official Confluence documentation export, then the MCP server exposes 11 search tools over stdio or HTTP transport.
14
14
 
15
15
  ## What's Inside
16
16
 
@@ -24,45 +24,75 @@ The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools
24
24
 
25
25
  ## Quick Start
26
26
 
27
- Download a pre-built binary from [Releases](https://github.com/tikoci/rosetta/releases) — no Bun, Node.js, or other tools required.
27
+ ## MCP Discovery Status
28
28
 
29
- ### 1. Download
29
+ - GitHub MCP Registry listing: planned
30
+ - Official MCP Registry publication: metadata is now prepared in `server.json`
30
31
 
31
- Go to the [latest release](https://github.com/tikoci/rosetta/releases/latest) and download the ZIP for your platform:
32
+ Local install remains the primary path today (`bunx @tikoci/rosetta`).
32
33
 
33
- | Platform | File |
34
- |----------|------|
35
- | macOS (Apple Silicon) | `rosetta-macos-arm64.zip` |
36
- | macOS (Intel) | `rosetta-macos-x64.zip` |
37
- | Windows | `rosetta-windows-x64.zip` |
38
- | Linux | `rosetta-linux-x64.zip` |
34
+ When ready to publish to the official registry:
39
35
 
40
- Extract the ZIP to a permanent location (e.g., `~/rosetta` or `C:\rosetta`).
36
+ ```sh
37
+ brew install mcp-publisher
38
+ mcp-publisher validate server.json
39
+ mcp-publisher login github
40
+ mcp-publisher publish server.json
41
+ ```
42
+
43
+ After publication, the server should be discoverable via:
44
+
45
+ ```sh
46
+ curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.tikoci/rosetta"
47
+ ```
48
+
49
+ ### Option A: Install with Bun (recommended)
41
50
 
42
- ### 2. Run Setup
51
+ Zero install, zero config, no binary signing issues. Requires [Bun](https://bun.sh/) — no Gatekeeper or SmartScreen warnings since there's no compiled binary to sign.
43
52
 
44
- Open a terminal in the extracted folder and run:
53
+ **1. Install Bun** (if you don't have it):
45
54
 
46
55
  ```sh
47
- ./rosetta --setup
56
+ # macOS / Linux
57
+ curl -fsSL https://bun.sh/install | bash
58
+
59
+ # Windows
60
+ powershell -c "irm bun.sh/install.ps1 | iex"
48
61
  ```
49
62
 
50
- On Windows:
51
- ```powershell
52
- .\rosetta.exe --setup
63
+ **2. Configure your MCP client** with `bunx @tikoci/rosetta` as the command. No setup step needed — the database downloads automatically on first launch (~50 MB compressed).
64
+
65
+ <details>
66
+ <summary><b>VS Code Copilot</b></summary>
67
+
68
+ Open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`), choose **"MCP: Add Server…"**, select **"Command (stdio)"**, enter `bunx` as the command, and `@tikoci/rosetta` as the argument.
69
+
70
+ Or add to User Settings JSON (`Cmd+Shift+P` → "Preferences: Open User Settings (JSON)"):
71
+
72
+ ```json
73
+ "mcp": {
74
+ "servers": {
75
+ "rosetta": {
76
+ "command": "bunx",
77
+ "args": ["@tikoci/rosetta"]
78
+ }
79
+ }
80
+ }
53
81
  ```
54
82
 
55
- This downloads the documentation database (~50 MB compressed, ~230 MB on disk) and prints configuration instructions for your MCP client.
83
+ </details>
56
84
 
57
- > **macOS Gatekeeper:** If macOS blocks the binary, go to **System Settings → Privacy & Security** and click **Allow Anyway**, then run again. Or from Terminal: `xattr -d com.apple.quarantine ./rosetta`
58
- >
59
- > **Windows SmartScreen:** If Windows warns about an unrecognized app, click **More info → Run anyway**.
85
+ <details>
86
+ <summary><b>Claude Code</b></summary>
60
87
 
61
- ### 3. Configure Your MCP Client
88
+ ```sh
89
+ claude mcp add rosetta -- bunx @tikoci/rosetta
90
+ ```
62
91
 
63
- The `--setup` command prints the exact config for your platform. Here's what it looks like for each client:
92
+ </details>
64
93
 
65
- #### Claude Desktop
94
+ <details>
95
+ <summary><b>Claude Desktop</b></summary>
66
96
 
67
97
  Edit your Claude Desktop config file:
68
98
  - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
@@ -74,37 +104,97 @@ Add (or merge into existing config):
74
104
  {
75
105
  "mcpServers": {
76
106
  "rosetta": {
77
- "command": "/path/to/rosetta"
107
+ "command": "bunx",
108
+ "args": ["@tikoci/rosetta"]
78
109
  }
79
110
  }
80
111
  }
81
112
  ```
82
113
 
83
- Replace `/path/to/rosetta` with the actual path printed by `--setup`. Then **restart Claude Desktop**.
114
+ > **PATH note:** Claude Desktop on macOS doesn't always inherit your shell PATH. If `bunx` isn't found, use the full path — typically `~/.bun/bin/bunx`. Run `which bunx` to find it, or use `bunx @tikoci/rosetta --setup` which prints the full-path config for you.
84
115
 
85
- #### Claude Code
116
+ Then **restart Claude Desktop**.
86
117
 
87
- ```sh
88
- claude mcp add rosetta /path/to/rosetta
89
- ```
118
+ </details>
119
+
120
+ <details>
121
+ <summary><b>GitHub Copilot CLI</b></summary>
122
+
123
+ Inside a `copilot` session, type `/mcp add` to open the interactive form:
124
+
125
+ - **Server Name:** `routeros-rosetta`
126
+ - **Server Type:** 2 (STDIO)
127
+ - **Command:** `bunx @tikoci/rosetta`
90
128
 
91
- #### VS Code Copilot
129
+ Press <kbd>Tab</kbd> to navigate fields, <kbd>Ctrl+S</kbd> to save.
92
130
 
93
- The simplest way: open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`), choose **"MCP: Add Server…"**, select **"Command (stdio)"**, and enter the full path to the `rosetta` binary.
131
+ </details>
94
132
 
95
- Or add to your User Settings JSON (`Cmd+Shift+P` → "Preferences: Open User Settings (JSON)"):
133
+ <details>
134
+ <summary><b>Cursor</b></summary>
135
+
136
+ Open **Settings → MCP** and add a new server:
96
137
 
97
138
  ```json
98
- "mcp": {
99
- "servers": {
139
+ {
140
+ "mcpServers": {
100
141
  "rosetta": {
101
- "command": "/path/to/rosetta"
142
+ "command": "bunx",
143
+ "args": ["@tikoci/rosetta"]
102
144
  }
103
145
  }
104
146
  }
105
147
  ```
106
148
 
107
- ### 4. Try It
149
+ </details>
150
+
151
+ <details>
152
+ <summary><b>OpenAI Codex</b></summary>
153
+
154
+ ```sh
155
+ codex mcp add rosetta -- bunx @tikoci/rosetta
156
+ ```
157
+
158
+ > **Note:** ChatGPT Apps require a remote HTTPS MCP endpoint and cannot use local stdio servers like this one. Codex (CLI and desktop app) supports stdio and works with `bunx`.
159
+
160
+ </details>
161
+
162
+ **That's it.** First launch takes a moment to download the database; subsequent starts are instant. The database is stored in `~/.rosetta/ros-help.db`.
163
+
164
+ > **Verify it works:** Run `bunx @tikoci/rosetta --setup` to see the database status and print config for all MCP clients.
165
+ >
166
+ > **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. No manual update needed. (Note: the `~/.rosetta/ros-help.db` database persists across updates — it's re-downloaded only when missing or when you run `--setup --force`.)
167
+
168
+ ### Option B: Pre-built binary (no runtime needed)
169
+
170
+ Download a compiled binary from [Releases](https://github.com/tikoci/rosetta/releases) — no Bun, Node.js, or other tools required.
171
+
172
+ **1. Download** the ZIP for your platform from the [latest release](https://github.com/tikoci/rosetta/releases/latest):
173
+
174
+ | Platform | File |
175
+ |----------|------|
176
+ | macOS (Apple Silicon) | `rosetta-macos-arm64.zip` |
177
+ | macOS (Intel) | `rosetta-macos-x64.zip` |
178
+ | Windows | `rosetta-windows-x64.zip` |
179
+ | Linux | `rosetta-linux-x64.zip` |
180
+
181
+ Extract the ZIP to a permanent location (e.g., `~/rosetta` or `C:\rosetta`).
182
+
183
+ **2. Run setup** to download the database and see MCP client config:
184
+
185
+ ```sh
186
+ ./rosetta --setup
187
+ ```
188
+
189
+ On Windows: `.\rosetta.exe --setup`
190
+
191
+ > **macOS Gatekeeper:** If macOS blocks the binary: `xattr -d com.apple.quarantine ./rosetta` or go to **System Settings → Privacy & Security → Allow Anyway**.
192
+ >
193
+ > **Windows SmartScreen:** Click **More info → Run anyway**.
194
+
195
+ **3. Configure your MCP client** using the config printed by `--setup`. It uses the full path to the binary — paste it into your MCP client's config as shown.
196
+
197
+ ### Try It
108
198
 
109
199
  Ask your AI assistant questions like:
110
200
 
@@ -117,7 +207,7 @@ Ask your AI assistant questions like:
117
207
 
118
208
  ## MCP Tools
119
209
 
120
- The server provides 10 tools, designed to work together:
210
+ The server provides 11 tools, designed to work together:
121
211
 
122
212
  | Tool | What it does |
123
213
  |------|-------------|
@@ -127,6 +217,7 @@ The server provides 10 tools, designed to work together:
127
217
  | `routeros_search_properties` | Search across 4,860 property names and descriptions |
128
218
  | `routeros_command_tree` | Browse the `/ip/firewall/filter` style command hierarchy |
129
219
  | `routeros_search_callouts` | Search warnings, notes, and tips across all pages |
220
+ | `routeros_search_changelogs` | Search parsed changelog entries — filter by version range, category, breaking changes |
130
221
  | `routeros_command_version_check` | Check which RouterOS versions include a command |
131
222
  | `routeros_device_lookup` | Hardware specs for 144 MikroTik products — filter by architecture, RAM, storage, PoE, wireless, LTE |
132
223
  | `routeros_stats` | Database health: page/property/command counts, coverage stats |
@@ -134,78 +225,79 @@ The server provides 10 tools, designed to work together:
134
225
 
135
226
  The AI assistant typically starts with `routeros_search`, then drills into specific pages, properties, or the command tree based on what it finds. Each tool's description includes workflow hints (e.g., "→ use `routeros_get_page` to read full content") and empty-result suggestions so the AI knows how to chain tools together — this is where most of the tuning effort goes.
136
227
 
137
- ## Alternative: Run with Bun
228
+ ## Troubleshooting
138
229
 
139
- If you have [Bun](https://bun.sh/) installed and prefer not to use the pre-built binary — for example, to avoid Gatekeeper/SmartScreen warnings, or to inspect the code you're running — you can run the MCP server directly from source. No HTML export or command tree data is needed; the database is downloaded from GitHub Releases just like the binary option.
230
+ | Issue | Solution |
231
+ |-------|----------|
232
+ | **First launch is slow** | One-time database download (~50 MB). Subsequent starts are instant. |
233
+ | **`npx @tikoci/rosetta` fails** | This package requires Bun, not Node.js. Use `bunx` instead of `npx`. |
234
+ | **`npm install -g` then `rosetta` fails** | Global npm install works if Bun is on PATH — it delegates to `bun` at runtime. But prefer `bunx` — it's simpler and auto-updates. |
235
+ | **ChatGPT Apps can't connect with `bunx @tikoci/rosetta`** | Expected: ChatGPT Apps supports remote HTTPS MCP endpoints, not local stdio command launch. Use OpenAI Codex for local stdio, or deploy/tunnel a remote MCP URL for ChatGPT. |
236
+ | **Claude Desktop can't find `bunx`** | Claude Desktop on macOS may not inherit shell PATH. Use the full path to bunx (run `which bunx` to find it, typically `~/.bun/bin/bunx`). `bunx @tikoci/rosetta --setup` prints the full-path config. |
237
+ | **macOS Gatekeeper blocks binary** | Use `bunx` install (no Gatekeeper issues), or: `xattr -d com.apple.quarantine ./rosetta` |
238
+ | **Windows SmartScreen warning** | Use `bunx` install (no SmartScreen issues), or click **More info → Run anyway** |
239
+ | **How to update** | `bunx` always uses the latest published version. For binaries, re-download from [Releases](https://github.com/tikoci/rosetta/releases/latest). |
140
240
 
141
- ### 1. Install Bun
241
+ ## HTTP Transport
142
242
 
143
- ```sh
144
- # macOS / Linux
145
- curl -fsSL https://bun.sh/install | bash
146
- # or: brew install oven-sh/bun/bun
243
+ Most MCP clients use stdio (the default). Some — like the OpenAI platform and remote/LAN setups — require an HTTP endpoint instead. Rosetta supports the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) via the `--http` flag:
147
244
 
148
- # Windows
149
- powershell -c "irm bun.sh/install.ps1 | iex"
245
+ ```sh
246
+ rosetta --http # http://localhost:8080/mcp
247
+ rosetta --http --port 9090 # custom port
248
+ rosetta --http --host 0.0.0.0 # accessible from LAN
150
249
  ```
151
250
 
152
- ### 2. Download and Install
251
+ Then point your MCP client at the URL:
153
252
 
154
- ```sh
155
- git clone https://github.com/tikoci/rosetta.git
156
- cd rosetta
157
- bun install
253
+ ```json
254
+ { "url": "http://localhost:8080/mcp" }
158
255
  ```
159
256
 
160
- Or download the source archive from the [latest release](https://github.com/tikoci/rosetta/releases/latest) ("Source code" ZIP or tarball), extract it, and run `bun install`.
257
+ **Key facts:**
161
258
 
162
- ### 3. Run Setup
259
+ - **Read-only** — the server queries a local SQLite database. It does not store data, accept uploads, or modify anything.
260
+ - **No authentication** — designed for local/trusted-network use. For public exposure, put it behind a reverse proxy (nginx, caddy) with TLS and auth.
261
+ - **TLS built-in** — for direct HTTPS without a proxy: `--tls-cert cert.pem --tls-key key.pem` (or `TLS_CERT_PATH` + `TLS_KEY_PATH` env vars)
262
+ - **Defaults to localhost** — binding to all interfaces (`--host 0.0.0.0`) requires an explicit flag and logs a warning.
263
+ - **Origin validation** — rejects cross-origin requests to prevent DNS rebinding attacks.
264
+ - **Stdio remains default** — `--http` is opt-in. Existing stdio configs are unaffected.
163
265
 
164
- ```sh
165
- bun run src/mcp.ts --setup
166
- ```
266
+ The `PORT`, `HOST`, `TLS_CERT_PATH`, and `TLS_KEY_PATH` environment variables are supported (lower precedence than CLI flags).
167
267
 
168
- This downloads the documentation database and prints MCP client configuration. The config uses `bun` as the command with `src/mcp.ts` as the entrypoint:
268
+ ## Container Images
169
269
 
170
- #### Claude Desktop
270
+ Release CI publishes multi-arch OCI images to:
171
271
 
172
- ```json
173
- {
174
- "mcpServers": {
175
- "rosetta": {
176
- "command": "bun",
177
- "args": ["run", "src/mcp.ts"],
178
- "cwd": "/path/to/rosetta"
179
- }
180
- }
181
- }
182
- ```
272
+ - Docker Hub: `ammo74/rosetta`
273
+ - GHCR: `ghcr.io/tikoci/rosetta`
183
274
 
184
- #### Claude Code
275
+ Tags per release:
185
276
 
186
- ```sh
187
- claude mcp add rosetta -- bun run src/mcp.ts --cwd /path/to/rosetta
188
- ```
277
+ - `${version}` (example: `v0.2.1`)
278
+ - `latest`
279
+ - `sha-<12-char-commit>`
189
280
 
190
- #### VS Code Copilot
281
+ Container defaults:
191
282
 
192
- Open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`), choose **"MCP: Add Server…"**, select **"Command (stdio)"**, and enter `bun run src/mcp.ts` with the working directory set to the rosetta folder.
283
+ - Starts in HTTP mode (`--http`) on `0.0.0.0`
284
+ - Uses `PORT` if set, otherwise 8080
285
+ - Uses HTTPS only when both `TLS_CERT_PATH` and `TLS_KEY_PATH` are set
193
286
 
194
- Or add to your User Settings JSON:
287
+ Examples:
195
288
 
196
- ```json
197
- "mcp": {
198
- "servers": {
199
- "rosetta": {
200
- "command": "bun",
201
- "args": ["run", "src/mcp.ts"],
202
- "cwd": "/path/to/rosetta"
203
- }
204
- }
205
- }
289
+ ```sh
290
+ docker run --rm -p 8080:8080 ghcr.io/tikoci/rosetta:latest
206
291
  ```
207
292
 
208
- Replace `/path/to/rosetta` with the actual path (printed by `--setup`).
293
+ ```sh
294
+ docker run --rm -p 8443:8443 \
295
+ -e PORT=8443 \
296
+ -e TLS_CERT_PATH=/certs/cert.pem \
297
+ -e TLS_KEY_PATH=/certs/key.pem \
298
+ -v "$PWD/certs:/certs:ro" \
299
+ ghcr.io/tikoci/rosetta:latest
300
+ ```
209
301
 
210
302
  ## Building from Source
211
303
 
@@ -267,11 +359,13 @@ make release VERSION=v0.1.0
267
359
 
268
360
  This cross-compiles to macOS (arm64 + x64), Windows (x64), and Linux (x64), creates ZIP archives, compresses the database, tags the commit, and creates a GitHub Release with all artifacts.
269
361
 
362
+ The release workflow also publishes OCI images to Docker Hub (`ammo74/rosetta`) and GHCR (`ghcr.io/tikoci/rosetta`) using crane (no Docker daemon required in CI).
363
+
270
364
  ## Project Structure
271
365
 
272
366
  ```text
273
367
  src/
274
- ├── mcp.ts # MCP server (10 tools, stdio) + CLI dispatch
368
+ ├── mcp.ts # MCP server (11 tools, stdio + HTTP) + CLI dispatch
275
369
  ├── setup.ts # --setup: DB download + MCP client config
276
370
  ├── query.ts # NL → FTS5 query planner, BM25 ranking
277
371
  ├── db.ts # SQLite schema, WAL mode, FTS5 triggers
@@ -285,6 +379,7 @@ src/
285
379
 
286
380
  scripts/
287
381
  └── build-release.ts # Cross-compile + package releases
382
+ └── container-entrypoint.sh # OCI image runtime entrypoint (HTTP default)
288
383
  ```
289
384
 
290
385
  ## Data Sources
package/bin/rosetta.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bin/rosetta.js — Entry point for `npx @tikoci/rosetta` and `bunx @tikoci/rosetta`.
3
+ * bin/rosetta.js — Entry point for `bunx @tikoci/rosetta` (and npx fallback).
4
4
  *
5
5
  * The server uses bun:sqlite and other Bun APIs, so it requires the Bun runtime.
6
6
  * When run under Bun (via bunx), this imports src/mcp.ts directly.
7
- * When run under Node (via npx), this spawns `bun run src/mcp.ts` as a subprocess.
7
+ * When run under Node (via npx), this spawns `bun` as a subprocess if available.
8
8
  */
9
9
 
10
10
  import { dirname, join } from "node:path";
@@ -17,17 +17,23 @@ if (typeof Bun !== "undefined") {
17
17
  // Running under Bun — import TypeScript entry directly
18
18
  await import(entry);
19
19
  } else {
20
- // Running under Node — delegate to Bun subprocess
20
+ // Running under Node — try to delegate to Bun, but warn the user
21
+ console.error("Note: rosetta requires the Bun runtime. Attempting to run via bun...");
22
+ console.error();
21
23
  const { spawn } = await import("node:child_process");
22
24
  const proc = spawn("bun", ["run", entry, ...process.argv.slice(2)], {
23
25
  stdio: "inherit",
24
26
  });
25
27
  proc.on("error", (err) => {
26
28
  if (err.code === "ENOENT") {
27
- console.error("rosetta requires the Bun runtime (bun:sqlite is not available in Node.js).");
29
+ console.error("rosetta requires Bun (bun:sqlite is not available in Node.js).\n");
30
+ console.error("Recommended: install Bun, then use bunx instead of npx:\n");
31
+ console.error(" curl -fsSL https://bun.sh/install | bash");
32
+ console.error(" bunx @tikoci/rosetta --setup\n");
28
33
  console.error("Install Bun: https://bun.sh");
29
34
  process.exit(1);
30
35
  }
36
+ console.error(`Failed to start bun: ${err.message}`);
31
37
  process.exit(1);
32
38
  });
33
39
  proc.on("exit", (code) => process.exit(code ?? 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,9 @@
25
25
  "@modelcontextprotocol/sdk": "^1.27.1",
26
26
  "zod": "^4.3.6"
27
27
  },
28
+ "engines": {
29
+ "bun": ">=1.1"
30
+ },
28
31
  "devDependencies": {
29
32
  "@biomejs/biome": "^2.4.7",
30
33
  "@types/bun": "^1.3.10",
package/src/db.ts CHANGED
@@ -20,22 +20,9 @@
20
20
  */
21
21
 
22
22
  import sqlite from "bun:sqlite";
23
- import path from "node:path";
23
+ import { resolveDbPath } from "./paths.ts";
24
24
 
25
- declare const IS_COMPILED: boolean;
26
-
27
- /**
28
- * Resolve the base directory for finding ros-help.db:
29
- * - Compiled binary: directory containing the executable
30
- * - Dev mode: project root (one level up from src/)
31
- */
32
- const baseDir =
33
- typeof IS_COMPILED !== "undefined" && IS_COMPILED
34
- ? path.dirname(process.execPath)
35
- : path.resolve(import.meta.dirname, "..");
36
-
37
- export const DB_PATH =
38
- process.env.DB_PATH?.trim() || path.join(baseDir, "ros-help.db");
25
+ export const DB_PATH = resolveDbPath(import.meta.dirname);
39
26
 
40
27
  export const db = new sqlite(DB_PATH);
41
28
 
package/src/mcp.ts CHANGED
@@ -9,38 +9,63 @@
9
9
  * --setup [--force] Download database + print MCP client config
10
10
  * --version Print version
11
11
  * --help Print usage
12
+ * --http Start with Streamable HTTP transport (instead of stdio)
13
+ * --port <N> HTTP listen port (default: 8080, env: PORT)
14
+ * --host <ADDR> HTTP bind address (default: localhost, env: HOST)
15
+ * --tls-cert <PATH> TLS certificate PEM file (enables HTTPS, env: TLS_CERT_PATH)
16
+ * --tls-key <PATH> TLS private key PEM file (requires --tls-cert, env: TLS_KEY_PATH)
12
17
  * (default) Start MCP server (stdio transport)
13
18
  *
14
19
  * Environment variables:
15
20
  * DB_PATH — absolute path to ros-help.db (default: next to binary or project root)
21
+ * PORT — HTTP listen port (lower precedence than --port)
22
+ * HOST — HTTP bind address (lower precedence than --host)
23
+ * TLS_CERT_PATH — TLS certificate path (lower precedence than --tls-cert)
24
+ * TLS_KEY_PATH — TLS private key path (lower precedence than --tls-key)
16
25
  */
17
26
 
18
- declare const VERSION: string;
19
- declare const IS_COMPILED: boolean;
27
+ import { resolveVersion } from "./paths.ts";
28
+
29
+ const RESOLVED_VERSION = resolveVersion(import.meta.dirname);
20
30
 
21
31
  // ── CLI dispatch (before MCP server init) ──
22
32
 
23
33
  const args = process.argv.slice(2);
24
34
 
35
+ /** Extract the value following a named flag (e.g. --port 8080 → "8080") */
36
+ function getArg(name: string): string | undefined {
37
+ const idx = args.indexOf(name);
38
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
39
+ }
40
+
25
41
  if (args.includes("--version") || args.includes("-v")) {
26
- const ver = typeof VERSION !== "undefined" ? VERSION : "dev";
27
- console.log(`rosetta ${ver}`);
42
+ console.log(`rosetta ${RESOLVED_VERSION}`);
28
43
  process.exit(0);
29
44
  }
30
45
 
31
46
  if (args.includes("--help") || args.includes("-h")) {
32
- const ver = typeof VERSION !== "undefined" ? VERSION : "dev";
33
- console.log(`rosetta ${ver} — MCP server for RouterOS documentation`);
47
+ console.log(`rosetta ${RESOLVED_VERSION} MCP server for RouterOS documentation`);
34
48
  console.log();
35
49
  console.log("Usage:");
36
50
  console.log(" rosetta Start MCP server (stdio transport)");
51
+ console.log(" rosetta --http Start with Streamable HTTP transport");
37
52
  console.log(" rosetta --setup Download database + print MCP client config");
38
53
  console.log(" rosetta --setup --force Re-download database");
39
54
  console.log(" rosetta --version Print version");
40
55
  console.log(" rosetta --help Print this help");
41
56
  console.log();
57
+ console.log("HTTP options (require --http):");
58
+ console.log(" --port <N> Listen port (default: 8080, env: PORT)");
59
+ console.log(" --host <ADDR> Bind address (default: localhost, env: HOST)");
60
+ console.log(" --tls-cert <PATH> TLS certificate PEM file (env: TLS_CERT_PATH)");
61
+ console.log(" --tls-key <PATH> TLS private key PEM file (env: TLS_KEY_PATH)");
62
+ console.log();
42
63
  console.log("Environment:");
43
64
  console.log(" DB_PATH Absolute path to ros-help.db (optional)");
65
+ console.log(" PORT HTTP listen port (lower precedence than --port)");
66
+ 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)");
44
69
  process.exit(0);
45
70
  }
46
71
 
@@ -55,8 +80,9 @@ if (args.includes("--setup")) {
55
80
 
56
81
  // ── MCP Server ──
57
82
 
83
+ const useHttp = args.includes("--http");
84
+
58
85
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
59
- const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
60
86
  const { z } = await import("zod/v3");
61
87
 
62
88
  // Dynamic imports — db.ts eagerly opens the DB file on import,
@@ -65,12 +91,8 @@ const { z } = await import("zod/v3");
65
91
  //
66
92
  // Check if DB has data BEFORE importing db.ts. If empty/missing,
67
93
  // auto-download so db.ts opens the real database.
68
- const _baseDir =
69
- typeof IS_COMPILED !== "undefined" && IS_COMPILED
70
- ? (await import("node:path")).dirname(process.execPath)
71
- : (await import("node:path")).resolve(import.meta.dirname, "..");
72
- const _dbPath =
73
- process.env.DB_PATH?.trim() || (await import("node:path")).join(_baseDir, "ros-help.db");
94
+ const { resolveDbPath } = await import("./paths.ts");
95
+ const _dbPath = resolveDbPath(import.meta.dirname);
74
96
 
75
97
  const _pageCount = (() => {
76
98
  try {
@@ -116,7 +138,7 @@ initDb();
116
138
 
117
139
  const server = new McpServer({
118
140
  name: "rosetta",
119
- version: typeof VERSION !== "undefined" ? VERSION : "0.2.0",
141
+ version: RESOLVED_VERSION,
120
142
  });
121
143
 
122
144
  // ---- routeros_search ----
@@ -180,19 +202,17 @@ Use after routeros_search identifies a relevant page. Pass the numeric page ID
180
202
  Returns: plain text, code blocks, and callout blocks (notes, warnings, info, tips).
181
203
  Callouts contain crucial caveats and edge-case details — always review them.
182
204
 
183
- **Large page handling:** Always set max_length (e.g., 30000) on the first call.
184
- Some pages are 100K+ chars. When max_length is set and the page exceeds it,
205
+ **Large page handling:** max_length defaults to 16000. When page content exceeds it,
185
206
  pages with sections return a **table of contents** instead of truncated text.
186
207
  The TOC lists each section's heading, hierarchy level, character count, and
187
208
  deep-link URL. Re-call with the section parameter to retrieve specific sections.
188
209
 
189
210
  **Section parameter:** Pass a section heading or anchor_id (from the TOC)
190
- to get that section's content. Parent sections automatically include all
191
- sub-section content, so requesting a top-level heading gives you everything
192
- under it.
211
+ to get that section's content. If a section is still too large, its sub-section
212
+ TOC is returned instead request a more specific sub-section.
193
213
 
194
214
  Recommended workflow for large pages:
195
- 1. Call with max_length=30000 → get TOC if page is large
215
+ 1. First call → get TOC if page is large (automatic with default max_length)
196
216
  2. Review section headings to find the relevant section
197
217
  3. Call again with section="Section Name" to get that section's content
198
218
 
@@ -209,12 +229,12 @@ Workflow — what to do with this content:
209
229
  .number()
210
230
  .int()
211
231
  .min(1000)
212
- .optional()
213
- .describe("Recommended: set to 30000. Max combined text+code length. If exceeded and page has sections, returns a TOC instead of truncated text. Omit only if you need the entire page."),
232
+ .default(16000)
233
+ .describe("Max combined text+code length (default: 16000). If exceeded and page has sections, returns a TOC instead of truncated text. Set higher (e.g. 50000) to get more content in one call."),
214
234
  section: z
215
235
  .string()
216
236
  .optional()
217
- .describe("Section heading or anchor_id from TOC. Returns only that section's content."),
237
+ .describe("Section heading or anchor_id from TOC. Returns only that section's content (also subject to max_length)."),
218
238
  },
219
239
  },
220
240
  async ({ page, max_length, section }) => {
@@ -451,6 +471,29 @@ Examples:
451
471
 
452
472
  // ---- routeros_search_changelogs ----
453
473
 
474
+ /** Group flat changelog results by version for compact output. */
475
+ function groupChangelogsByVersion(results: Array<{ version: string; released: string | null; category: string; is_breaking: number; description: string }>) {
476
+ const byVersion = new Map<string, { released: string | null; entries: Array<{ category: string; is_breaking: number; description: string }> }>();
477
+ for (const r of results) {
478
+ let group = byVersion.get(r.version);
479
+ if (!group) {
480
+ group = { released: r.released, entries: [] };
481
+ byVersion.set(r.version, group);
482
+ }
483
+ group.entries.push({ category: r.category, is_breaking: r.is_breaking, description: r.description });
484
+ }
485
+ return {
486
+ total_entries: results.length,
487
+ versions: Array.from(byVersion.entries()).map(([version, { released, entries }]) => ({
488
+ version,
489
+ released,
490
+ entry_count: entries.length,
491
+ breaking_count: entries.filter(e => e.is_breaking).length,
492
+ entries,
493
+ })),
494
+ };
495
+ }
496
+
454
497
  server.registerTool(
455
498
  "routeros_search_changelogs",
456
499
  {
@@ -484,7 +527,7 @@ Coverage depends on which versions were extracted — typically matches ros_vers
484
527
  from_version: z
485
528
  .string()
486
529
  .optional()
487
- .describe("Start of version range, inclusive (e.g., '7.21')"),
530
+ .describe("Start of version range, EXCLUSIVE — returns changes AFTER this version (e.g., from_version='7.21.3' excludes 7.21.3 entries, includes 7.22+)"),
488
531
  to_version: z
489
532
  .string()
490
533
  .optional()
@@ -501,10 +544,10 @@ Coverage depends on which versions were extracted — typically matches ros_vers
501
544
  .number()
502
545
  .int()
503
546
  .min(1)
504
- .max(100)
547
+ .max(500)
505
548
  .optional()
506
- .default(20)
507
- .describe("Max results (default 20)"),
549
+ .default(50)
550
+ .describe("Max results (default 50, max 500). Version-range queries often need higher limits."),
508
551
  },
509
552
  },
510
553
  async ({ query, version, from_version, to_version, category, breaking_only, limit }) => {
@@ -535,8 +578,10 @@ Coverage depends on which versions were extracted — typically matches ros_vers
535
578
  ],
536
579
  };
537
580
  }
581
+ // Group by version for compact output — avoids repeating version/released on every entry
582
+ const grouped = groupChangelogsByVersion(results);
538
583
  return {
539
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
584
+ content: [{ type: "text", text: JSON.stringify(grouped, null, 2) }],
540
585
  };
541
586
  },
542
587
  );
@@ -719,7 +764,94 @@ Requires network access to upgrade.mikrotik.com.`,
719
764
 
720
765
  // ---- Start ----
721
766
 
722
- const transport = new StdioServerTransport();
723
- await server.connect(transport);
767
+ if (useHttp) {
768
+ const { existsSync } = await import("node:fs");
769
+ const { WebStandardStreamableHTTPServerTransport } = await import(
770
+ "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
771
+ );
772
+
773
+ const port = Number(getArg("--port") ?? process.env.PORT ?? 8080);
774
+ const hostname = getArg("--host") ?? process.env.HOST ?? "localhost";
775
+ const tlsCert = getArg("--tls-cert") ?? process.env.TLS_CERT_PATH;
776
+ const tlsKey = getArg("--tls-key") ?? process.env.TLS_KEY_PATH;
777
+
778
+ if ((tlsCert && !tlsKey) || (!tlsCert && tlsKey)) {
779
+ process.stderr.write(
780
+ "Error: TLS cert and key must both be provided (via flags or TLS_CERT_PATH/TLS_KEY_PATH)\n"
781
+ );
782
+ process.exit(1);
783
+ }
784
+ if (tlsCert && !existsSync(tlsCert)) {
785
+ process.stderr.write(`Error: TLS certificate not found: ${tlsCert}\n`);
786
+ process.exit(1);
787
+ }
788
+ if (tlsKey && !existsSync(tlsKey)) {
789
+ process.stderr.write(`Error: TLS private key not found: ${tlsKey}\n`);
790
+ process.exit(1);
791
+ }
792
+
793
+ const useTls = !!(tlsCert && tlsKey);
794
+ const scheme = useTls ? "https" : "http";
795
+
796
+ const httpTransport = new WebStandardStreamableHTTPServerTransport({
797
+ sessionIdGenerator: () => crypto.randomUUID(),
798
+ enableJsonResponse: false,
799
+ });
800
+
801
+ await server.connect(httpTransport);
802
+
803
+ const isLAN = hostname === "0.0.0.0" || hostname === "::";
804
+ if (isLAN) {
805
+ process.stderr.write(
806
+ "Warning: Binding to all interfaces — the MCP server will be accessible from the network.\n"
807
+ );
808
+ if (!useTls) {
809
+ process.stderr.write(
810
+ " Consider using --tls-cert/--tls-key or a reverse proxy for production use.\n"
811
+ );
812
+ }
813
+ }
814
+
815
+ Bun.serve({
816
+ port,
817
+ hostname,
818
+ ...(useTls && tlsCert && tlsKey
819
+ ? { tls: { cert: Bun.file(tlsCert), key: Bun.file(tlsKey) } }
820
+ : {}),
821
+ async fetch(req: Request): Promise<Response> {
822
+ const url = new URL(req.url);
823
+
824
+ // DNS rebinding protection: reject browser-origin requests
825
+ const origin = req.headers.get("origin");
826
+ if (origin) {
827
+ try {
828
+ const originHost = new URL(origin).host;
829
+ const serverHost = `${hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname}:${port}`;
830
+ if (originHost !== serverHost && originHost !== `localhost:${port}` && originHost !== `127.0.0.1:${port}`) {
831
+ return new Response("Forbidden: Origin not allowed", { status: 403 });
832
+ }
833
+ } catch {
834
+ return new Response("Forbidden: Invalid Origin", { status: 403 });
835
+ }
836
+ }
837
+
838
+ if (url.pathname === "/mcp") {
839
+ return httpTransport.handleRequest(req);
840
+ }
841
+
842
+ return new Response("Not Found", { status: 404 });
843
+ },
844
+ });
845
+
846
+ const displayHost = isLAN ? "localhost" : hostname;
847
+ process.stderr.write(`rosetta ${RESOLVED_VERSION} — Streamable HTTP\n`);
848
+ process.stderr.write(` ${scheme}://${displayHost}:${port}/mcp\n`);
849
+ } else {
850
+ const { StdioServerTransport } = await import(
851
+ "@modelcontextprotocol/sdk/server/stdio.js"
852
+ );
853
+ const transport = new StdioServerTransport();
854
+ await server.connect(transport);
855
+ }
724
856
 
725
857
  })();
package/src/paths.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * paths.ts — Shared DB path resolution for all entry points.
3
+ *
4
+ * Three modes:
5
+ * 1. Compiled binary (IS_COMPILED) → next to executable
6
+ * 2. Dev mode (.git exists in project root) → project root
7
+ * 3. Package mode (bunx / bun add -g) → ~/.rosetta/
8
+ *
9
+ * DB_PATH env var overrides all modes.
10
+ * This module must NOT import db.ts or bun:sqlite — it's used before the DB is opened.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+
17
+ declare const IS_COMPILED: boolean;
18
+ declare const VERSION: string;
19
+
20
+ /** True when running as a compiled binary (bun build --compile) */
21
+ export function isCompiled(): boolean {
22
+ try {
23
+ return typeof IS_COMPILED !== "undefined" && IS_COMPILED;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** True when running from a git checkout (dev mode) */
30
+ function isDevMode(projectRoot: string): boolean {
31
+ return existsSync(path.join(projectRoot, ".git"));
32
+ }
33
+
34
+ /**
35
+ * Resolve the directory where ros-help.db should live.
36
+ * - Compiled: directory containing the executable
37
+ * - Dev: project root (one level up from src/)
38
+ * - Package (bunx / global install): ~/.rosetta/
39
+ */
40
+ export function resolveBaseDir(srcDir: string): string {
41
+ if (isCompiled()) {
42
+ return path.dirname(process.execPath);
43
+ }
44
+
45
+ const projectRoot = path.resolve(srcDir, "..");
46
+ if (isDevMode(projectRoot)) {
47
+ return projectRoot;
48
+ }
49
+
50
+ // Package mode — stable user-local directory
51
+ const dataDir = path.join(homedir(), ".rosetta");
52
+ mkdirSync(dataDir, { recursive: true });
53
+ return dataDir;
54
+ }
55
+
56
+ /**
57
+ * Resolve the full path to ros-help.db.
58
+ * DB_PATH env var overrides all detection logic.
59
+ */
60
+ export function resolveDbPath(srcDir: string): string {
61
+ const envPath = process.env.DB_PATH?.trim();
62
+ if (envPath) return envPath;
63
+ return path.join(resolveBaseDir(srcDir), "ros-help.db");
64
+ }
65
+
66
+ /** Detect invocation mode: "compiled" | "dev" | "package" */
67
+ export type InvocationMode = "compiled" | "dev" | "package";
68
+
69
+ export function detectMode(srcDir: string): InvocationMode {
70
+ if (isCompiled()) return "compiled";
71
+ const projectRoot = path.resolve(srcDir, "..");
72
+ if (isDevMode(projectRoot)) return "dev";
73
+ return "package";
74
+ }
75
+
76
+ /**
77
+ * Resolve the version string.
78
+ * Compiled mode: injected at build time via --define.
79
+ * Dev/package mode: read from package.json.
80
+ */
81
+ export function resolveVersion(srcDir: string): string {
82
+ try {
83
+ if (typeof VERSION !== "undefined") return VERSION;
84
+ } catch {}
85
+ try {
86
+ const pkgPath = path.join(srcDir, "..", "package.json");
87
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
88
+ return pkg.version ?? "unknown";
89
+ } catch {
90
+ return "unknown";
91
+ }
92
+ }
package/src/query.test.ts CHANGED
@@ -877,13 +877,13 @@ describe("searchChangelogs", () => {
877
877
  }
878
878
  });
879
879
 
880
- test("version range filter works", () => {
881
- const results = searchChangelogs("", { fromVersion: "7.22", toVersion: "7.22.1" });
880
+ test("version range filter works (from_version exclusive, to_version inclusive)", () => {
881
+ const results = searchChangelogs("", { fromVersion: "7.21", toVersion: "7.22.1" });
882
882
  expect(results.length).toBeGreaterThan(0);
883
883
  for (const r of results) {
884
884
  expect(["7.22", "7.22.1"]).toContain(r.version);
885
885
  }
886
- // Should not include 7.21
886
+ // Should not include 7.21 (from_version is exclusive)
887
887
  expect(results.some((r) => r.version === "7.21")).toBe(false);
888
888
  });
889
889
 
@@ -902,7 +902,8 @@ describe("searchChangelogs", () => {
902
902
  });
903
903
 
904
904
  test("FTS combined with version range", () => {
905
- const results = searchChangelogs("MLAG", { fromVersion: "7.22", toVersion: "7.22" });
905
+ // from_version is exclusive, so use 7.21 to include 7.22
906
+ const results = searchChangelogs("MLAG", { fromVersion: "7.21", toVersion: "7.22" });
906
907
  expect(results.length).toBe(1);
907
908
  expect(results[0].category).toBe("bridge");
908
909
  });
package/src/query.ts CHANGED
@@ -184,6 +184,7 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
184
184
  word_count: number;
185
185
  code_lines: number;
186
186
  callouts: Array<{ type: string; content: string }>;
187
+ callout_summary?: { count: number; types: Record<string, number> };
187
188
  truncated?: { text_total: number; code_total: number };
188
189
  sections?: SectionTocEntry[];
189
190
  section?: { heading: string; level: number; anchor_id: string };
@@ -221,11 +222,11 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
221
222
 
222
223
  const descendants = db
223
224
  .prepare(
224
- `SELECT heading, level, text, code, word_count
225
+ `SELECT heading, level, anchor_id, text, code, word_count
225
226
  FROM sections WHERE page_id = ? AND sort_order > ? AND level > ? AND sort_order < ?
226
227
  ORDER BY sort_order`,
227
228
  )
228
- .all(page.id, sec.sort_order, sec.level, upperBound) as Array<{ heading: string; level: number; text: string; code: string; word_count: number }>;
229
+ .all(page.id, sec.sort_order, sec.level, upperBound) as Array<{ heading: string; level: number; anchor_id: string; text: string; code: string; word_count: number }>;
229
230
 
230
231
  let fullText = sec.text;
231
232
  let fullCode = sec.code;
@@ -237,6 +238,34 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
237
238
  totalWords += child.word_count;
238
239
  }
239
240
 
241
+ // If section+descendants exceed maxLength and there are subsections, return sub-TOC
242
+ if (maxLength && (fullText.length + fullCode.length) > maxLength && descendants.length > 0) {
243
+ const subToc: SectionTocEntry[] = [
244
+ { heading: sec.heading, level: sec.level, anchor_id: sec.anchor_id, char_count: sec.text.length + sec.code.length, url: `${page.url}#${sec.anchor_id}` },
245
+ ...descendants.map((d) => ({ heading: d.heading, level: d.level, anchor_id: d.anchor_id, char_count: d.text.length + d.code.length, url: `${page.url}#${d.anchor_id}` })),
246
+ ];
247
+ const totalChars = fullText.length + fullCode.length;
248
+ return {
249
+ id: page.id, title: page.title, path: page.path, url: `${page.url}#${sec.anchor_id}`,
250
+ text: "", code: "",
251
+ word_count: totalWords, code_lines: 0,
252
+ callouts: [], callout_summary: calloutSummary(callouts),
253
+ sections: subToc,
254
+ section: { heading: sec.heading, level: sec.level, anchor_id: sec.anchor_id },
255
+ note: `Section "${sec.heading}" content (${totalChars} chars) exceeds max_length (${maxLength}). Showing ${subToc.length} sub-sections. Re-call with a more specific section heading or anchor_id.`,
256
+ };
257
+ }
258
+
259
+ // If section exceeds maxLength but has no sub-sections, truncate
260
+ if (maxLength && (fullText.length + fullCode.length) > maxLength) {
261
+ const textTotal = fullText.length;
262
+ const codeTotal = fullCode.length;
263
+ const codeBudget = Math.min(codeTotal, Math.floor(maxLength * 0.2));
264
+ const textBudget = maxLength - codeBudget;
265
+ fullText = `${fullText.slice(0, textBudget)}\n\n[... truncated — ${textTotal} chars total, showing first ${textBudget}]`;
266
+ fullCode = codeTotal > codeBudget ? `${fullCode.slice(0, codeBudget)}\n# [... truncated — ${codeTotal} chars total]` : fullCode;
267
+ }
268
+
240
269
  return {
241
270
  id: page.id,
242
271
  title: page.title,
@@ -258,7 +287,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
258
287
  id: page.id, title: page.title, path: page.path, url: page.url,
259
288
  text: "", code: "",
260
289
  word_count: page.word_count, code_lines: page.code_lines,
261
- callouts, sections: toc,
290
+ callouts: [], callout_summary: calloutSummary(callouts),
291
+ sections: toc,
262
292
  note: `Section "${section}" not found. ${toc.length} sections available — use a heading or anchor_id from the list.`,
263
293
  };
264
294
  }
@@ -284,7 +314,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
284
314
  id: page.id, title: page.title, path: page.path, url: page.url,
285
315
  text: "", code: "",
286
316
  word_count: page.word_count, code_lines: page.code_lines,
287
- callouts, sections: toc,
317
+ callouts: [], callout_summary: calloutSummary(callouts),
318
+ sections: toc,
288
319
  truncated: { text_total: text.length, code_total: code.length },
289
320
  note: `Page content (${totalChars} chars) exceeds max_length (${maxLength}). Showing table of contents with ${toc.length} sections. Re-call with section parameter to retrieve specific sections.`,
290
321
  };
@@ -303,6 +334,13 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
303
334
  return { id: page.id, title: page.title, path: page.path, url: page.url, text, code, word_count: page.word_count, code_lines: page.code_lines, callouts, ...(truncated ? { truncated } : {}) };
304
335
  }
305
336
 
337
+ /** Build compact callout summary (count + type breakdown) for TOC-mode responses. */
338
+ function calloutSummary(callouts: Array<{ type: string; content: string }>): { count: number; types: Record<string, number> } {
339
+ const types: Record<string, number> = {};
340
+ for (const c of callouts) types[c.type] = (types[c.type] || 0) + 1;
341
+ return { count: callouts.length, types };
342
+ }
343
+
306
344
  /** Build section TOC for a page. */
307
345
  function getPageToc(pageId: number, pageUrl: string): SectionTocEntry[] {
308
346
  const rows = db
@@ -825,14 +863,14 @@ function getChangelogVersions(): string[] {
825
863
  return rows.map((r) => r.version).sort(compareVersions);
826
864
  }
827
865
 
828
- /** Filter versions to those within [fromVersion, toVersion] range (inclusive). */
866
+ /** Filter versions to those within (fromVersion, toVersion] range (fromVersion exclusive, toVersion inclusive). */
829
867
  function filterVersionRange(
830
868
  versions: string[],
831
869
  fromVersion?: string,
832
870
  toVersion?: string,
833
871
  ): string[] {
834
872
  return versions.filter((v) => {
835
- if (fromVersion && compareVersions(v, fromVersion) < 0) return false;
873
+ if (fromVersion && compareVersions(v, fromVersion) <= 0) return false;
836
874
  if (toVersion && compareVersions(v, toVersion) > 0) return false;
837
875
  return true;
838
876
  });
@@ -88,25 +88,27 @@ describe("bin/rosetta.js", () => {
88
88
  // ---------------------------------------------------------------------------
89
89
 
90
90
  describe("build-time constants", () => {
91
- test("mcp.ts declares VERSION", () => {
91
+ test("mcp.ts imports resolveVersion from paths.ts", () => {
92
92
  const src = readText("src/mcp.ts");
93
- expect(src).toContain("declare const VERSION");
93
+ expect(src).toContain("resolveVersion");
94
94
  });
95
95
 
96
- test("mcp.ts declares IS_COMPILED", () => {
96
+ test("mcp.ts does not have hardcoded version fallback", () => {
97
97
  const src = readText("src/mcp.ts");
98
- expect(src).toContain("declare const IS_COMPILED");
98
+ expect(src).not.toContain('"0.2.0"');
99
+ expect(src).not.toContain("\"dev\"");
99
100
  });
100
101
 
101
- test("db.ts declares IS_COMPILED", () => {
102
- const src = readText("src/db.ts");
102
+ test("paths.ts declares IS_COMPILED and VERSION", () => {
103
+ const src = readText("src/paths.ts");
103
104
  expect(src).toContain("declare const IS_COMPILED");
105
+ expect(src).toContain("declare const VERSION");
104
106
  });
105
107
 
106
- test("setup.ts declares REPO_URL and VERSION", () => {
108
+ test("setup.ts declares REPO_URL and imports resolveVersion", () => {
107
109
  const src = readText("src/setup.ts");
108
110
  expect(src).toContain("declare const REPO_URL");
109
- expect(src).toContain("declare const VERSION");
111
+ expect(src).toContain("resolveVersion");
110
112
  });
111
113
 
112
114
  test("build script injects all three constants", () => {
package/src/setup.ts CHANGED
@@ -1,51 +1,22 @@
1
1
  /**
2
2
  * setup.ts — Download the RouterOS documentation database and print MCP client config.
3
3
  *
4
- * Called by `rosetta --setup` (compiled binary) or `bun run src/setup.ts` (dev).
4
+ * Called by `rosetta --setup` (compiled binary), `bunx @tikoci/rosetta --setup`,
5
+ * or `bun run src/setup.ts` (dev).
5
6
  * Downloads ros-help.db.gz from the latest GitHub Release, decompresses it,
6
7
  * validates the DB, and prints config snippets for each MCP client.
7
8
  */
8
9
 
10
+ import { execSync } from "node:child_process";
9
11
  import { existsSync, writeFileSync } from "node:fs";
10
- import path from "node:path";
11
12
  import { gunzipSync } from "bun";
13
+ import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion } from "./paths.ts";
12
14
 
13
15
  declare const REPO_URL: string;
14
- declare const VERSION: string;
15
16
 
16
17
  const GITHUB_REPO =
17
18
  typeof REPO_URL !== "undefined" ? REPO_URL : "tikoci/rosetta";
18
- const RELEASE_VERSION =
19
- typeof VERSION !== "undefined" ? VERSION : "dev";
20
-
21
- /** Where the binary (or dev project root) lives */
22
- function getBaseDir(): string {
23
- // If IS_COMPILED is defined, use the executable's directory
24
- // Otherwise use project root (one level up from src/)
25
- try {
26
- // @ts-expect-error IS_COMPILED defined at build time
27
- if (typeof IS_COMPILED !== "undefined" && IS_COMPILED) {
28
- return path.dirname(process.execPath);
29
- }
30
- } catch {
31
- // not compiled
32
- }
33
- return path.resolve(import.meta.dirname, "..");
34
- }
35
-
36
- /** The binary/script path for MCP config */
37
- function getServerCommand(): string {
38
- try {
39
- // @ts-expect-error IS_COMPILED defined at build time
40
- if (typeof IS_COMPILED !== "undefined" && IS_COMPILED) {
41
- return process.execPath;
42
- }
43
- } catch {
44
- // not compiled
45
- }
46
- // Dev mode — bun run src/mcp.ts
47
- return path.resolve(import.meta.dirname, "mcp.ts");
48
- }
19
+ const RELEASE_VERSION = resolveVersion(import.meta.dirname);
49
20
 
50
21
  /** Check if a DB file exists and has actual page data */
51
22
  function dbHasData(dbPath: string): boolean {
@@ -90,10 +61,8 @@ export async function downloadDb(
90
61
  }
91
62
 
92
63
  export async function runSetup(force = false) {
93
- const baseDir = getBaseDir();
94
- const dbPath = path.join(baseDir, "ros-help.db");
95
- const serverCmd = getServerCommand();
96
- const isCompiled = serverCmd === process.execPath;
64
+ const mode = detectMode(import.meta.dirname);
65
+ const dbPath = resolveDbPath(import.meta.dirname);
97
66
 
98
67
  console.log(`rosetta ${RELEASE_VERSION}`);
99
68
  console.log();
@@ -118,7 +87,8 @@ export async function runSetup(force = false) {
118
87
  console.log(`✓ Database ready (${row.c} pages, ${cmdRow.c} commands)`);
119
88
  } catch (e) {
120
89
  console.error(`✗ Database validation failed: ${e}`);
121
- console.error(` Try re-downloading with: ${isCompiled ? path.basename(serverCmd) : "bun run src/setup.ts"} --setup --force`);
90
+ const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";
91
+ console.error(` Try re-downloading with: ${retryCmd} --setup --force`);
122
92
  process.exit(1);
123
93
  }
124
94
 
@@ -128,10 +98,21 @@ export async function runSetup(force = false) {
128
98
  console.log("Configure your MCP client:");
129
99
  console.log("─".repeat(60));
130
100
 
131
- if (isCompiled) {
132
- printCompiledConfig(serverCmd);
101
+ if (mode === "compiled") {
102
+ printCompiledConfig(process.execPath);
103
+ } else if (mode === "package") {
104
+ printPackageConfig();
133
105
  } else {
134
- printDevConfig(baseDir);
106
+ printDevConfig(resolveBaseDir(import.meta.dirname));
107
+ }
108
+ }
109
+
110
+ /** Try to resolve the absolute path to bunx (for clients that don't inherit PATH) */
111
+ function resolveBunxPath(): string | null {
112
+ try {
113
+ return execSync("which bunx", { encoding: "utf-8" }).trim() || null;
114
+ } catch {
115
+ return null;
135
116
  }
136
117
  }
137
118
 
@@ -175,6 +156,82 @@ function printCompiledConfig(serverCmd: string) {
175
156
  console.log(` }`);
176
157
  console.log(` }`);
177
158
  console.log();
159
+
160
+ // Copilot CLI
161
+ console.log("▸ GitHub Copilot CLI");
162
+ console.log(` Inside a copilot session, type /mcp add:`);
163
+ console.log(` Name: routeros-rosetta | Type: STDIO | Command: ${serverCmd}`);
164
+ console.log();
165
+
166
+ // OpenAI Codex
167
+ console.log("▸ OpenAI Codex");
168
+ console.log(` codex mcp add rosetta -- ${serverCmd}`);
169
+ console.log();
170
+
171
+ printHttpConfig(`${serverCmd} --http`);
172
+ }
173
+
174
+ function printPackageConfig() {
175
+ // Resolve full path to bunx — Claude Desktop doesn't inherit shell PATH
176
+ const bunxFullPath = resolveBunxPath();
177
+
178
+ // Claude Desktop
179
+ const isMac = process.platform === "darwin";
180
+ const configPath = isMac
181
+ ? "~/Library/Application\\ Support/Claude/claude_desktop_config.json"
182
+ : "%APPDATA%\\Claude\\claude_desktop_config.json";
183
+
184
+ const bunxCmd = bunxFullPath ? JSON.stringify(bunxFullPath) : "\"bunx\"";
185
+ console.log();
186
+ console.log("▸ Claude Desktop");
187
+ console.log(` Edit: ${configPath}`);
188
+ console.log();
189
+ console.log(` {`);
190
+ console.log(` "mcpServers": {`);
191
+ console.log(` "rosetta": {`);
192
+ console.log(` "command": ${bunxCmd},`);
193
+ console.log(` "args": ["@tikoci/rosetta"]`);
194
+ console.log(` }`);
195
+ console.log(` }`);
196
+ console.log(` }`);
197
+ console.log();
198
+ if (bunxFullPath) {
199
+ console.log(` Note: Full path used because Claude Desktop may not inherit shell PATH.`);
200
+ console.log();
201
+ }
202
+ console.log(` Then restart Claude Desktop.`);
203
+
204
+ // Claude Code (inherits PATH — short form is fine)
205
+ console.log();
206
+ console.log("▸ Claude Code");
207
+ console.log(` claude mcp add rosetta -- bunx @tikoci/rosetta`);
208
+
209
+ // VS Code Copilot (inherits PATH)
210
+ console.log();
211
+ console.log("▸ VS Code Copilot (User Settings JSON)");
212
+ console.log();
213
+ console.log(` "mcp": {`);
214
+ console.log(` "servers": {`);
215
+ console.log(` "rosetta": {`);
216
+ console.log(` "command": "bunx",`);
217
+ console.log(` "args": ["@tikoci/rosetta"]`);
218
+ console.log(` }`);
219
+ console.log(` }`);
220
+ console.log(` }`);
221
+ console.log();
222
+
223
+ // Copilot CLI (inherits PATH)
224
+ console.log("▸ GitHub Copilot CLI");
225
+ console.log(` Inside a copilot session, type /mcp add:`);
226
+ console.log(` Name: routeros-rosetta | Type: STDIO | Command: bunx @tikoci/rosetta`);
227
+ console.log();
228
+
229
+ // OpenAI Codex (inherits PATH)
230
+ console.log("▸ OpenAI Codex");
231
+ console.log(` codex mcp add rosetta -- bunx @tikoci/rosetta`);
232
+ console.log();
233
+
234
+ printHttpConfig("bunx @tikoci/rosetta --http");
178
235
  }
179
236
 
180
237
  function printDevConfig(baseDir: string) {
@@ -212,6 +269,38 @@ function printDevConfig(baseDir: string) {
212
269
  console.log("▸ VS Code Copilot");
213
270
  console.log(` The repo includes .vscode/mcp.json — just open the folder in VS Code.`);
214
271
  console.log();
272
+
273
+ // Copilot CLI
274
+ console.log("▸ GitHub Copilot CLI");
275
+ console.log(` Inside a copilot session, type /mcp add:`);
276
+ console.log(` Name: routeros-rosetta | Type: STDIO | Command: bun run src/mcp.ts`);
277
+ console.log();
278
+
279
+ // OpenAI Codex
280
+ console.log("▸ OpenAI Codex");
281
+ console.log(` codex mcp add rosetta -- bun run src/mcp.ts`);
282
+ console.log();
283
+
284
+ printHttpConfig(`bun run src/mcp.ts --http`);
285
+ }
286
+
287
+ function printHttpConfig(startCmd: string) {
288
+ console.log("─".repeat(60));
289
+ console.log("Streamable HTTP transport (for HTTP-only MCP clients):");
290
+ console.log("─".repeat(60));
291
+ console.log();
292
+ console.log("▸ Start in HTTP mode");
293
+ console.log(` ${startCmd}`);
294
+ console.log(` ${startCmd} --port 9090`);
295
+ console.log(` ${startCmd} --host 0.0.0.0 # LAN access`);
296
+ console.log(` ${startCmd} --tls-cert cert.pem --tls-key key.pem # HTTPS`);
297
+ console.log();
298
+ console.log("▸ URL-based MCP clients (OpenAI, etc.)");
299
+ console.log(` { "url": "http://localhost:8080/mcp" }`);
300
+ console.log();
301
+ console.log(" For LAN access, replace localhost with the server's IP address.");
302
+ console.log(" Use a reverse proxy (nginx, caddy) for production HTTPS.");
303
+ console.log();
215
304
  }
216
305
 
217
306
  // Run directly