MCP Gateway
A standalone CLI gateway that aggregates multiple MCP (Model Context Protocol) servers behind a single headless HTTP/SSE endpoint with Code Mode — reducing LLM input token usage by up to 92% when using multiple MCP servers. Headless gateway (v2.2.0): CLI-managed, no UI dependencies.
Features
- Multi-Server Aggregation — Connect to multiple MCP servers (
remote/local, OpenCode format) and expose them through a single endpoint - Code Mode — 4 meta-tools that let LLMs discover and use tools dynamically without loading all schemas upfront
- OAuth 2.0 Support — Built-in OAuth flow with dynamic client registration (RFC 7591) and token storage
- Hermetic Sandbox — Starlark-based sandbox for safe code execution
- MCP Protocol Compliant — Works with Claude Desktop, Cursor, and any MCP-compatible client
Installation
pip install mcp-gway
Or with mise:
mise install
uv sync --all-groups # installs dev group with pre-commit
Quick Start
OpenCode Format (Primary)
OpenCode schema — remote / local with transport auto-detection. This is the recommended path.
# Remote — auto-detects transport (streamable-http → sse → http)
mcp-gway add youtube --type remote --url https://api.example.com/mcp
# SSRF-guard: private/loopback/link-local hosts rejected (src/mcp_gway/models.py:115-163); localhost solo en tests.
# Remote with headers
mcp-gway add supabase --type remote --url https://mcp.supabase.com/mcp --header "Authorization=Bearer TOKEN"
# Remote with pre-registered OAuth
mcp-gway add supabase --type remote --url https://mcp.supabase.com/mcp --oauth-client-id ID --oauth-client-secret SECRET --oauth-scope "openid profile"
> **Shell-history warning:** no pases secretos reales en `--header` / `--oauth-client-secret` (quedan en `~/.bash_history` / `ps`). Prefiere `mcp-gway refresh <name> --auth` o variables de entorno efímeras.
# Remote with timeout and enable toggle
mcp-gway add api --type remote --url https://api.example.com/mcp --timeout 10000 --enabled
mcp-gway add api --type remote --url https://api.example.com/mcp --timeout 10000 --no-enabled
# Local
mcp-gway add filesystem --type local --command "npx -y @anthropic/mcp-filesystem"
mcp-gway add tools --type local --command "python -m my_mcp_server" --env MY_VAR=value --cwd /path/to/workdir
mcp-gway add tools --type local --command "npx -y my-mcp" --env KEY=VALUE --env OTHER=123 --cwd /srv/mcp/workdir
# List and serve (local-first)
mcp-gway list
mcp-gway serve --port 8080 # bindea 127.0.0.1 por defecto
mcp-gway serve --host 127.0.0.1 --port 8080
curl -s http://127.0.0.1:8080/health | jq
Server Types (only remote / local)
--type only accepts local|remote (cli.py:50). Legacy values http|stdio|sse|streamable-http are rejected by click, and --args / --docs-url do not exist. For local, --command is a single string (split via shlex).
Management — CLI-Only
Headless gateway: all server management (add/remove/list/inspect/refresh) is CLI-only.
One Gateway(registry, host) process serves /mcp, /health, /ready, /live and /metrics on the same Starlette app. Registry (servers/*.json + servers/*.pyi) is the single source of truth.
Local-First Security
# Default seguro — solo loopback
mcp-gway serve --port 8080 # bindea 127.0.0.1
# Exponer en 0.0.0.0 requiere opt-in explícito
MCP_GWAY_ALLOW_REMOTE=1 mcp-gway serve --host 0.0.0.0 --port 8080
# └─ log warning "server exposed on non-loopback host"
# Protege con firewall + auth reversa: nunca expongas 0.0.0.0 sin firewall/auth delante.
# Sin opt-in → error controlado
mcp-gway serve --host 0.0.0.0
# Error: binding to non-loopback host '0.0.0.0' requires MCP_GWAY_ALLOW_REMOTE=1
# exit 2
Observability — Logs + Metrics + Health (Approach C, v2.2.0)
Zero vendor lock-in: stdlib
jsonlogs (nostructlog), vendoredMetricsRegistry(noprometheus_client), correlation viaX-Request-ID+contextvars, health probes/health|/ready|/live+ Prometheus text/metrics. Local-first + masking***preserved;X-Warning: exposedsolo enGET /metrics→403.
Health & Metrics:
curl -s http://127.0.0.1:8080/health | jq
# {"status":"ok","version":"2.2.0","checks":{"registry":"ok","routes":"ok"},"uptime_seconds":42}
curl -s http://127.0.0.1:8080/ready | jq # 200 ready / 503 not_ready (registry/routes/event_loop checks)
curl -s http://127.0.0.1:8080/live | jq # 200 alive — no FS I/O, <5ms
curl -s http://127.0.0.1:8080/metrics | head -n 20
# # HELP mcp_gway_http_requests_total Total HTTP requests
# # TYPE mcp_gway_http_requests_total counter
# mcp_gway_http_requests_total{method="GET",path="/health",status="200"} 7
Correlation & JSON logs:
curl -s -H "X-Request-ID: demo123" http://127.0.0.1:8080/health -D - | grep -i X-Request-ID
# X-Request-ID: demo123 ← echo on every response; json log line also has "request_id":"demo123"
uv run mcp-gway serve --port 8080 2>&1 | head # each line valid JSON: timestamp, level, logger, message, request_id, method, path, status, duration_ms
X-Request-IDorX-Correlation-IDaccepted, sanitized to^[A-Za-z0-9_-]{1,64}$, truncated; autouuid4if absent.- Labels bounded:
pathcollapsed to/mcpor/mcp/messages(alias SSE al mismo handler_mcp_post, no endpoint independiente; all other routes recorded as-is), server sanitized[^A-Za-z0-9_]→_32 chars. - Metrics:
http_requests_total,http_request_duration_seconds(buckets 0.005..5),mcp_tool_calls_total{server,tool,status},discovery_duration_seconds,sandbox_execute_total{status},registry_operations_total{op},gateway_sessions_active.
FEAT-007 hardening (v2.2.1): process/build lifecycle, stdio coverage, upstream telemetry, cardinality cap.
- Lifecycle:
build_info{version},process_start_time_seconds,uptime_seconds(heartbeat, 30s tick),lifetime_seconds(set at shutdown) + a JSONgateway shutdown summary(uptime + totals) on exit. - stdio (
serve --transport stdio, default): per-requeststdio_requests_total{method,status}+stdio_request_duration_seconds{method}and a JSON access log (transport:"stdio", same shape as HTTP). - Upstream CodeMode calls (tool execution):
upstream_tool_calls_total{server,tool,status}(ok/timeout/error),upstream_tool_duration_seconds{server,tool}; opt-in retries counted inupstream_retries_total{server}. - Corruption visibility:
code_mode_servers_skipped_total{reason}+ structured WARN + degraded banner hint (mcp-gway refresh <name>) when a server fails injection at startup. - Label cardinality hard-capped per metric (
_MAX_LABEL_COMBOS=200); overflow coalesces into a reserved_otherseries so a label storm cannot grow memory without bound. - Slow-request WARN: requests over
_SLOW_REQUEST_THRESHOLD_MS(1000) additionally log aslow requestJSON line with duration. - CLI structured outcomes:
cli <action> <status>JSON events — WARNING always emitted, INFO only whenMCP_GWAY_LOG_LEVELis set (zero operator noise by default). - New opt-in flag:
mcp-gway add --retry-on-transport-error— retries exactly once ONLY when the transport/connect phase fails (never after the tool call starts; non-idempotency-safe, ADR-012 decision 9). Default off → zero behavior change.
Local-first gating: /metrics never leaks secrets; serve on non-loopback without MCP_GWAY_ALLOW_REMOTE=1 exits 2; X-Warning: exposed only on GET /metrics → 403 (src/mcp_gway/observability/health.py:127-139).
Connect from Claude Desktop
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"gateway": {
"url": "http://localhost:8080/mcp"
}
}
}
Commands
| Command | Description |
|---|---|
mcp-gway add --type remote|local |
Add an MCP server and generate .pyi stub (OpenCode format, primary) |
mcp-gway remove |
Remove an MCP server |
mcp-gway update |
Update tools for a server |
mcp-gway list |
List all connected servers |
mcp-gway inspect |
Show tool signatures for a server |
mcp-gway refresh [<name>] [--auth] [--oauth-port <port>] |
Refresh connection and re-discover tools |
mcp-gway serve [--transport stdio|http|sse] [--host 127.0.0.1] [--port <port>] [--log-level LEVEL] [--registry-dir PATH] |
Start gateway (default stdio; --host/--port only with http|sse). http/sse share Gateway.app; stdio keeps stdout pure NDJSON. Default 127.0.0.1; 0.0.0.0 necesita MCP_GWAY_ALLOW_REMOTE=1 |
mcp-gway mcp [--log-level LEVEL] [--registry-dir PATH] |
DEPRECATED hidden alias: serve --transport stdio equiv mcp — mismo loop NDJSON y mismos args a _serve_stdio, modulo aviso de deprecacion en stderr (solo mcp). Prefer command: [mcp-gway, serve, --transport, stdio] for OpenCode type: local |
mcp-gway local-unrestricted enable|disable|status |
Break-glass marker 72h (explicit only) — enable/remove, or status without side effects |
Types: only
--type local|remote(cli.py:50). Legacyhttp|stdio|sse|streamable-httpare rejected by click. There is no--args/--docs-url.
Options for add (OpenCode) — 13 flags (cli.py:45-95):
| Option | Description |
|---|---|
--type remote|local |
Server type (primary) |
--url <url> |
URL for remote |
--header "KEY=VALUE" |
HTTP header for remote (repeatable) |
--command "<cmd>" |
Command for local (e.g. "npx -y my-mcp") |
--env KEY=VALUE |
Environment variable for local (repeatable) |
--cwd <path> |
Working directory for local |
--oauth-client-id ID |
Pre-registered OAuth client ID |
--oauth-client-secret SECRET |
Pre-registered OAuth client secret |
--oauth-scope SCOPE |
OAuth scope |
--oauth-port <port> |
Local port for OAuth callback (default 8989) |
--timeout <ms> |
Connection timeout in ms (default 5000) |
--enabled / --no-enabled |
Enable/disable without removal (default enabled) |
--tools <list> |
Comma-separated tool filter (default * = all) |
Local Commands — Dynamic Allow-List (feat-006)
Dynamic-no-static: no hardcoded binaries. Operators allow-list once via env; see ADR-009.
Default-deny: empty MCP_GWAY_ALLOW_LOCAL_COMMANDS denies every local command (vacío = deny, se mantiene).
Recomendado: MCP_GWAY_ALLOW_LOCAL_COMMANDS="npx,uvx,python3,bunx".
# Allow-list (CSV basenames, `*` = invalid → deny + warn)
export MCP_GWAY_ALLOW_LOCAL_COMMANDS="npx,uvx,python3,bunx"
mcp-gway add fs --type local --command "npx -y @anthropic/mcp-filesystem" --cwd /srv/mcp/workdir
Break-glass 72h (bootstrap only, time-boxed) — marker required, env alone never activates
$env:MCP_GWAY_ALLOW_UNRESTRICTED_LOCAL="1"
mcp-gway local-unrestricted enable
mcp-gway local-unrestricted status
mcp-gway refresh
Disable — both steps required (explicit only, never auto-created on add/refresh/spawn)
mcp-gway local-unrestricted disable # 1. removes marker file
Remove-Item Env:\MCP_GWAY_ALLOW_UNRESTRICTED_LOCAL # 2. unsets env, returns to allow-list mode
-
Nota CISO opt-in:
bunxsolo como recomendado en documentación (no default en código, default-deny vacío se mantiene); solo opt-in con pin + owner + regate 90d;bunruntime sigue fuera; denylist EXACT PATH,PATHEXT,SYSTEMROOT,COMSPEC,LD_PRELOAD,LD_LIBRARY_PATH,PYTHONPATH,PYTHONHOME,NODE_OPTIONS,NODE_PATH,NODE_EXTRA_CA_CERTS,NODE_TLS_REJECT_UNAUTHORIZED + PREFIXES DYLD_,NPM_CONFIG_,BUN_,UV_ + PATH controlado (NODE_ENVpermitido, no denylisted); prohibido*, paths o shell. -
Tag
v2.0.0interno no publicado — no anuncio externo. Tras actualizar, borra la caché vieja manualmente:rm ~/.config/mcp-gway/catalog.json. -
Marker
~/.config/mcp-gway/.local_unrestricted(epoch,0o600, 72h TTL) — fail-closed: missing, expired, future, insecure, unreadable, or invalid → deny. States viamcp-gway local-unrestricted status:disabled(env unset),marker-missing,expired,future(timestamp in the future),marker-insecure(permissions !=0o600on posix),marker-unreadable,marker-invalid,active. -
Allow-list still applies when break-glass inactive: env alone never activates; allow-listed binaries remain allowed when marker missing/expired/etc. Final deny carries an actionable hint (
local-unrestricted enable/status). -
Disable requires both steps:
local-unrestricted disableremoves the marker file, plusRemove-Item Env:\MCP_GWAY_ALLOW_UNRESTRICTED_LOCALunsets the env and returns to allow-list mode. -
Any syntactically valid basename allowed while marker fresh; otherwise deny.
-
unsetreturns to allow-list mode. -
CLI
add/refreshenforces allow-list/unrestricted plus re-validation before persist. -
cwdmust be absolute + real +is_dir, elsereason_code=invalid_cwd. Env denylist (PATH,LD_PRELOAD,PYTHONPATH, …) →reason_code=denied_env. -
Spawn only resolved via PATH lookup (
shutil.which(basename)); nevershell=True/cmd /c/sh -c. Errors carryreason_code(not_allowlisted,binary_not_found, …).
Code Mode
When connected, the gateway exposes 4 meta-tools:
| Tool | Description |
|---|---|
listToolFiles |
List all available .pyi stub files |
readToolFile |
Read function signatures from a stub |
getToolDocs |
Get detailed documentation for a tool |
executeToolCode |
Execute code in a sandboxed Starlark interpreter |
OAuth Authentication
For servers requiring OAuth (e.g., Supabase):
# Trigger OAuth flow (preferido — no deja secretos en shell-history)
mcp-gway refresh supabase --auth
# Or store token manually (solo fallback; chmod 600 obligatorio)
mkdir -p ~/.config/mcp-gway/tokens
echo '{"access_token": "YOUR_TOKEN"}' > ~/.config/mcp-gway/tokens/supabase.json
chmod 600 ~/.config/mcp-gway/tokens/supabase.json
Development
# Install dependencies
uv sync --all-groups # installs dev group with pre-commit
uv run pre-commit install # once per clone — hooks already configured in .pre-commit-config.yaml
# Run checks
uv run pre-commit run --all-files # ruff + ruff-format + hygiene (trailing-whitespace, end-of-file-fixer, check-yaml, check-added-large-files)
uv run pytest -v # 242 tests — CLI, MCP, Code Mode, stdio, OAuth, observability
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
# Verification probes (sin Node, sin build)
curl -s http://127.0.0.1:8080/health | jq .status # "ok"
curl -s http://127.0.0.1:8080/ready | jq .status # "ready"
curl -s http://127.0.0.1:8080/metrics | head -n 5 # # HELP mcp_gway_...
# Local-first check
mcp-gway serve --host 0.0.0.0 2>&1 | grep -q "requires MCP_GWAY_ALLOW_REMOTE" && echo "gate ok"
Pre-commit is already in place (.pre-commit-config.yaml — ruff v0.16.4, ruff-format, trailing-whitespace, end-of-file-fixer, check-yaml, check-added-large-files).
Architecture
┌──────────────────────────────────────────────────────────────────────┐
│ MCP Gateway v2.2.0 │
├──────────────────────────────────────────────────────────────────────┤
│ CLI (click) │ Gateway (Starlette + uvicorn, CSP) │
│ - add remote/local │ - POST /mcp (JSON-RPC) │
│ - remove/inspect/list │ - GET /mcp (SSE endpoint event) │
│ - refresh --auth │ - POST /mcp/messages?session_id=... (alias) │
│ - serve --host 127.0.0.1 │ - GET /health │
│ (local-first default) │ - GET /ready, /live, /metrics │
├──────────────────────────────────────────────────────────────────────┤
│ Code Mode (4 meta-tools) │ Starlark Sandbox │
│ - listToolFiles │ - Hermetic execution │
│ - readToolFile │ - Server injection │
│ - getToolDocs │ │
│ - executeToolCode │ │
├──────────────────────────────────────────────────────────────────────┤
│ Registry (única fuente) │ OAuth2 (RFC 7591, reutilizado) │
│ - servers/*.pyi = signatures │ - Dynamic registration │
│ - servers/*.json = config │ - PKCE + FileTokenStorage │
│ - last-write-wins, atómico │ - tokens/ no expuesto vía API │
└──────────────────────────────────────────────────────────────────────┘
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Server1 │ │ Server2 │ │ Server3 │
│ (remote)│ │ (local) │ │ (remote)│
└─────────┘ └─────────┘ └─────────┘
- Sin Node en runtime ni CI: sin UI ni assets vendoreados,
ruffúnico linter,uv_buildbackend. - Release híbrido (ADR-007):
push tags v*→uv build+pypi-publish(GAv2.0.0tag manual, release interno no publicado) +workflow_run Tests completed→python-semantic-release@v10 (>=10.0.0, uv.lock 10.6.1)parafix/perfpatches auto (línea v2.0.1..v2.2.0 ya liberada así).concurrency: release,fetch-depth:0,[tool.semantic_release]syncpyproject.toml+__init__.py(2.2.0exacta).
License
MIT
Metadata
Release files for mcp-gway 2.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_gway-2.4.0.tar.gz | 66.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_gway-2.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 144.5 kB
Release files / mcp_gway-2.4.0.tar.gz
| Download URL | mcp_gway-2.4.0.tar.gz |
|---|---|
| Size | 66.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
03770babd6efb2d71f953d14bf7c4abd303abc6f769077c368ce440230bc4973
|
|
BLAKE2b-256 checksum How to use checksums |
4a41d5744964235e50eb536f3901ebd1d6bdb743f49402e89a689187a60a925a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency logRelease files / mcp_gway-2.4.0-py3-none-any.whl
| Download URL | mcp_gway-2.4.0-py3-none-any.whl |
|---|---|
| Size | 78.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4939a2dd47f28918a2fe01b0608d9cbe2bd4eaf05fcc98c2125648c9e56c809b
|
|
BLAKE2b-256 checksum How to use checksums |
dddcd2a3152516e056fdb62c80fa0fa290678012b56d679d9a7e534be031cdf9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency log