autorouter-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Webb Ventures
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,393 @@
1
+ # autorouter
2
+
3
+ [![CI](https://github.com/Webb-Ventures/autorouter/actions/workflows/ci.yml/badge.svg)](https://github.com/Webb-Ventures/autorouter/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/autorouter-mcp.svg)](https://www.npmjs.com/package/autorouter-mcp)
5
+ [![licence: MIT](https://img.shields.io/badge/licence-MIT-blue.svg)](LICENSE)
6
+
7
+ One tool instead of every tool.
8
+
9
+ Every MCP server you register injects its full tool list — names, descriptions,
10
+ JSON schemas — into the system prompt on every single turn. Five servers and a
11
+ handful of skills is routinely 20–30k tokens of permanent overhead, paid whether
12
+ or not the task touches any of them. It also makes tool selection _worse_: more
13
+ candidates means more mis-picks.
14
+
15
+ autorouter is an MCP server that exposes ~4 stable tools and hides everything
16
+ else behind a search. The model asks for what it needs in natural language, gets
17
+ back a few ranked candidates, reads one schema, and calls it through the router.
18
+
19
+ ```
20
+ find_capabilities({ query: "why did my deploy fail" })
21
+ → mcp:railway/get_deployment_logs (tool)
22
+ → mcp:datadog/search_datadog_logs (tool)
23
+ → skill:incident-triage (skill)
24
+
25
+ describe_capability({ id: "mcp:railway/get_deployment_logs" })
26
+ call_capability({ id: "mcp:railway/get_deployment_logs", arguments: { lines: 50 } })
27
+ ```
28
+
29
+ ## Install
30
+
31
+ ```sh
32
+ npm install -g @webb-ventures/autorouter-mcp # or: npx autorouter-mcp <command>
33
+ autorouter init --target claude # claude | codex | cursor | vscode
34
+ autorouter adopt --target claude # ← the step that actually saves context
35
+ ```
36
+
37
+ **`adopt` is not optional.** Registering the router alongside your existing
38
+ servers is a net _increase_: their schemas are still loaded and the router adds
39
+ four more tools. `adopt` moves the downstream entries out of the harness config
40
+ and into the router's own, so the harness loads one server and the router still
41
+ reaches all of them.
42
+
43
+ MCP servers are not the whole bill. Skills and plugins load through entirely
44
+ separate mechanisms — every `SKILL.md` contributes its name and description to
45
+ the system prompt, and an enabled plugin contributes its own skills, commands
46
+ _and_ servers just by being installed. On Claude Code, `adopt` handles those too,
47
+ using the only two levers the harness exposes:
48
+
49
+ | what | how | effect |
50
+ | ------- | ---------------------------------------------- | ------------------------------------------------------- |
51
+ | skills | `skillOverrides[name] = "user-invocable-only"` | out of the model's context; `/name` still works for you |
52
+ | plugins | `enabledPlugins[id] = false` | plugin's skills, commands and servers all stop loading |
53
+
54
+ The router still finds all of them — it reads `~/.claude/skills` and
55
+ `installed_plugins.json` directly, which record _installation_, not what the
56
+ harness has enabled. That is what makes disabling a plugin a move rather than a
57
+ deletion.
58
+
59
+ Two things are refused rather than done quietly. A plugin whose MCP servers the
60
+ router cannot reach (usually OAuth, where the harness holds the token and the
61
+ router does not) stays enabled — disabling it would take away a server that
62
+ currently works. So does a plugin whose servers the router never learned about,
63
+ which happens if you drop `"plugins"` from `import`. Both are reported as `skip`
64
+ lines with the reason.
65
+
66
+ Use `--servers-only` to keep the old behaviour, `--skill-mode off` to remove the
67
+ slash command as well, and `--keep-skill` / `--keep-plugin` to exempt individual
68
+ ones. Codex, Cursor and VS Code have no skill or plugin concept, so there is
69
+ nothing extra to do there.
70
+
71
+ Run `autorouter doctor` to see the difference:
72
+
73
+ ```
74
+ ## Context cost
75
+ exposing everything: ~23,172 tokens
76
+ router surface: ~700 tokens
77
+ still loaded direct: ~20,115 tokens (18,657 servers + 1,458 skills)
78
+ actually saved: ~2,357 tokens per request (10%)
79
+
80
+ ## Not yet adopted
81
+ claude
82
+ servers: datadog:mcp, semble, railway, pencil, gemini
83
+ plugins: frontend-design
84
+ skills: lavish, react-doctor, find-skills, use-railway
85
+ → autorouter adopt --target claude
86
+ ```
87
+
88
+ That "still loaded direct" line is what adoption removes. Every removal is backed
89
+ up verbatim to `~/.autorouter/adopted/` before anything is written;
90
+ `autorouter restore --target claude` puts it back byte for byte.
91
+
92
+ ## How it finds things
93
+
94
+ Retrieve, then rerank.
95
+
96
+ 1. **BM25** over every capability, always on, no dependencies. The tokenizer
97
+ splits camelCase and snake_case (`execute_sql` → `execute`, `sql`, `executesql`)
98
+ and boosts fields: name ×3, keywords ×2, description ×1, schema ×0.5.
99
+ 2. **Embeddings**, optional. Voyage or OpenAI; scores are min-max normalized and
100
+ fused `0.5 × bm25 + 0.5 × cosine`. An absent or unreachable provider is not an
101
+ error — it degrades to pure lexical.
102
+ 3. **A selector model** reranks the shortlist. It should be the cheapest model in
103
+ whichever harness you are using, so `init` asks which one when it cannot infer
104
+ it, and stores the answer in that harness's own server entry.
105
+
106
+ ### The selector backend
107
+
108
+ Three ways to reach a model, tried in this order under `"mode": "auto"`:
109
+
110
+ | backend | how | when it fires |
111
+ | ---------- | ------------------------------------------- | --------------------------------------------------------------- |
112
+ | `sampling` | `sampling/createMessage` back to the host | the host declares the capability — few do; Claude Code does not |
113
+ | `api` | direct HTTPS to Anthropic / OpenAI / Ollama | `selector.apiKeyEnv` names a variable that is actually set |
114
+ | `cli` | `claude -p` or `codex exec`, headless | a harness CLI is on `PATH` |
115
+
116
+ The CLI backend is the one that usually fires, and it exists because the other two
117
+ usually cannot. Sampling is the protocol's own answer and almost nothing
118
+ implements it. The API backend then asks for an `ANTHROPIC_API_KEY` that a Claude
119
+ Code subscriber has no reason to own — they logged in, they did not buy a key — so
120
+ the router would degrade to raw index order while telling them to go purchase
121
+ access to a model they are already paying for. Meanwhile `claude` is sitting on
122
+ `PATH`, already authenticated. Shelling out to it reuses that login with nothing
123
+ to configure and no second bill.
124
+
125
+ What it costs is process startup: 3-5s for a 30-candidate list against
126
+ `claude -p --model haiku`, against roughly 1s for a direct HTTPS request. That is
127
+ why a configured API key still outranks it. Selections are memoized per query and
128
+ candidate set for the life of the process, so the price is paid once per distinct
129
+ search, not once per turn.
130
+
131
+ Getting there took stripping the harness back to a reranker. Out of the box the
132
+ same call took 22s, because a headless agent boots hooks, LSP, plugin sync and
133
+ `CLAUDE.md` discovery it has no use for, and then spends 6,223 output tokens
134
+ thinking about a list it could rank in 168 — and returns a _worse_ answer for the
135
+ deliberation. So the subprocess runs with `--bare --tools "" --setting-sources ""`
136
+ and `MAX_THINKING_TOKENS=0` (Codex: `--ephemeral -s read-only`). The empty tool
137
+ list is a correctness property before it is a saving: a selector that could edit
138
+ files would be a different program.
139
+
140
+ ```jsonc
141
+ "selector": {
142
+ "mode": "auto", // auto | sampling | cli | api | off
143
+ "provider": "cli",
144
+ "cliCommand": "claude", // only needed if the binary is not under its usual name
145
+ "model": "haiku",
146
+ "candidates": 30,
147
+ "maxResults": 8,
148
+ "timeoutMs": 20000 // raised automatically to the backend's own floor
149
+ }
150
+ ```
151
+
152
+ It is also started with an empty MCP config (`--strict-mcp-config`,
153
+ `mcp_servers={}`) — inheriting the router's own wiring would load the exact
154
+ catalog the router exists to keep out of context, and on a bad day recurse into
155
+ the router itself.
156
+
157
+ `model` is passed through only when you set it. Codex rejects model names an
158
+ account's plan does not carry, so the account default is what it gets otherwise.
159
+
160
+ `mode: "off"` skips reranking entirely and returns raw index order, which is
161
+ fast, free, and noticeably worse.
162
+
163
+ Downstream servers are **not** spawned at startup. The catalog is built once and
164
+ persisted to `~/.cache/autorouter/catalog.json`; a server is cold-started only
165
+ when one of its capabilities is first called.
166
+
167
+ ## What it indexes
168
+
169
+ | kind | source |
170
+ | ---------------------------- | --------------------------------------------------------------- |
171
+ | `tool`, `prompt`, `resource` | every configured MCP server (following `nextCursor` pagination) |
172
+ | `skill` | `**/SKILL.md` under your skill paths and plugin `skills/` dirs |
173
+ | `command`, `agent` | plugin `commands/*.md` and `agents/*.md` |
174
+
175
+ Skills, commands and agents are also republished as [slash commands](#slash-commands).
176
+
177
+ Harness configs are imported rather than duplicated: `~/.claude.json` (global and
178
+ per-project) and `.mcp.json`, `~/.codex/config.toml`, `~/.cursor/mcp.json`,
179
+ `~/.vscode/mcp.json`, and installed Claude Code plugins.
180
+
181
+ ## Configuration
182
+
183
+ `autorouter.json` — looked up at `$AUTOROUTER_CONFIG`, `./.autorouter.json`,
184
+ `./autorouter.json`, `~/.config/autorouter/config.json`, `~/.autorouter.json`.
185
+
186
+ ```jsonc
187
+ {
188
+ "import": ["claude", "codex", "cursor", "plugins"],
189
+ "servers": { "custom": { "command": "…", "args": [] } },
190
+ "skillPaths": ["~/.claude/skills", ".claude/skills"],
191
+ "exclude": ["gemini.generate_video"], // never surfaced at all
192
+ "alwaysExpose": ["semble.search"], // stays first-class, never adopted
193
+ "confirm": ["supabase.execute_sql"], // first call is rejected, must re-issue
194
+ "selector": { "mode": "auto", "model": "claude-haiku-4-5-20251001" },
195
+ "embeddings": {
196
+ "provider": "voyage",
197
+ "model": "voyage-3-lite",
198
+ "apiKeyEnv": "VOYAGE_API_KEY",
199
+ },
200
+ }
201
+ ```
202
+
203
+ Env overrides (`AUTOROUTER_IMPORT`, `AUTOROUTER_SELECTOR_MODEL`,
204
+ `AUTOROUTER_SELECTOR_MODE`, `AUTOROUTER_EMBEDDINGS_PROVIDER`, …) let each harness
205
+ pin its own behaviour without a shared global file. `AUTOROUTER_HOME` redirects
206
+ every home-relative path, for tests and containers.
207
+
208
+ ## Slash commands
209
+
210
+ Adopting a plugin into the router would otherwise cost you its slash commands:
211
+ Claude Code reads `commands/*.md` off disk, and nothing over MCP can add to that
212
+ list. What it _does_ surface as slash commands are MCP prompts, so every skill,
213
+ plugin command and subagent in the catalog is republished as one:
214
+
215
+ ```
216
+ /mcp__autorouter__find <what you want to do>
217
+ /mcp__autorouter__datadog_ddviz
218
+ /mcp__autorouter__supabase_supabase
219
+ ```
220
+
221
+ Bodies are substituted the way the native loader substitutes them — `$ARGUMENTS`
222
+ for everything you typed, `$1`..`$9` positionally — so a command file written for
223
+ Claude Code behaves identically through the router. `argument-hint` frontmatter
224
+ is passed through as the argument description. Names are prefixed with the owning
225
+ plugin, so two plugins shipping a `setup` command both stay reachable rather than
226
+ one silently shadowing the other.
227
+
228
+ Tools are deliberately not published here. A prompt returns text for the model to
229
+ act on and cannot return a tool result, so a slash command for `execute_sql` would
230
+ look callable and do nothing. Tools reach the model through search instead.
231
+
232
+ ## Harness support
233
+
234
+ Searching returns a tool with its **full description and input schema**, not a
235
+ summary — the same information a native tool listing carries, because a truncated
236
+ description leaves the model guessing argument names, and a guessed argument is
237
+ the whole difference between roughly reliable and reliable.
238
+
239
+ Better still, where the client honours `notifications/tools/list_changed`,
240
+ `find_capabilities` **promotes** what it matched to real first-class tools. The
241
+ model then makes an ordinary tool call: the host validates arguments against the
242
+ real schema, the permission prompt names the real tool rather than
243
+ `call_capability`, and the router is not in the execution path at all. It becomes
244
+ a loader that decides what is in your tool list, not a middleman on every call.
245
+
246
+ | supports `listChanged` | proxy only |
247
+ | ----------------------------------------------- | ------------------------------------------------ |
248
+ | Claude Code ≥ 2.1.232, GitHub Copilot, opencode | Codex, Gemini CLI, Claude Desktop, Vercel AI SDK |
249
+
250
+ Where it is unsupported the schema travels inline in the search result instead,
251
+ and `activate_capabilities` is never advertised, so the model is never told about
252
+ a tool it cannot use. Override the detection with `AUTOROUTER_DYNAMIC=on|off|auto`.
253
+ A tool registered mid-turn may not be callable until the next turn on Claude Code,
254
+ so `call_capability` always remains as the same-turn fallback and returns an
255
+ identical result.
256
+
257
+ ## Token budgets
258
+
259
+ A router that leaks context is just a slower way of loading everything, so the
260
+ three surfaces that persist or accumulate are each bounded in **tokens**, not in
261
+ item count. Counting items looks like a bound and is not one: tool definitions in
262
+ a real catalog range from 199 to 1,748 tokens, so "15 tools" is anywhere between
263
+ 3k and 26k depending only on what the model happened to search for.
264
+
265
+ | surface | budget | lifetime |
266
+ | ------------------------- | --------------------------------- | ---------- |
267
+ | promoted tool list | 3,000 tok, LRU eviction | session |
268
+ | inline schemas per search | 700 tok, spent top-down | one result |
269
+ | prompt list | descriptions clamped to 180 chars | session |
270
+
271
+ Tools promoted by the search in flight are never evicted — a tool that vanishes
272
+ between being offered and being called is worse than one never offered.
273
+
274
+ Schemas are **compacted** everywhere they are repeated: `$schema`, `title` and
275
+ `examples` are dropped, and prose is trimmed, more aggressively the deeper it
276
+ sits. Every structural field survives untouched — names, types, enums, `required`,
277
+ nesting, and `additionalProperties: false` — because dropping one of those turns a
278
+ valid call into a guessed one. On this machine that is a 31% cut across the
279
+ catalog and 3× on the worst offenders, with no effect on what is callable.
280
+ `describe_capability` is the exception and returns the schema verbatim: it exists
281
+ precisely to recover anything a budgeted result had to leave out.
282
+
283
+ ## CLI
284
+
285
+ For agents that have a shell but no MCP, the same engine is directly usable:
286
+
287
+ ```sh
288
+ autorouter search "chart a csv"
289
+ autorouter search "query postgres" --raw --limit 5
290
+ autorouter describe skill:dataviz
291
+ autorouter call mcp:railway/get_logs --args '{"lines":50}'
292
+ autorouter list --kind skill
293
+ autorouter doctor
294
+ autorouter reindex
295
+
296
+ autorouter adopt --target claude --dry-run # preview, change nothing
297
+ autorouter adopt --target claude --servers-only # skip skills and plugins
298
+ autorouter adopt --target claude --keep supabase --keep-plugin datadog
299
+ autorouter restore --target claude # undo the most recent adopt
300
+
301
+ autorouter login # which servers need a grant
302
+ autorouter login datadog:mcp # authorize one (opens a browser)
303
+ autorouter logout datadog:mcp # forget a stored grant
304
+ ```
305
+
306
+ ## OAuth servers
307
+
308
+ Several of the heaviest MCP servers — Datadog, Supabase, Linear — carry no
309
+ credentials in their config at all. Datadog's headers expand to `""`, Supabase's
310
+ are only `X-Source-*`. The working token is an OAuth grant your harness obtained
311
+ and keeps in its own credential store, and it was issued to that harness.
312
+
313
+ The router does not read it. It runs its own authorization-code flow and holds
314
+ its own grant, which means it also works under Codex and Cursor, neither of which
315
+ has a token to borrow:
316
+
317
+ ```sh
318
+ autorouter login datadog:mcp
319
+ autorouter login supabase:supabase
320
+ autorouter reindex
321
+ ```
322
+
323
+ Registration is RFC 7591 dynamic client registration, so there is no app to
324
+ create first. Tokens live in `~/.autorouter/oauth/<server>.json` at `0600` and
325
+ are refreshed automatically; `logout` deletes them. The loopback redirect uses a
326
+ fixed port (33418, `--port` or `$AUTOROUTER_OAUTH_PORT` to change it) because the
327
+ redirect URI is baked into the registration a provider stores — a grant obtained
328
+ on one port cannot be refreshed from another.
329
+
330
+ ### Choosing permissions
331
+
332
+ A dynamically registered client defaults to every scope the provider advertises,
333
+ which for Supabase includes `database:write` and `edge_functions:write` — a token
334
+ able to drop tables, held on behalf of a search tool. Scopes are the only
335
+ restriction that survives a prompt injection, so pick them deliberately:
336
+
337
+ ```sh
338
+ autorouter login supabase --list-scopes # what this server offers
339
+ autorouter login supabase --read-only # 8 of 13, no write scopes
340
+ autorouter login supabase --scopes "projects:read,database:read"
341
+ ```
342
+
343
+ `--read-only` keeps the scopes whose names do not grant mutation. It is a
344
+ heuristic over naming conventions (`:write`, `admin`, `manage`, `all`) and it
345
+ cannot see through an opaque name like GitHub's `repo`, which grants write while
346
+ reading as neutral — `--scopes` is the exact lever when it guesses wrong. Where a
347
+ provider offers no read-only subset at all, `--read-only` fails loudly rather than
348
+ requesting an empty scope, which most providers read as "give the default".
349
+
350
+ Scopes are fixed when the grant is issued, so narrowing an existing one means
351
+ `--force` and a fresh authorization. A re-login inherits the previous narrowing
352
+ unless you pass a new one, and `autorouter login` with no argument prints what
353
+ each stored grant actually covers.
354
+
355
+ Until a server has a grant, `adopt` refuses to move it or to disable the plugin
356
+ that supplies it. Moving a server the router cannot reach would delete a working
357
+ capability, so `doctor` and `adopt` both print the exact `login` command instead.
358
+
359
+ ## The trade-off, stated plainly
360
+
361
+ Routing means your host's permission prompt sees `call_capability`, not
362
+ `supabase.execute_sql`. That is a real loss of granularity and this tool does not
363
+ pretend otherwise. Three mitigations: `exclude` means a capability is never
364
+ surfaced; `confirm` rejects the first attempt with the resolved target spelled
365
+ out and requires the model to re-issue with `confirm: true`; and every result is
366
+ prefixed with the resolved `server/tool` so the transcript stays auditable.
367
+
368
+ Adopting skills and plugins edits `~/.claude/settings.json`, a file you also edit
369
+ by hand. The whole file is backed up verbatim alongside the server moves and
370
+ `restore` rewrites it byte for byte, but it is a shared file and worth knowing
371
+ about. Hidden skills default to `user-invocable-only` rather than `off` for the
372
+ same reason: `/name` keeps working, so the capability is relocated, not removed.
373
+
374
+ ## Development
375
+
376
+ ```sh
377
+ bun install
378
+ bun test
379
+ bun run src/cli.ts doctor
380
+ bun run build # bun build src/cli.ts --target=node --outfile dist/cli.js
381
+ ```
382
+
383
+ The build targets Node so `npx` works for people who do not have Bun.
384
+
385
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the invariants worth knowing before
386
+ changing anything — chiefly that nothing is spawned at startup, that every write
387
+ `adopt` makes has to restore byte for byte, and that the selector subprocess runs
388
+ with no tools by design.
389
+
390
+ ## Licence
391
+
392
+ [MIT](LICENSE). Security reports go through [SECURITY.md](SECURITY.md), not the
393
+ public issue tracker.