MCP Gateway
mcpstead
MCP Gateway
One downstream /mcp endpoint that fronts many upstream MCP servers. Persistent upstream connections with reconnect, tool registry with qualified names, JSON or SSE responses based on client Accept, per-upstream auth, Prometheus metrics.
Install
# npm (macOS, Linux, WSL)
npm i -g @ahkohd/mcpstead
# homebrew (macOS, Linux)
brew install ahkohd/tap/mcpstead
# cargo
cargo install mcpstead --locked --force
# verify
mcpstead --version
Quick start
# 1. write a config
mkdir -p ~/.config/mcpstead
cat > ~/.config/mcpstead/config.yaml <<'EOF'
host: 0.0.0.0
port: 8766
mcp:
auth:
mode: none
servers:
- name: example
url: http://127.0.0.1:3000/mcp
protocol: streamable
auth: none
EOF
# 2. run
mcpstead --config ~/.config/mcpstead/config.yaml
Then point any MCP client at http://127.0.0.1:8766/mcp.
Docker
docker build -t mcpstead .
docker run --rm \
-p 8766:8766 \
-v "$PWD/config:/etc/mcpstead:ro" \
mcpstead
The Dockerfile installs from crates.io.
HTTP API
| Method | Path | Purpose |
|---|---|---|
| POST | /mcp | MCP over HTTP JSON-RPC |
| GET | /mcp | returns 405 |
| DELETE | /mcp | terminate downstream session |
| GET | /health | upstream status, tool counts, last-seen, reconnects |
| GET | /metrics | Prometheus text format |
| POST | /-/reload | reload config without restart |
MCP client setup
Local, no-auth:
mcpstead:
url: http://127.0.0.1:8766/mcp
tools:
resources: false
prompts: false
Bearer auth:
mcpstead:
url: http://127.0.0.1:8766/mcp
headers:
Authorization: Bearer ${MCPSTEAD_BEARER_TOKEN}
tools:
resources: false
prompts: false
Tools appear with qualified names - <server>__<tool> - so multiple upstreams can ship overlapping tool names without collision.
Auth
Downstream (clients to mcpstead)
Default is no auth:
mcp:
auth:
mode: none
Bearer auth:
mcp:
auth:
mode: bearer
Token comes from env:
export MCPSTEAD_BEARER_TOKEN='replace-with-strong-secret'
mcpstead --config ~/.config/mcpstead/config.yaml
Clients send Authorization: Bearer <token>. Missing or wrong token returns 401. mcp.auth.bearer_token in config is rejected at startup.
/health and /metrics stay open regardless of auth mode - for monitoring without exposing the token.
Upstream (mcpstead to MCP servers)
Per-server in the servers list. Three modes:
servers:
- name: local
url: http://127.0.0.1:3000/mcp
auth: none
- name: workflow
url: https://workflow.example/mcp-server/http
auth:
type: bearer
token_env: WORKFLOW_TOKEN
- name: custom
url: https://api.example/mcp
headers:
X-API-Key: '${EXAMPLE_KEY}'
token_env resolves the named env var at startup and on config reload.
Config
Set the config path with --config <path> or the MCPSTEAD_CONFIG env var.
host: 0.0.0.0
port: 8766
mcp:
auth:
mode: none # none | bearer
session:
idle_ttl_seconds: 3600
gc_interval_seconds: 60
shutdown_grace_seconds: 5
servers:
- name: local
url: http://127.0.0.1:3000/mcp
protocol: streamable # streamable | sse | auto
required: false # if true, gateway won't start without this upstream
auth: none
reconnect:
max_attempts: 0 # 0 = infinite
backoff_base_ms: 1000
backoff_max_ms: 30000
tools:
ttl_seconds: 300
tls_skip_verify: false
quirks:
normalize_sse_events: true
inject_accept_header: 'application/json, text/event-stream'
metrics:
enabled: true
logging:
level: info
Hot reload
mcpstead reloads its config without restart on:
- SIGHUP (
systemctl reload mcpsteadorkill -HUP <pid>) POST /-/reload(gated by bearer auth in bearer mode)
Hot-reloadable:
- upstream list
- per-upstream auth, headers, quirks, reconnect, tools, URL, protocol, and TLS settings
mcp.auth.modeandMCPSTEAD_BEARER_TOKENmetrics.enabled
Restart required:
hostportlogging.levelmcp.session.*
Reload is best-effort. Bad config is rejected and ignored; the running config stays in place. Check logs and mcpstead_config_reloads_total{result="error"} for failures.
MCP session config keys
mcp.session.idle_ttl_seconds- evict inactive sessions after this many seconds (default3600)mcp.session.gc_interval_seconds- idle-session GC wake interval (default60)mcp.session.shutdown_grace_seconds- max shutdown accounting sweep time (default5)
Per-upstream config keys
name- required, used as tool prefixurl- required, MCP endpointprotocol-streamable | sse | auto(defaultauto)required- block startup if upstream fails to initialize (defaultfalse)auth-none,bearer(withtoken_env), orheadersmapreconnect.max_attempts-0= infinite (default)reconnect.backoff_base_ms/backoff_max_ms- exponential backoff boundstools.ttl_seconds- refresh cachedtools/listafter this intervaltls_skip_verify- disable TLS cert checks for this upstream (defaultfalse, use only for trusted local nets)quirks.normalize_sse_events- stripevent:lines from upstream SSE responsesquirks.inject_accept_header- override the Accept header sent upstream
Observability
Metrics
/metrics exposes Prometheus-format counters, gauges, and histograms. Label cardinality assumes a small bounded set of upstreams and tools; tool-call series are keyed by (server, tool).
mcpstead_build_info{version="...",rust_version="...",git_sha="..."}
mcpstead_start_time_seconds
mcpstead_uptime_seconds
mcpstead_process_resident_memory_bytes
mcpstead_process_virtual_memory_bytes
mcpstead_process_cpu_seconds_total
mcpstead_process_open_fds
mcpstead_process_max_fds
mcpstead_process_threads
mcpstead_upstream_connected{server="..."}
mcpstead_upstream_tools_count{server="..."}
mcpstead_upstream_reconnects_total{server="..."}
mcpstead_upstream_last_seen_seconds{server="..."}
mcpstead_upstream_initialize_total{server="...",result="success|error"}
mcpstead_upstream_initialize_duration_seconds_bucket{server="...",le="..."}
mcpstead_upstream_health_checks_total{server="...",result="success|failure"}
mcpstead_upstream_reconnect_attempts_total{server="...",result="success|error"}
mcpstead_upstream_backoff_seconds_total{server="..."}
mcpstead_upstream_in_backoff{server="..."}
mcpstead_upstream_current_backoff_seconds{server="..."}
mcpstead_upstream_session_resets_total{server="...",reason="unknown_session|expired|terminated"}
mcpstead_upstream_tools_refresh_total{server="...",result="success|error"}
mcpstead_upstream_tools_refresh_duration_seconds_bucket{server="...",le="..."}
mcpstead_upstream_tools_last_refresh_timestamp_seconds{server="..."}
mcpstead_upstream_bytes_total{server="...",direction="sent|received"}
mcpstead_downstream_sessions_active
mcpstead_downstream_sessions_total
mcpstead_downstream_session_duration_seconds_bucket{le="..."}
mcpstead_downstream_session_terminations_total{reason="..."}
mcpstead_mcp_requests_total{method="...",result="success|error"}
mcpstead_mcp_request_duration_seconds_bucket{method="...",le="..."}
mcpstead_mcp_auth_attempts_total{result="success|failure"}
mcpstead_mcp_auth_failures_total{reason="..."}
mcpstead_config_reloads_total{result="success|error"}
mcpstead_config_last_reload_timestamp_seconds
mcpstead_tool_calls_total{server="...",tool="..."}
mcpstead_tool_call_errors_total{server="...",tool="...",reason="..."}
mcpstead_tool_call_duration_seconds_bucket{server="...",tool="...",le="..."}
Scrape config
- job_name: mcpstead
metrics_path: /metrics
static_configs:
- targets: ['mcpstead:8766']
Health check
curl http://127.0.0.1:8766/health
Returns JSON: per-upstream connection state, tool counts, last successful contact, reconnect counts, last error.
Troubleshooting
- All tools list empty - at least one upstream failed to
initialize. Check/healthfor per-server status; check upstream URL, auth, and reachability. - Sporadic
SSE parse failed- upstream sends an SSE dialect mcpstead doesn't recognize. Tryquirks.normalize_sse_events: truefor that server, or setquirks.inject_accept_header: 'application/json'to force JSON. tools/callreturns auth error - upstream rejected the bearer token. Confirmtoken_envresolves to the right value at startup; check upstream's expected header name.- TLS handshake errors against a self-signed upstream - set
tls_skip_verify: trueon that server. Only safe on trusted local networks. - 401 from
/mcpwith mode bearer - client missing or sending wrongAuthorization: Bearer <token>. VerifyMCPSTEAD_BEARER_TOKENmatches what the client sends. - Upstream goes red in
/healthrepeatedly - checkmcpstead_upstream_reconnects_totalandmcpstead_upstream_last_seen_seconds. Tunereconnect.backoff_max_msif the upstream needs longer recovery.