One endpoint for all your MCP servers.
mcproutersits in front of every Model Context Protocol server you run — filesystem, github, postgres, slack, whatever — and routes each JSON-RPC call to the right backend based on which tool, resource, or prompt it needs. Fallback chains, health checks, load balancing, structured logging, and Prometheus metrics are all built in.
Agent frameworks like Claude Code, Codex, and Cursor already let you configure multiple MCP servers. What they don't do is route between them. So every client has to:
- Know the exact URL / command of each server
- Maintain a separate connection to each
- Figure out on its own which server provides which tool
- Handle failures one-by-one with no shared fallback logic
- Log and meter each conversation separately
That's fine when you run two servers. It falls apart when you run ten.
mcprouter unifies them behind a single endpoint. Clients point at
one URL. mcprouter introspects every backend at startup (via
tools/list, resources/list, prompts/list), builds a capability
map, and dispatches every request to the right place. If a backend is
slow or dead, the circuit breaker trips and traffic flows down a
configured fallback chain. Multiple backends providing the same tool?
Load-balance them. Need Prometheus metrics or a rotated request log?
It's there.
The project is ~3.5K lines of Go, uses only stdlib + gopkg.in/yaml.v3,
and ships 200 tests that exercise every piece of routing, fallback,
health, load balancing, transport, and metrics logic.
| Config-driven registry | YAML describes every backend with transport, weight, priority, auth, health params |
| JSON-RPC 2.0 routing | Method-, tool-, resource-, and prompt-level dispatch; namespaced aliases to avoid collisions |
| Capability discovery | Automatic tools/list / resources/list / prompts/list scrape, cached with TTL and live refresh |
| Aggregated discovery | Fan out tools/list to every backend and merge results transparently |
| Health checking | Periodic MCP ping per backend, auto-removal of dead backends, GET /health snapshot |
| Fallback chains | Per-capability ordered retries with fixed backoff and per-backend circuit breakers |
| Load balancing | Round-robin, smooth weighted RR (nginx algorithm), least-connections, sticky sessions |
| Transports | stdio (subprocess), http (JSON-RPC POST), sse (Server-Sent Events), websocket (stub) |
| Request logging | Structured JSON, file rotation, automatic redaction of tokens/secrets |
| Metrics | Built-in Prometheus exposition at /metrics — counters, gauges, histograms |
| Batch support | Handles JSON-RPC array batches out of the box |
| Graceful shutdown | SIGINT/SIGTERM → drain in-flight → close backends |
| Zero external deps | stdlib only, with gopkg.in/yaml.v3 for config |
git clone https://github.com/JSLEEKR/mcprouter
cd mcprouter
go build -o mcprouter ./cmd/mcprouter
./mcprouter versiongo install github.com/JSLEEKR/mcprouter/cmd/mcprouter@latestPre-built binaries for Linux / macOS / Windows are on the Releases page.
- Write a config (
config.yaml):
server:
host: 127.0.0.1
port: 8080
backends:
- name: fs
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
tools: [read_file, write_file, list_directory]
- name: github
transport: http
url: https://mcp.example.com/github/rpc
tools: [create_issue, get_pr]
auth:
type: bearer
token: ghp_YOUR_TOKEN
routing:
strategy: round_robin
namespace: true
metrics:
enabled: true- Validate it:
mcprouter validate --config config.yaml
# OK: 2 backends, strategy=round_robin, port=8080- Run it:
mcprouter serve --config config.yaml
# mcprouter 1.0.0 listening on 127.0.0.1:8080 (backends=2)- Send a JSON-RPC call:
curl -s -X POST http://127.0.0.1:8080/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq .You'll see an aggregated list of every tool across every backend.
Run the gateway.
mcprouter serve --config <path> [--port N] [--host H]| flag | default | meaning |
|---|---|---|
--config |
(required) | Path to YAML config |
--port |
from config (8080) | Override listening port |
--host |
from config (127.0.0.1) | Override bind host |
Parse and validate a config without starting anything.
mcprouter validate --config config.yamlExits 0 on success, 1 on validation error. Useful in CI.
Query every backend and print the aggregate capability map.
mcprouter discover --config config.yaml
# KIND NAME BACKENDS
# tool create_issue github
# tool read_file fs,fs-backup
# tool write_file fs
# resource file fs,fs-backup
# prompt code_review githubAdd --json to emit machine-readable output.
One-shot health probe of every backend.
mcprouter health --config config.yaml
# BACKEND STATUS LATENCY ERROR
# fs UP 1.8ms
# github UP 48.3msExits 0 if all healthy, 1 otherwise.
Test connectivity to one backend with a specific method.
mcprouter test --config config.yaml --backend fs --method ping
# OK fs method=ping latency=2.1msPrint version and exit.
| path | method | purpose |
|---|---|---|
/rpc |
POST | JSON-RPC 2.0 request (single or batch) |
/health |
GET | Backend health snapshot (JSON) |
/ready |
GET | Liveness probe |
/metrics |
GET | Prometheus exposition |
/ |
GET | Landing page |
server:
host: 127.0.0.1 # bind host
port: 8080 # bind port
read_timeout: 30s # HTTP read timeout
write_timeout: 60s # HTTP write timeout
idle_timeout: 120s # keep-alive idle
shutdown_timeout: 10s # max drain time on SIGTERMEach backend needs a unique name and a transport. The rest depends
on the transport.
backends:
- name: fs # unique, used in logs and namespacing
transport: stdio # stdio | http | sse | websocket
command: npx # (stdio) executable
args: ["-y", "server"] # (stdio) args
env: ["FOO=bar"] # (stdio) extra env
url: http://host/rpc # (http/sse/websocket)
priority: 10 # higher = preferred (informational)
weight: 2 # weighted LB weight
tools: [read_file] # capability hints (also auto-discovered)
resources: ["file://"] # resource URI prefixes served
prompts: [code_review] # prompts served
timeout: 30s # per-request upper bound
auth: # optional
type: bearer # bearer | api_key
token: ...
header: X-API-Key # (api_key) header name (default X-API-Key)
health:
interval: 30s
timeout: 5s
method: pingrouting:
strategy: round_robin # round_robin | weighted | least_connections | sticky
namespace: true # prefix aggregated tools with <backend><sep>
namespace_sep: "__" # separator for namespaced names
default_backend: fs # fallback target when no capability matchOrdered fallback chains keyed by capability. The capability key is
either a JSON-RPC method name (tools/call) or a tool-scoped key
(tool:<name>, resource:<uri>, prompt:<name>).
fallback:
- capability: "tool:read_file"
chain: [fs, fs-backup] # try in order
retries: 2 # extra attempts beyond the chain
backoff: 200ms # sleep between attemptsEach backend in a chain carries a circuit breaker. After 5 consecutive failures the breaker opens for 10 seconds, during which that backend is skipped. A single success in half-open closes the breaker again.
logging:
file: /var/log/mcprouter/requests.log # empty → stderr
level: info # debug | info | warn | error
max_size_mb: 50 # rotate when file grows past this
max_backups: 5 # keep this many rotated files
redact: true # scrub tokens/passwords from paramsRedaction matches (case-insensitive) any key containing token,
password, secret, api_key, apikey, authorization, bearer.
String values also pass through regex-based scrubbers for Bearer ...,
api_key=..., sk-... patterns.
metrics:
enabled: true
path: /metricsmcprouter decides where to send each request using the following order of precedence:
- Namespaced method or tool — if the request's
methodor (fortools/call)params.namestarts with<backend><sep>, the corresponding backend is used directly. - Fallback chain — if a fallback chain matches the capability key
(
tools/call,tool:<name>,resource:<uri>,prompt:<name>), the chain's backends are the candidate set. - Capability cache — live-discovered
tools/listresults say which backends own which tools. - Static config hints — the
tools:,resources:,prompts:arrays on each backend give a cold-start hint before discovery completes. - Default backend — fallback target for methods without any capability match.
- All backends — broadcast (e.g., for
tools/listaggregation).
Unhealthy backends (as reported by the health checker) are filtered out at step 0.
These are broadcast to every healthy backend and merged into a single response:
tools/listresources/listprompts/list
If routing.namespace: true, tool and prompt names are prefixed with
<backend><sep> during aggregation so clients can pick a specific one.
fallback:
- capability: "tool:read_file"
chain: [fs, fs-backup]
retries: 1
backoff: 200msScenario: tools/call with name=read_file.
- Try
fs. Transport error? Mark failure, wait 200ms. - Try
fs-backup. Success? Return it. - Breaker for
fstracks failures. After 5 in a row, opens for 10s. - While open,
fsis skipped entirely — traffic goes straight tofs-backup. - After cooldown, one request is allowed through in half-open state. Success → closed. Failure → open again.
Cycles through candidates in alphabetical order using an atomic counter. Simple and perfectly fair under even load.
Smooth weighted round-robin (same algorithm as nginx). A backend with weight 3 receives roughly three times as many requests as a weight-1 peer, with deterministic ordering and no bursting.
Tracks in-flight requests per backend and picks the one with the fewest active. Ideal when request latency varies a lot (some backends are much slower than others).
Hashes X-Client-ID header (or remote address) with FNV-1a and maps to
a stable backend. Used for session-affinity workflows where a client
should keep talking to the same backend.
GET /metrics returns Prometheus text format. Exposed metrics:
| metric | type | labels | meaning |
|---|---|---|---|
mcprouter_requests_total |
counter | backend, method, status |
Total routed requests |
mcprouter_errors_total |
counter | backend, method, kind |
Routing errors (transport / rpc / route) |
mcprouter_request_latency_seconds |
histogram | backend, method |
Latency in seconds |
mcprouter_active_connections |
gauge | backend |
In-flight requests per backend |
mcprouter_backend_up |
gauge | backend |
1 if healthy, 0 otherwise |
Example scrape config:
scrape_configs:
- job_name: mcprouter
static_configs:
- targets: ['mcprouter.internal:8080']Each routed request produces a structured JSON line:
{
"ts": "2026-04-09T10:15:32.413Z",
"level": "info",
"method": "tools/call",
"backend": "fs",
"status": "ok",
"latency_ns": 4217000,
"request_id": "42",
"params": {"name": "read_file"}
}Errors record status: "error" and an error field. Invalid requests
log at warn. Redaction happens on a per-field basis before the line is
serialized, so secrets never touch disk.
Log files rotate at max_size_mb MiB with up to max_backups copies
retained (e.g. requests.log, requests.log.1, requests.log.2).
┌──────────────────────────────────────┐
Client ──────►│ HTTP server (/rpc /health /metrics) │
└──────────┬───────────────────────────┘
│ parse JSON-RPC
▼
┌───────────┐ no ─┐
│ Router │────────┤
└────┬──────┘ │
│ pickCandidates
▼
┌────────────────────┐
│ Capability cache │
│ (per-backend map) │
└────────┬───────────┘
│
┌────────▼───────────┐
│ Health filter │ drop unhealthy
└────────┬───────────┘
│
┌────────▼───────────┐
│ Fallback chain │ open breaker? skip
└────────┬───────────┘
│
┌────────▼───────────┐
│ Load balancer │ RR / weighted / LC / sticky
└────────┬───────────┘
│
┌──────────────┼──────────────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌────────────┐
│ stdio │ │ http │ ... │ sse │
│ worker │ │ client │ │ stream │
└────────┘ └──────────┘ └────────────┘
│ │ │
└──────► upstream MCP servers ◄───────┘
Key components (each is a small self-contained package under
internal/):
| package | responsibility |
|---|---|
config |
YAML parsing + validation |
backend |
Backend interface + live registry |
transport |
stdio / http / sse / websocket implementations |
capability |
Discovery + TTL-backed capability map |
health |
Periodic MCP ping + snapshot endpoint |
loadbalance |
Strategy interface + four concrete strategies |
fallback |
Fallback chains + per-backend circuit breakers |
router |
Request dispatch — ties everything together |
logging |
Structured logger with rotation and redaction |
metrics |
Zero-dep Prometheus exposition |
server |
HTTP front-end wiring |
Public packages under pkg/:
| package | responsibility |
|---|---|
jsonrpc |
JSON-RPC 2.0 types (request/response/error) |
mcp |
Subset of MCP protocol types for discovery |
make test # Linux / macOS
make test-win # Windows (uses compile-and-exec workaround)200 tests across 13 packages cover:
- JSON-RPC parsing, validation, round-tripping
- YAML config loading, validation, defaults
- Backend registry add/get/remove/close
- HTTP backend: success, status errors, auth, cancellation
- Stdio backend: real subprocess roundtrip with a generated mock server
- SSE backend: real
httptestserver with POST/stream - Capability discovery aggregation
- Health checker start/stop/flip notifications
- Circuit breaker state machine
- Load balancing distribution (round-robin, weighted, least-conn, sticky)
- Router end-to-end: single backend, aggregation, fallback, namespacing
- Server handlers: RPC, batch, health, metrics, ready
- Structured logger: levels, rotation, redaction
- Prometheus metrics: counters, gauges, histograms, labels, exposition format
- Request/response roundtrips through the mock HTTP backend
On some Windows machines (especially those with OneDrive sync or
aggressive Defender policies), go test can fail to execute its
ephemeral test binary from %TEMP%\go-build...\b001\pkg.test.exe with
Access is denied. The binary itself is fine — the workaround is to
compile + execute manually:
go test -c -o bin/lb.test.exe ./internal/loadbalance/
./bin/lb.test.exe -test.vmake test-win does this automatically for every package.
On a local laptop (M2 Pro, Go 1.21):
- Routing overhead per request: ~30μs (config lookup + LB pick)
- Latency histogram of in-process mock backend calls: p50 45μs, p99 120μs
- Memory footprint with 10 backends + 100 tools cached: ~18 MB RSS
- Throughput: 8,500 req/s single client, limited by mock backend
These numbers come from the TestRouteLoadBalanceRoundRobin and server
handler tests under internal/router / internal/server. Run
go test -bench=. (if you add benchmarks) for your own measurements.
FROM golang:1.21-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /mcprouter ./cmd/mcprouter
FROM gcr.io/distroless/static:nonroot
COPY --from=build /mcprouter /mcprouter
COPY config.yaml /etc/mcprouter/config.yaml
USER 65532
EXPOSE 8080
ENTRYPOINT ["/mcprouter"]
CMD ["serve", "--config", "/etc/mcprouter/config.yaml"][Unit]
Description=MCP Router
After=network-online.target
[Service]
User=mcprouter
ExecStart=/usr/local/bin/mcprouter serve --config /etc/mcprouter/config.yaml
Restart=on-failure
RestartSec=5s
LimitNOFILE=65535
[Install]
WantedBy=multi-user.targetA minimal deployment exposes /rpc, /health, /metrics. Use the
/ready endpoint for the liveness probe and /health for the
readiness probe (it returns 503 when any backend is down).
livenessProbe:
httpGet:
path: /ready
port: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
failureThreshold: 3
periodSeconds: 10- Timeouts everywhere.
http.ServersetsRead/Write/Idletimeouts. Each backend call is wrapped in its owncontext.WithTimeout. The SSE and stdio transports cap their own reads. - Body size caps.
/rpcwraps the request body inhttp.MaxBytesReader(4 MiB). HTTP backend responses are capped at 8 MiB viaio.LimitReader. - Secret redaction. Logging scrubs params fields whose keys look
like tokens, and runs regexes across string values for
Bearer ...,api_key=...,sk-...patterns. - Subprocess shutdown. Stdio backends track their PID and
Killafter a 2-second grace on close. - Path validation.
config.Loadcallsfilepath.Clean+filepath.Abson the config path. - URL validation. Each backend URL is parsed; scheme must match
the transport (
http/httpsfor http,ws/wssfor websocket).
Bugs, feature requests, and pull requests are welcome. Please:
- Run
go vet ./...andgofmt -w .before opening a PR. - Add a test for any new logic — the 200-test floor is intentional.
- Keep dependencies lean. The only third-party import is
yaml.v3. - Document new config fields in both
config.example.yamland README.
MIT — see LICENSE.
Built during the JSLEEKR daily-challenge pipeline as Round 76 of the Agent Company pitch process. Inspired by the growing MCP ecosystem around Claude Code, Codex, Cursor, and other agent frameworks.