A lightweight, self-hosted MCP (Model Context Protocol) gateway that aggregates multiple MCP servers into a single endpoint.
- Aggregation: Combine tools from multiple MCP servers into one endpoint
- Namespacing: Tools are automatically prefixed with backend name (e.g.,
homeassistant_turn_on) - Multiple transports: SSE, HTTP, and stdio backends supported
- Multi-token auth: Named API tokens with fine-grained permissions (allowed tools/backends)
- Tool groups: Expose subsets of tools via different path prefixes
- Hot reload: Config file watching with automatic backend updates
- Prometheus metrics: Built-in metrics endpoint for monitoring
- Resource/Prompt proxying: Forward MCP resources and prompts from backends
- Auto-reconnect: Exponential backoff reconnection with circuit breaker
- Health checks: Periodic backend health monitoring
- Actor-based: Built on kameo for robust concurrency
┌─────────────────────────────────────────────────────┐
│ MCP Gateway │
│ │
│ ┌──────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ HTTP Server │ │ Gateway │ │ Registry │ │
│ │ (axum) │──│ Actor │──│ Actor │ │
│ │ /mcp │ │ │ │ │ │
│ └──────────────┘ └─────────────┘ └────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ ┌──────▼─────┐ ┌────────▼─────┐ ┌────────▼────┐ │
│ │ Backend │ │ Backend │ │ Backend │ │
│ │ Actor │ │ Actor │ │ Actor │ │
│ │ (HA) │ │ (Jellyfin) │ │ (Proxmox) │ │
│ └──────┬─────┘ └───────┬──────┘ └───────┬─────┘ │
└─────────┼────────────────┼─────────────────┼────────┘
│ │ │
▼ ▼ ▼
MCP Server 1 MCP Server 2 MCP Server 3
- Configure your backends in
config.toml:
[gateway]
bind = "0.0.0.0:8080"
[[backends]]
name = "homeassistant"
url = "http://localhost:3001/mcp/sse"
transport = "sse"
enabled = true
[[backends]]
name = "jellyfin"
url = "http://localhost:3002/mcp"
transport = "http"
enabled = true- Run the gateway:
cargo run -- config.toml
# or
MCP_GATEWAY_CONFIG=config.toml cargo run- Connect your agent to the gateway:
{
"mcpServers": {
"homelab": {
"url": "http://localhost:8080/mcp",
"transport": "http"
}
}
}Prebuilt images are published to ghcr.io/mozart409/axon-gateway. Example
Compose files live in example/:
compose.yml— runs the published image (ghcr.io/mozart409/axon-gateway:latest)compose.local.yml— builds the image from this repo
# from example/, with a config.toml alongside it
podman compose -f compose.yml up -d # or: docker composeMount your config read-only at /app/config.toml and pass it as the command
argument (both example files already do this).
Healthcheck note: the runtime image is the minimal Chainguard
wolfi-base, which ships no HTTP client — nocurl, nowget, and its busybox has nowgetapplet. The Compose healthchecks probe/healthwithwget, so the Dockerfile installs the tiny Wolfiwgetpackage (apk add --no-cache wget) specifically to make that check pass. Drop that line only if you also remove thehealthcheck:blocks from the Compose files.
| Endpoint | Method | Description |
|---|---|---|
/mcp |
POST | JSON-RPC endpoint (Streamable HTTP transport) |
/mcp/sse |
GET | SSE endpoint for streaming transport |
/mcp/group/{group_name} |
POST | JSON-RPC endpoint filtered by tool group |
/ |
GET | Landing page |
/ui |
GET | SSR dashboard page |
/ui/backends/{name} |
GET | Backend detail page |
/ui/events |
GET | Dashboard SSE stream (text/event-stream) |
/ui/partials/backends |
GET | SSR backend panel fragment |
/ui/actions/backends/{name}/reconnect |
POST | HTMX action: reconnect backend |
/ui/actions/backends/{name}/disable |
POST | HTMX action: disable backend |
/ui/actions/backends/{name}/enable |
POST | HTMX action: enable backend |
/ui/actions/reload |
POST | HTMX action: reload config |
/build |
GET | Build/runtime metadata (version, pid, startup time) |
/health |
GET | Health check |
/status |
GET | Gateway status (backend list, counts) |
/status/detailed |
GET | Detailed status with tool/resource/prompt lists |
/status/groups |
GET | List configured tool groups |
/metrics |
GET | Prometheus metrics endpoint |
The web UI is SSR-first and keeps all rendering server-side:
- Server framework:
axum - Templating:
maud - Live updates:
htmx+ SSE extension (/ui/events) - Styling: Tailwind CSS v4 (compiled
styles/output.cssserved by axum)
Runtime update flow:
- Client loads
/ui(fully rendered HTML works without JavaScript). - If JS is available, HTMX opens SSE connection to
/ui/events. - Backend status updates and admin actions publish typed UI events.
- SSE events carry HTML fragments (
backend_status_changed,flash) swapped into the DOM.
UI routes use the same bearer auth logic as MCP/admin routes:
- If no auth is configured,
/uiand/ui/eventsare open. - If auth is configured, callers must send
Authorization: Bearer <token>. - HTMX action routes under
/ui/actions/*require auth and return HTML fragments.
From the repository root:
# terminal 1: run gateway
bacon run-long
# terminal 2: rebuild Tailwind on change
bacon css-watchUseful checks:
curl http://127.0.0.1:8080/build
curl -I http://127.0.0.1:8080/styles/output.css/build helps detect stale processes (pid/start time/fingerprint mismatch).
- Backend filtering/search and grouped backend views
- Action audit log / live event feed panel
- Pagination and virtualized rows for large backend/tool lists
- More granular SSE topics per widget
- Integration snapshots for rendered fragments
When backend homeassistant provides tools turn_on and turn_off, the gateway exposes them as:
homeassistant_turn_onhomeassistant_turn_off
When your agent calls homeassistant_turn_on, the gateway:
- Looks up which backend owns the tool
- Strips the prefix to get
turn_on - Forwards the call to the Home Assistant MCP server
- Returns the result
Enable auth by setting auth_token in config:
[gateway]
bind = "0.0.0.0:8080"
auth_token = "your-secret-token"Clients must include Authorization: Bearer your-secret-token header.
For fine-grained access control, configure named tokens:
[gateway]
bind = "0.0.0.0:8080"
[[tokens]]
name = "admin"
token = "${ADMIN_TOKEN}"
allowed_backends = ["*"] # All backends
[[tokens]]
name = "readonly"
token = "${READONLY_TOKEN}"
allowed_tools = ["*_read", "*_list"] # Only read/list toolsToken permissions:
allowed_backends: List of backend names (supports*wildcard)allowed_tools: List of tool name patterns (supports*wildcard)
Requests are rate-limited per token using a fixed 1-minute window. There is
a single setting, rate_limit_per_minute, configurable at two levels:
[gateway]
bind = "0.0.0.0:8080"
rate_limit_per_minute = 100 # global default applied to every token
[[tokens]]
name = "admin"
token = "${ADMIN_TOKEN}"
rate_limit_per_minute = 1000 # per-token override
[[tokens]]
name = "readonly"
token = "${READONLY_TOKEN}"
# no value → falls back to the global default (100)Semantics:
- Default:
0— both the global and per-token values default to0, which means unlimited. Rate limiting is off until you set a non-zero value. - Resolution: a per-token
rate_limit_per_minute > 0overrides the global value; a per-token0(or omitted) falls back to the global default. - Window: fixed 1-minute window per token; the counter resets once a full minute has elapsed (not a sliding window or token bucket).
- Simple shared-token auth (
auth_token, no named[[tokens]]) is not rate-limited. Use named tokens to enforce limits. - Exceeding the limit returns an auth error (HTTP 429).
Expose subsets of tools via different path prefixes:
[[groups]]
name = "coding"
backends = ["vscode", "git"]
description = "Development tools only"
[[groups]]
name = "readonly"
tools = ["*_read", "*_list", "*_get"]
description = "Read-only operations"Access grouped tools at /mcp/group/{group_name} instead of /mcp.
Prometheus metrics are exposed at /metrics:
axon_tool_calls_total- Tool call counter by backend/toolaxon_tool_call_duration_seconds- Tool call latency histogramaxon_tool_call_errors_total- Tool call error counteraxon_backend_state- Backend connection state gaugeaxon_http_requests_total- HTTP request counteraxon_health_checks_total- Health check counteraxon_resource_reads_total- Resource read counteraxon_prompt_gets_total- Prompt get counter
You can reference environment variables in config.toml using ${VAR_NAME} placeholders.
Example:
[gateway]
bind = "0.0.0.0:8080"
auth_token = "${AXON_GATEWAY_TOKEN}"
[[tokens]]
name = "claude-agent"
token = "${CLAUDE_AGENT_TOKEN}"
[[backends]]
name = "secure-api"
url = "https://example.com/mcp"
transport = "http"
auth_token = "${SECURE_API_TOKEN}"
[[backends]]
name = "local-tool"
command = "/usr/local/bin/mcp-tool"
transport = "stdio"
env = { API_KEY = "${OPENAI_API_KEY}" }Resolution rules:
- Placeholders are resolved at config load time
- Missing variables are a hard error (startup/reload fails)
- Process environment variables take precedence over
.env - A
.envfile in the same directory asconfig.tomlis loaded automatically
Phase 1 (MVP) is complete:
- Real rmcp integration: Backend actors connect to real MCP servers
- Multiple transports: SSE, HTTP (via streamable HTTP), and stdio supported
- Tool aggregation: Tools fetched from backends and namespaced
- Tool forwarding:
tools/callforwarded to correct backend with real results
Completed:
- Reconnection logic: Automatic reconnect with exponential backoff (1s base, 5min max)
- Health checks: Periodic pings to detect dead backends
- Hot reload: Watch config file and update backends without restart
- Metrics: Prometheus metrics for tool calls, latency, errors
- Tool filtering: Support
allowed_toolsconfig per token/group - Resource/Prompt proxying: Forward MCP resources and prompts
- Multi-token auth: Named tokens with fine-grained permissions
- SSE streaming: MCP SSE transport support (
/mcp/sse)
To make it production-ready:
- OpenID/OAuth: More auth options beyond simple tokens
src/
├── main.rs # Entry point
├── config.rs # Config parsing
├── types.rs # Core types (Tool, JsonRpc, etc.)
├── error.rs # Error types (thiserror)
├── registry.rs # Registry actor (tool aggregation)
├── backend.rs # Backend actor (per-server connection via rmcp)
├── gateway.rs # Gateway actor (orchestration)
└── server.rs # HTTP/SSE server (axum)
MIT