MCP gateway
Put BitRouter in front of your MCP servers — one endpoint for many tool servers, with fan-out, name prefixing, list caching, and the option to run the tool loop server-side.
An agent that wants ten tool servers normally has to know about ten endpoints, each with its own address, auth, and lifecycle. The MCP gateway puts BitRouter in front of them: your agent connects to one endpoint, and the gateway forwards discovery and tool calls to the right upstream and relays the responses back. Same idea as the model surface, applied to tools.
That buys three things you'd otherwise rebuild in every agent:
- Uniform auth — the agent authenticates once to BitRouter instead of carrying credentials for every upstream.
- Discovery — tools from every server surface in one place, so an agent finds what's available without being pre-wired to each one.
- Policy — every tool call passes through one process, which is the only practical place to enforce rules consistently.
Declaring upstream servers
Upstreams live under mcp_servers in bitrouter.yaml, keyed by server id. Two transports are supported — stdio spawns a child process, http dials a Streamable HTTP endpoint:
mcp_servers:
demo:
name: demo
transport:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
env:
LOG_LEVEL: warn
vendor:
name: vendor
transport:
type: http
url: https://mcp.example.com/v1/mcp
headers:
Authorization: "Bearer ${MCP_TOKEN}"A stdio child inherits the ambient environment, and env adds to it rather than replacing it. HTTP headers are static and sent on every request — the seam for an upstream's API key.
The id carries into the URL, so it's constrained: non-empty, no /, and not literally sse (reserved by the spec's deprecated transport name).
mcp_servers is empty by default, and when it's empty the binary doesn't mount the MCP route at all. A 404 on /mcp before you've declared anything is the expected behavior, not a broken build.
Two routes: per-server and aggregate
Once servers are declared, the daemon serves them two ways:
| Route | What it is |
|---|---|
POST /mcp/{server} | One upstream, passed through. The {server} segment is the id from mcp_servers. |
POST /mcp | The aggregate endpoint — one virtual server fanning out across every participating upstream. |
The aggregate is what makes "connect once" true. A list call queries every participating server and returns the merged catalog; a tool call routes to the server that owns the name. It's on by default and its path is configurable:
mcp:
aggregate:
enabled: true # false → only the per-server /mcp/{server} routes are mounted
route: /mcpName prefixing and opting out
Merging catalogs means names can collide, so tools and prompts are prefixed in the aggregate — the search tool on the demo server is advertised as demo__search. The prefix defaults to {server_name}__ and is overridable per server. A server can also stay out of the fan-out entirely while remaining reachable on its own route:
mcp_servers:
vendor:
name: vendor
transport: { type: http, url: https://mcp.example.com/v1/mcp }
aggregate: false # reachable at /mcp/vendor only
demo:
name: demo
transport: { type: stdio, command: mcp-demo }
tool_prefix: "fs__" # advertised as fs__search instead of demo__searchPrefixing applies to the aggregate. On POST /mcp/{server} you're talking to one upstream, so its tools keep their own names.
Caching list calls
Discovery calls are cheap individually and expensive in aggregate — fanning tools/list out to every upstream on every agent connection adds up fast. The gateway wraps them in a TTL cache, on by default:
| Setting | Default | Method cached |
|---|---|---|
tools_list_ttl_secs | 60 | tools/list |
resources_list_ttl_secs | 60 | resources/list |
resources_templates_list_ttl_secs | 300 | resources/templates/list |
prompts_list_ttl_secs | 300 | prompts/list |
max_entries_per_server | 64 | LRU safety bound, per server |
Set any TTL to 0 to disable caching for that method, or mcp.cache.enabled: false to skip installing the layer:
mcp:
cache:
tools_list_ttl_secs: 5 # a server whose tool list changes as you develop itOnly list calls are cached. Tool execution is never cached — every tools/call goes to the upstream.
Upstream protocol lifecycle
mcp.upstream_protocol chooses how the gateway dials upstreams:
latest(default) — the legacyinitializelifecycle, at the newest version the MCP SDK treats as current (today2025-11-25).2026-07-28— start withserver/discoverand fall back toinitializeonly on a JSON-RPCMETHOD_NOT_FOUND.
The fallback is deliberately narrow: any other discovery error fails the connection rather than silently retrying the old way. Opt in per deployment, and only for upstreams you expect to speak the modern lifecycle.
Opting into 2026-07-28 also lets an upstream answer tools/call with an MRTR input_required response or a Tasks task handle. Neither is a shape this gateway can carry, so both surface as explicit errors rather than being papered over.
Letting BitRouter run the tool loop
Everything above assumes your agent calls the tools. The alternative is to hand the loop to the router: list the servers under server_tools.mcp_servers and BitRouter advertises their tools to the model, executes the calls, feeds results back, and re-calls until the model stops asking.
server_tools:
mcp_servers: [demo, vendor]
max_iterations: 6 # optional override of the loop's round capAn empty mcp_servers here leaves the pipeline strictly single-shot — declaring an upstream under mcp_servers makes it reachable, but only naming it under server_tools puts it inside the router's own loop. Each upstream becomes an MCP-backed toolset, and the same demo__search prefixing applies. The loop's bounds, approval policy, and the model-backed tools that share it are on Server tools.
Introspecting the upstreams
v1.0 keeps no global tool registry — these are live, one-shot queries against the servers:
bitrouter tools list # every tool advertised by every configured server
bitrouter tools status # health-check each server with a tools/list round-trip
bitrouter tools discover <server> # print a YAML stub to paste under mcp_servers:tools status is the first thing to run when a tool isn't reaching the model — it separates "the server is down" from "the model was never offered it." tools discover is the fastest way to add a server: point it at one and paste the stub it prints.
Harnesses get the gateway for free
Launch an agent harness through bitrouter tui and the aggregate endpoint is injected into it automatically as a streamable-HTTP MCP server (bitrouter_tools), alongside the skills server (bitrouter_skills). Orchestrators and the subagents they spawn inherit your whole configured tool surface without being set up for it individually — which is the point of declaring servers in one file.
See Skills for the capability layer riding the same rails.
How is this guide?
Overview
BitRouter routes more than models — the MCP gateway makes tools and skills routable resources, and the ACP gateway makes sub-agents first-class primitives, both on the same endpoint.
ACP gateway
Sub-agents as routable primitives — declare ACP agents in bitrouter.yaml, dispatch tasks to them, and keep their model traffic under your routing policy.