{
  "markdown": "# autorouter\n\n[![CI](https://github.com/Webb-Ventures/autorouter/actions/workflows/ci.yml/badge.svg)](https://github.com/Webb-Ventures/autorouter/actions/workflows/ci.yml)\n[![npm](https://img.shields.io/npm/v/autorouter-mcp.svg)](https://www.npmjs.com/package/autorouter-mcp)\n[![licence: MIT](https://img.shields.io/badge/licence-MIT-blue.svg)](LICENSE)\n\nOne tool instead of every tool.\n\nEvery MCP server you register injects its full tool list — names, descriptions,\nJSON schemas — into the system prompt on every single turn. Five servers and a\nhandful of skills is routinely 20–30k tokens of permanent overhead, paid whether\nor not the task touches any of them. It also makes tool selection _worse_: more\ncandidates means more mis-picks.\n\nautorouter is an MCP server that exposes a handful of stable tools and hides\neverything else behind a search. The model asks for what it needs in natural\nlanguage, gets back a few ranked candidates with schemas attached, and calls one\nthrough the router. A tool it actually uses is then promoted into the real tool\nlist, so the context is spent on what the session needs rather than on what it\nmight.\n\n```\nfind_capabilities({ query: \"inspect recent deployment errors\" })\n  → mcp:deployments/get_logs       (tool)\n  → mcp:monitoring/search_events   (tool)\n  → skill:incident-triage          (skill)\n\ndescribe_capability({ id: \"mcp:deployments/get_logs\" })\ncall_capability({ id: \"mcp:deployments/get_logs\", arguments: { lines: 50 } })\n```\n\n## Install\n\n```sh\nnpm install -g autorouter-mcp        # or: npx autorouter-mcp <command>\nautorouter init --target claude      # claude | codex | cursor | vscode\nautorouter adopt --target claude     # ← the step that actually saves context\n```\n\nThat is a one-time migration of what you already have. New servers do not need\nit — see [Adding an MCP server](#adding-an-mcp-server).\n\n**`adopt` is not optional.** Registering the router alongside your existing\nservers is a net _increase_: their schemas are still loaded and the router adds\nfour more tools. `adopt` moves the downstream entries out of the harness config\nand into the router's own, so the harness loads one server and the router still\nreaches all of them.\n\nMCP servers are not the whole bill. Skills and plugins load through entirely\nseparate mechanisms — every `SKILL.md` contributes its name and description to\nthe system prompt, and an enabled plugin contributes its own skills, commands\n_and_ servers just by being installed. On Claude Code, `adopt` handles those too,\nusing the only two levers the harness exposes:\n\n| what    | how                                            | effect                                                  |\n| ------- | ---------------------------------------------- | ------------------------------------------------------- |\n| skills  | `skillOverrides[name] = \"user-invocable-only\"` | out of the model's context; `/name` still works for you |\n| plugins | `enabledPlugins[id] = false`                   | plugin's skills, commands and servers all stop loading  |\n\nThe router still finds all of them — it reads `~/.claude/skills` and\n`installed_plugins.json` directly, which record _installation_, not what the\nharness has enabled. That is what makes disabling a plugin a move rather than a\ndeletion.\n\nTwo things are refused rather than done quietly. A plugin whose MCP servers the\nrouter cannot reach (usually OAuth, where the harness holds the token and the\nrouter does not) stays enabled — disabling it would take away a server that\ncurrently works. So does a plugin whose servers the router never learned about,\nwhich happens if you drop `\"plugins\"` from `import`. Both are reported as `skip`\nlines with the reason.\n\nUse `--servers-only` to keep the old behaviour, `--skill-mode off` to remove the\nslash command as well, and `--keep-skill` / `--keep-plugin` to exempt individual\nones. Codex, Cursor and VS Code have no skill or plugin concept, so there is\nnothing extra to do there.\n\nRun `autorouter doctor` to see the difference:\n\n```\n## Context cost\n  exposing everything: ~87,524 tokens\n  router surface:      ~972 tokens\n  best case:           ~86,552 tokens saved (99%)\n\n  per harness (a session only ever runs in one):\n    codex   still loaded ~35,500 → saves ~51,052 (58%)\n    claude, cursor, vscode: fully adopted → saves ~86,552 (99%)\n\n## Not yet adopted\n  codex\n    servers: project-tools, docs-search, issue-tracker\n    → autorouter adopt --target codex\n```\n\n\"Still loaded\" is what adoption removes, and it is reported per harness rather\nthan summed: a session runs in exactly one, so a server still registered in Codex\ncosts a Claude Code session nothing. Every removal is backed up verbatim to\n`~/.autorouter/adopted/` before anything is written;\n`autorouter restore --target claude` puts it back byte for byte.\n\n## How it finds things\n\nRetrieve, then rerank.\n\n1. **BM25** over every capability, always on, no dependencies. The tokenizer\n   splits camelCase and snake_case (`get_user` → `get`, `user`, `getuser`)\n   and boosts fields: name ×3, keywords ×2, description ×1, schema ×0.5.\n2. **Embeddings**, optional. Voyage or OpenAI; scores are min-max normalized and\n   fused `0.5 × bm25 + 0.5 × cosine`. An absent or unreachable provider is not an\n   error — it degrades to pure lexical.\n3. **A selector model** reranks the shortlist. It should be the cheapest model in\n   whichever harness you are using, so `init` asks which one when it cannot infer\n   it, and stores the answer in that harness's own server entry.\n\n### The selector backend\n\nThree ways to reach a model, tried in this order under `\"mode\": \"auto\"`:\n\n| backend    | how                                         | when it fires                                                   |\n| ---------- | ------------------------------------------- | --------------------------------------------------------------- |\n| `sampling` | `sampling/createMessage` back to the host   | the host declares the capability — few do; Claude Code does not |\n| `api`      | direct HTTPS to Anthropic / OpenAI / Ollama | `selector.apiKeyEnv` names a variable that is actually set      |\n| `cli`      | `claude -p` or `codex exec`, headless       | a harness CLI is on `PATH`                                      |\n\nThe CLI backend is the one that usually fires, and it exists because the other two\nusually cannot. Sampling is the protocol's own answer and almost nothing\nimplements it. The API backend then asks for an `ANTHROPIC_API_KEY` that a Claude\nCode subscriber has no reason to own — they logged in, they did not buy a key — so\nthe router would degrade to raw index order while telling them to go purchase\naccess to a model they are already paying for. Meanwhile `claude` is sitting on\n`PATH`, already authenticated. Shelling out to it reuses that login with nothing\nto configure and no second bill.\n\nWhat it costs is process startup, which makes a CLI selector slower than a direct\nHTTPS request. That is why a configured API key still outranks it. Selections are\nmemoized per query and candidate set for the life of the process, so the price is\npaid once per distinct search, not once per turn.\n\nThe harness is stripped back to a reranker. A default headless agent may boot\nhooks, language servers, plugin sync and project-instruction discovery that a\nshort ranking task does not need. The subprocess therefore runs with\n`--bare --tools \"\" --setting-sources \"\"` and `MAX_THINKING_TOKENS=0` (Codex:\n`--ephemeral -s read-only`). The empty tool list is a correctness property before\nit is a saving: a selector that could edit files would be a different program.\n\n```jsonc\n\"selector\": {\n  \"mode\": \"auto\",          // auto | sampling | cli | api | off\n  \"provider\": \"cli\",\n  \"cliCommand\": \"claude\",  // only needed if the binary is not under its usual name\n  \"model\": \"haiku\",\n  \"candidates\": 30,\n  \"maxResults\": 8,\n  \"timeoutMs\": 20000       // raised automatically to the backend's own floor\n}\n```\n\nIt is also started with an empty MCP config (`--strict-mcp-config`,\n`mcp_servers={}`) — inheriting the router's own wiring would load the exact\ncatalog the router exists to keep out of context, and on a bad day recurse into\nthe router itself.\n\n`model` is passed through only when you set it. Codex rejects model names an\naccount's plan does not carry, so the account default is what it gets otherwise.\n\n`mode: \"off\"` skips reranking entirely and returns raw index order, which is\nfast, free, and noticeably worse.\n\nDownstream servers are **not** spawned at startup. The catalog is built once and\npersisted to `~/.cache/autorouter/catalog.json`; a server is cold-started only\nwhen one of its capabilities is first called.\n\n## What it indexes\n\n| kind                         | source                                                          |\n| ---------------------------- | --------------------------------------------------------------- |\n| `tool`, `prompt`, `resource` | every configured MCP server (following `nextCursor` pagination) |\n| `skill`                      | `**/SKILL.md` under your skill paths and plugin `skills/` dirs  |\n| `command`, `agent`           | plugin `commands/*.md` and `agents/*.md`                        |\n\nPlugin commands and agents are also republished as [slash commands](#slash-commands);\nskills are searchable but not republished by default, because the prompt list\nthat would carry them is permanent context — see [Slash commands](#slash-commands).\n\nHarness configs are imported rather than duplicated: `~/.claude.json` (global and\nper-project) and `.mcp.json`, `~/.codex/config.toml`, `~/.cursor/mcp.json`,\n`~/.vscode/mcp.json`, and installed Claude Code plugins.\n\n## Adding an MCP server\n\nRegister it with the router directly — it never enters anyone's context:\n\n```sh\nautorouter add linear --url https://mcp.linear.app/mcp\nautorouter add foo -- npx -y foo-mcp            # stdio, like `claude mcp add`\nautorouter add --json '{\"mcpServers\":{\"linear\":{\"url\":\"…\"}}}'   # paste the vendor snippet\nautorouter remove linear\n```\n\nThe entry is saved before the connection is checked, because the usual reason a\nnew server does not answer is that it has no OAuth grant yet — and\n`autorouter login <name>` needs the entry to exist before it can authorize it.\n\n**You can also just use the harness.** `claude mcp add foo …` still works: a\nrunning router notices the harness config changed, moves the entry into its own\nconfig with the usual backup, and tells you on the next search. So there is no\nsecond command to remember, and no window where you have forgotten it and are\npaying for that server's schemas on every turn. Set `\"autoAdopt\": false` to keep\nadoption manual.\n\n**From inside a session**, ask for it in words — the router exposes an\n`add_server` tool. Registering a stdio server means this machine will run that\ncommand from then on, so the first call only reports what would be registered;\nit takes a second call with `confirm: true` to write anything. Set\n`\"allowAddServer\": false` to withdraw the tool.\n\n## Configuration\n\n`autorouter.json` — looked up at `$AUTOROUTER_CONFIG`, `./.autorouter.json`,\n`./autorouter.json`, `~/.config/autorouter/config.json`, `~/.autorouter.json`.\n\n```jsonc\n{\n  \"import\": [\"claude\", \"codex\", \"cursor\", \"plugins\"],\n  \"servers\": { \"custom\": { \"command\": \"…\", \"args\": [] } },\n  \"skillPaths\": [\"~/.claude/skills\", \".claude/skills\"],\n  \"exclude\": [\"media.generate_video\"], // never surfaced at all\n  \"alwaysExpose\": [\"code-search.search\"], // stays first-class, never adopted\n  \"confirm\": [\"database.execute_statement\"], // first call is rejected, must re-issue\n  \"autoAdopt\": true, // move servers added to a harness behind the router\n  \"allowAddServer\": true, // expose the add_server tool to the model\n  \"promptMode\": \"commands\", // slash commands: \"all\" | \"commands\" | \"none\"\n  \"activation\": \"lazy\", // promote to a real tool: \"eager\" | \"lazy\" | \"off\"\n  \"selector\": { \"mode\": \"auto\" },\n  \"embeddings\": {\n    \"provider\": \"voyage\",\n    \"model\": \"voyage-3-lite\",\n    \"apiKeyEnv\": \"VOYAGE_API_KEY\",\n  },\n}\n```\n\nEnv overrides (`AUTOROUTER_IMPORT`, `AUTOROUTER_SELECTOR_MODEL`,\n`AUTOROUTER_SELECTOR_MODE`, `AUTOROUTER_PROMPT_MODE`, `AUTOROUTER_ACTIVATION`,\n`AUTOROUTER_AUTO_ADOPT`, `AUTOROUTER_ALLOW_ADD_SERVER`,\n`AUTOROUTER_EMBEDDINGS_PROVIDER`, …) let each harness\npin its own behaviour without a shared global file. `AUTOROUTER_HOME` redirects\nevery home-relative path, for tests and containers.\n\n## Slash commands\n\nAdopting a plugin into the router would otherwise cost you its slash commands:\nClaude Code reads `commands/*.md` off disk, and nothing over MCP can add to that\nlist. What it _does_ surface as slash commands are MCP prompts, so the catalog's\nplugin commands and subagents are republished as ones:\n\n```\n/mcp__autorouter__find <what you want to do>\n/mcp__autorouter__plugin_name_command_name\n```\n\n**Skills are not republished by default** (`promptMode: \"commands\"`). The prompt\nlist is permanent context — the host fetches it once and carries it for the whole\nsession — and skills are the bulk of it: one plugin shipping 141 of them costs\n~11.5k tokens on every turn, which is more than adopting that plugin saves. The\nalias is also mostly redundant, since `adopt --skill-mode user-invocable-only`\nleaves a local skill's native `/name` working and a plugin's skills are surfaced\nby the harness on demand rather than injected. Skills stay fully searchable\neither way; set `\"promptMode\": \"all\"` to get `/mcp__autorouter__skill_name` back,\nor `\"none\"` to publish only `find`.\n\nBodies are substituted the way the native loader substitutes them — `$ARGUMENTS`\nfor everything you typed, `$1`..`$9` positionally — so a command file written for\nClaude Code behaves identically through the router. `argument-hint` frontmatter\nis passed through as the argument description. Names are prefixed with the owning\nplugin, so two plugins shipping a `setup` command both stay reachable rather than\none silently shadowing the other.\n\nTools are deliberately not published here. A prompt returns text for the model to\nact on and cannot return a tool result, so a slash command for `database_write` would\nlook callable and do nothing. Tools reach the model through search instead.\n\n## Harness support\n\nSearching returns a tool with its **full description and input schema**, not a\nsummary — the same information a native tool listing carries, because a truncated\ndescription leaves the model guessing argument names, and a guessed argument is\nthe whole difference between roughly reliable and reliable.\n\nBetter still, where the client honours `notifications/tools/list_changed`, a tool\nyou **use** is **promoted** to a real first-class tool. From then on the model\nmakes an ordinary tool call: the host validates arguments against the real\nschema, the permission prompt names the real tool rather than `call_capability`,\nand the router is not in the execution path at all. It becomes a loader that\ndecides what is in your tool list, not a middleman on every call.\n\nPromotion is deferred to the first successful call (`activation: \"lazy\"`) because\na promoted tool is permanent context and a search hit is not evidence of need —\nmost of what a search matches is never called. Set `\"activation\": \"eager\"` to\npromote every hit as before, or `\"off\"` to route everything through\n`call_capability` and carry no tool definitions at all.\n\n| supports `listChanged`                          | proxy only                                       |\n| ----------------------------------------------- | ------------------------------------------------ |\n| Claude Code ≥ 2.1.232, GitHub Copilot, opencode | Codex, Gemini CLI, Claude Desktop, Vercel AI SDK |\n\nWhere it is unsupported the schema travels inline in the search result instead,\nand `activate_capabilities` is never advertised, so the model is never told about\na tool it cannot use. Override the detection with `AUTOROUTER_DYNAMIC=on|off|auto`.\nA tool registered mid-turn may not be callable until the next turn on Claude Code,\nso `call_capability` always remains as the same-turn fallback and returns an\nidentical result.\n\n## Token budgets\n\nA router that leaks context is just a slower way of loading everything, so the\nthree surfaces that persist or accumulate are each bounded in **tokens**, not in\nitem count. Counting items looks like a bound and is not one: tool definitions\nvary widely in size, so the context cost of \"15 tools\" depends entirely on what\nthe model happened to search for.\n\n| surface                   | budget                            | lifetime   |\n| ------------------------- | --------------------------------- | ---------- |\n| promoted tool list        | 3,000 tok, LRU eviction, on use only | session    |\n| inline schemas per search | 700 tok, spent top-down              | one result |\n| prompt list               | commands only, clamped to 120 chars  | session    |\n\nThe tool promoted by the call in flight is never evicted — a tool that vanishes\nbetween being offered and being called is worse than one never offered.\n\n`autorouter doctor` reports the real bill per harness rather than summing across\nthem, since a session only ever runs in one: a server still registered in Cursor\ncosts a Claude Code session nothing.\n\nSchemas are **compacted** everywhere they are repeated: `$schema`, `title` and\n`examples` are dropped, and prose is trimmed, more aggressively the deeper it\nsits. Every structural field survives untouched — names, types, enums, `required`,\nnesting, and `additionalProperties: false` — because dropping one of those turns a\nvalid call into a guessed one. This reduces repeated schema size without changing\nwhat is callable.\n\n`describe_capability` is the exception and returns the schema verbatim: it exists\nprecisely to recover anything a budgeted result had to leave out.\n\n## CLI\n\nFor agents that have a shell but no MCP, the same engine is directly usable:\n\n```sh\nautorouter search \"chart a csv\"\nautorouter search \"query a database\" --raw --limit 5\nautorouter describe skill:dataviz\nautorouter call mcp:deployments/get_logs --args '{\"lines\":50}'\nautorouter list --kind skill\nautorouter doctor\nautorouter reindex\n\nautorouter add linear --url https://mcp.linear.app/mcp\nautorouter add foo -- npx -y foo-mcp\nautorouter remove linear\n\nautorouter adopt --target claude --dry-run          # preview, change nothing\nautorouter adopt --target claude --servers-only     # skip skills and plugins\nautorouter adopt --target claude --keep project-tools --keep-plugin ui-toolkit\nautorouter restore --target claude                  # undo the most recent adopt\n\nautorouter login                                    # which servers need a grant\nautorouter login remote-server                      # authorize one (opens a browser)\nautorouter logout remote-server                     # forget a stored grant\n```\n\n## OAuth servers\n\nSome remote MCP servers carry no credentials in their visible configuration.\nThe working token is an OAuth grant obtained by the harness, stored in the\nharness's credential store, and issued specifically to that harness.\n\nThe router does not read it. It runs its own authorization-code flow and holds\nits own grant, which means it also works under Codex and Cursor, neither of which\nhas a token to borrow:\n\n```sh\nautorouter login remote-server\nautorouter reindex\n```\n\nRegistration is RFC 7591 dynamic client registration, so there is no app to\ncreate first. Tokens live in `~/.autorouter/oauth/<server>.json` at `0600` and\nare refreshed automatically; `logout` deletes them. The loopback redirect uses a\nfixed port (33418, `--port` or `$AUTOROUTER_OAUTH_PORT` to change it) because the\nredirect URI is baked into the registration a provider stores — a grant obtained\non one port cannot be refreshed from another.\n\n### Choosing permissions\n\nA dynamically registered client may default to every scope the provider\nadvertises, including write or administrative permissions. That can leave a\nsearch tool holding a token capable of destructive actions. Scopes are the only\nrestriction that survives a prompt injection, so pick them deliberately:\n\n```sh\nautorouter login remote-server --list-scopes\nautorouter login remote-server --read-only\nautorouter login remote-server --scopes \"projects:read,data:read\"\n```\n\n`--read-only` keeps the scopes whose names do not grant mutation. It is a\nheuristic over naming conventions (`:write`, `admin`, `manage`, `all`) and it\ncannot infer the permissions behind opaque scope names. `--scopes` is the exact\nlever when the heuristic cannot tell. Where a provider offers no read-only subset\nat all, `--read-only` fails loudly rather than requesting an empty scope, which\nmost providers read as \"give the default\".\n\nScopes are fixed when the grant is issued, so narrowing an existing one means\n`--force` and a fresh authorization. A re-login inherits the previous narrowing\nunless you pass a new one, and `autorouter login` with no argument prints what\neach stored grant actually covers.\n\nUntil a server has a grant, `adopt` refuses to move it or to disable the plugin\nthat supplies it. Moving a server the router cannot reach would delete a working\ncapability, so `doctor` and `adopt` both print the exact `login` command instead.\n\n## The trade-off, stated plainly\n\nRouting means your host's permission prompt sees `call_capability`, not\n`database.execute_statement`. That is a real loss of granularity and this tool\ndoes not pretend otherwise. Three mitigations: `exclude` means a capability is\nnever surfaced; `confirm` rejects the first attempt with the resolved target\nspelled out and requires the model to re-issue with `confirm: true`; and every\nresult is prefixed with the resolved `server/tool` so the transcript stays\nauditable.\n\nAdopting skills and plugins edits `~/.claude/settings.json`, a file you also edit\nby hand. The whole file is backed up verbatim alongside the server moves and\n`restore` rewrites it byte for byte, but it is a shared file and worth knowing\nabout. Hidden skills default to `user-invocable-only` rather than `off` for the\nsame reason: `/name` keeps working, so the capability is relocated, not removed.\n\n## Development\n\n```sh\nbun install\nbun test\nbun run src/cli.ts doctor\nbun run build          # bun build src/cli.ts --target=node --outfile dist/cli.js\n```\n\nThe build targets Node so `npx` works for people who do not have Bun.\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for the invariants worth knowing before\nchanging anything — chiefly that nothing is spawned at startup, that every write\n`adopt` makes has to restore byte for byte, and that the selector subprocess runs\nwith no tools by design.\n\n## Licence\n\n[MIT](LICENSE). Security reports go through [SECURITY.md](SECURITY.md), not the\npublic issue tracker.\n",
  "bytes": 22688,
  "sha": "afacc5cc444a2aef4f9a5be7ce24db4bf7a8245f5e2d7afb1f493675c95f3b6f",
  "repo_slug": "webb-ventures/autorouter",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_webb_ventures_autorouter_3404b204/readme"
}