A Python-based MCP (Model Context Protocol) proxy/gateway that consolidates multiple backend MCP servers behind a single endpoint. Built for token optimization, dynamic management, and production reliability.
| Feature | Description |
|---|---|
| Stateless MCP (2026-07-28) | Full support for the stateless protocol — no handshake, no sessions, load-balancer friendly |
| Dual Protocol | Serves both 2026-07-28 (stateless) and legacy (2025-11-25/2024-11-05) clients on the same endpoint |
| Meta-Tool Mode | Expose only 3 meta-tools instead of 43+. ~90% token savings with full capability. |
| Lazy Schema Loading | Tool schemas fetched only when called, not at discovery. 60-80% token savings. |
| Multi-Protocol Backends | HTTP (Streamable), SSE, and Stdio backends supported — auto-detects protocol era |
| Dynamic Management | Add/remove backends at runtime via API or Web UI |
| Web Dashboard | Built-in UI for CRUD, monitoring, search, and backup/restore |
| Tool Filtering | Include/exclude lists per backend to control what's exposed |
| Persistent State | API-added backends survive restarts via state.json |
| Auto-Reconnect | Exponential backoff retries for disconnected backends |
| Request Logging | Per-call metrics with latency, success/error tracking |
| Authentication | Gateway API key + per-backend auth headers |
| Live Status | WebSocket feed for real-time backend status changes |
| Backup/Restore | Export and import full gateway state |
| Cache Hints | Responds with ttlMs/cacheScope on tools/list for smart client caching |
┌──────────────────┐ ┌───────────────────────────────────┐
│ AI Harness │ │ MCP Gateway │
│ (Kiro/Claude/etc)│────────▶│ :8080/mcp (MCP protocol) │
└──────────────────┘ POST │ :8080/ (Web UI) │
│ :8080/admin (REST API) │
│ :8080/ws (WebSocket status) │
└──────────────┬────────────────────┘
│ routes tool calls
┌─────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ HTTP srv │ │ SSE srv │ │ stdio │
│ :3001 │ │ :3000 │ │ (npx) │
└──────────┘ └──────────┘ └──────────┘
cd mcp_gateway
cp config.example.yaml config.yaml # Edit with your backends
docker compose up --buildcd mcp_gateway
pip install -r requirements.txt
python -m src -c config.yamlReplace all individual server entries with one gateway connection:
{
"mcpServers": {
"gateway": {
"url": "http://localhost:8080/mcp"
}
}
}Open http://localhost:8080 for the management dashboard.
The built-in dashboard provides:
- Dashboard — Stats overview, backend cards with live status dots
- Backends — Add/edit/remove backends, backup/restore config
- Tools — Searchable list of all registered tools grouped by backend
- Activity Log — Real-time call history with latency and error tracking
- Settings — View gateway configuration status
All changes persist automatically to state.json.
gateway:
host: "0.0.0.0"
port: 8080
log_level: "info"
tool_cache_ttl: 300
lazy_schema_loading: true
state_file: "state.json"
auto_reconnect: true
reconnect_interval: 30
api_key: "${MCP_GATEWAY_API_KEY}" # Optional
mode: "meta" # "proxy" (all tools) or "meta" (3 meta-tools, ~92% token savings)
backends:
- name: my-server
url: "http://localhost:3000/mcp"
transport: "http" # http, sse, or stdio
description: "My MCP server"
lazy: true
headers: # Auth for this backend
Authorization: "Bearer ${MY_TOKEN}"
tools: # Tool filtering
include: ["tool_a", "tool_b"]Values in config.yaml support ${VAR} and ${VAR:-default} syntax.
| Variable | Default | Description |
|---|---|---|
MCP_GATEWAY_CONFIG |
./config.yaml |
Config file path |
MCP_GATEWAY_HOST |
0.0.0.0 |
Bind address |
MCP_GATEWAY_PORT |
8080 |
Listen port |
MCP_GATEWAY_LOG_LEVEL |
info |
Log verbosity |
MCP_GATEWAY_API_KEY |
(none) | Required API key for access |
| Transport | Config | Auto-Detection | Protocol Eras |
|---|---|---|---|
| HTTP (Streamable) | url: "http://.../mcp" |
Default for URL-based | 2026-07-28 + legacy (auto-detected) |
| SSE | url: "http://.../sse" |
Auto if URL contains /sse |
Legacy only |
| Stdio | command: "npx" + args: [...] |
Auto if command is set |
Legacy only |
Override auto-detection with explicit transport: "http", "sse", or "stdio".
The gateway auto-detects each backend's protocol era by trying server/discover first (2026-07-28), then falling back to the initialize handshake. Override this with protocol_version:
backends:
- name: modern-server
url: "http://localhost:3001/mcp"
protocol_version: "2026-07-28" # Force stateless (no fallback)
- name: legacy-server
url: "http://localhost:3002/mcp"
protocol_version: "2025-11-25" # Force legacy (skip probe)
- name: auto-server
url: "http://localhost:3003/mcp"
protocol_version: "auto" # Default: try modern, fall back to legacyThe gateway serves both protocol eras on the same /mcp endpoint:
- 2026-07-28 clients send
MCP-Protocol-Version: 2026-07-28header → stateless response withserver/discoversupport, no session - Legacy clients send an
initializerequest → session-based response as before
No configuration needed — era detection is per-request.
Control which tools each backend exposes:
backends:
- name: fleet
url: "http://localhost:3001/mcp"
tools:
include: ["get_routers", "get_groups"] # Only these
# OR
exclude: ["reboot_router", "reboot_group"] # All except theseConfigurable via the Web UI when adding/editing a backend.
When api_key is set in config, all requests (except /health, /, /static) require the key:
# Via header
curl -H "X-API-Key: your-key" http://localhost:8080/admin/backends
# Via Authorization header
curl -H "Authorization: Bearer your-key" http://localhost:8080/mcp
# Via query param
curl http://localhost:8080/admin/backends?api_key=your-keyEach backend can have custom headers (typically for authentication):
backends:
- name: protected-server
url: "http://api.example.com/mcp"
headers:
Authorization: "Bearer ${API_TOKEN}"
X-Custom-Header: "value"When enabled, the gateway retries disconnected backends with exponential backoff:
- Base interval:
reconnect_interval(default 30s) - Backoff: 30s → 60s → 120s → 240s → 300s (capped at 5 min)
- Resets on successful reconnection
- Tools are re-discovered after reconnection
Backends added via the Admin API or Web UI are saved to state.json. On restart:
- Backends from
config.yamlare loaded first - Additional backends from
state.jsonare loaded (no duplicates)
- Click Backup to download a JSON snapshot
- Click Restore to upload and apply a previous snapshot
# Download backup
curl http://localhost:8080/admin/backup > backup.json
# Restore from backup (replaces all current backends)
curl -X POST http://localhost:8080/admin/restore \
-H "Content-Type: application/json" \
-d @backup.json| Endpoint | Method | Description |
|---|---|---|
/mcp |
POST | MCP JSON-RPC protocol endpoint |
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | Web management dashboard |
/health |
GET | Health check (no auth required) |
/ws/status |
WebSocket | Live backend status events |
| Endpoint | Method | Description |
|---|---|---|
/admin/backends |
GET | List all backends with metrics |
/admin/backends |
POST | Add a new backend |
/admin/backends/{name} |
DELETE | Remove a backend |
/admin/refresh |
POST | Force re-discovery of all tools |
/admin/tools |
GET | List all registered tools |
/admin/metrics |
GET | Aggregated call metrics |
/admin/logs |
GET | Recent tool call log |
/admin/backup |
GET | Download state backup |
/admin/restore |
POST | Restore state from backup |
{
"name": "my-server",
"url": "http://localhost:3000/mcp",
"transport": "http",
"description": "My server description",
"lazy": true,
"headers": {
"Authorization": "Bearer token123"
},
"tools": {
"include": ["tool_a", "tool_b"]
}
}- On connection, the gateway calls
tools/liston each backend - It caches only
name+description— noinputSchema - Your harness sees a minimal tool listing (huge token savings)
- When a tool is called, the gateway routes it directly — schemas aren't needed for routing
Meta-tool mode is the most aggressive token optimization. Instead of exposing all upstream tools to the LLM (even with lazy loading, each tool still consumes ~150 tokens of schema in the system prompt), meta mode exposes only 3 generic tools:
| Meta-Tool | Purpose |
|---|---|
mcp_gateway_discover |
Find tools by backend or keyword — returns names + descriptions |
mcp_gateway_describe |
Get full parameter schema for a specific tool (on-demand) |
mcp_gateway_execute |
Call any upstream tool by name with arguments |
| Mode | Tools in Context | Approx. Tokens | Savings |
|---|---|---|---|
| No gateway (4 servers) | 43 full schemas | ~6,500 | — |
| Proxy mode | 43 (lazy, no schemas) | ~4,500 | ~30% |
| Meta mode | 3 meta-tool schemas | ~500 | ~92% |
LLM Gateway Upstream Servers
│ │ │
│─ discover(query="radio")──│ │
│ │── (searches internal registry)│
│◀─ get_radios, manage_radio│ │
│ │ │
│─ describe("get_radios") ──│ │
│ │── tools/list ───────────────▶│
│◀─ {radio_id, network...} │◀─────────────────────────────│
│ │ │
│─ execute("get_radios", │ │
│ {network: "..."}) ─│── tools/call("get_radios")──▶│
│◀─ [radio results] │◀─────────────────────────────│
gateway:
mode: "meta" # "proxy" (default) or "meta"The gateway still connects to all backends and discovers all tools internally — it just doesn't expose them to the client. Switch back to mode: "proxy" at any time without losing functionality.
mcp_gateway/
├── src/
│ ├── __init__.py
│ ├── __main__.py # CLI entrypoint
│ ├── config.py # YAML + env var config, ToolFilter, BackendServer
│ ├── meta_tools.py # Meta-tool definitions + dispatcher (discover/describe/execute)
│ ├── transport.py # HTTP, SSE, and Stdio transports with auth headers
│ ├── registry.py # Tool registry, metrics, auto-reconnect, WebSocket events
│ ├── persistence.py # State file management, backup/restore
│ └── server.py # MCP + Admin API + Auth middleware + WebSocket + Web UI
├── static/
│ ├── index.html # Web dashboard
│ ├── css/style.css # UI styles
│ ├── js/app.js # Frontend logic (CRUD, search, WS, backup)
│ └── libs/ # jQuery, Font Awesome (vendored)
├── config.yaml # Active config
├── config.example.yaml # Full reference config
├── .env.example # Environment variable reference
├── Dockerfile
├── docker-compose.yaml
└── requirements.txt
cp config.example.yaml config.yaml
cp .env.example .env
docker compose up --buildThe container uses host.docker.internal to reach services on the host network. Mount config.yaml as a volume for editing without rebuild.
