A single gateway that turns any OpenAPI spec into an MCP server. Configure one or more upstream APIs in config.yaml and the gateway exposes a separate MCP endpoint for each — all behind one HTTP server.
https://gateway/horde/mcp → MCP tools from horde's OpenAPI spec
https://gateway/foo/mcp → MCP tools from foo's OpenAPI spec
https://gateway/healthz → liveness probe
https://gateway/.well-known/mcp.json → MCP service discovery
make setup
make run# Build
make build
# or: docker build -t oapi2mcp .
# or: IMAGE_TAG=myrepo/oapi2mcp:latest make build
# Run — mount your config file at /app/config.yaml
docker run -p 8000:8000 \
-v "$(pwd)/config.yaml:/app/config.yaml:ro" \
oapi2mcpOverride host/port via CLI args or environment variables:
# Custom port via arg
docker run -p 9000:9000 \
-v "$(pwd)/config.yaml:/app/config.yaml:ro" \
oapi2mcp --port 9000
# Custom port via env var
docker run -p 9000:9000 \
-e PORT=9000 \
-v "$(pwd)/config.yaml:/app/config.yaml:ro" \
oapi2mcpThe container expects the config to be mounted at /app/config.yaml. It will exit non-zero if the file is absent or the spec URLs are unreachable.
make build and make test-docker use the Docker CLI, which works inside a container via Docker-out-of-Docker — mount the host socket:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd):/workspace" -w /workspace \
<your-ci-image> \
make build# config.yaml
# Optional: public URL of this gateway, used in /.well-known/mcp.json.
# Set this when running behind a reverse proxy or load balancer so that
# advertised MCP URLs reflect the external address, not the internal one.
# If omitted, URLs are derived from each request's Host header.
public_url: https://gateway.example.com
apis:
horde:
spec: https://horde.example.com/openapi.json
base_url: https://horde.example.com
auth: bearer_passthrough # forward caller's Bearer token upstream
internal:
spec: http://internal-svc/openapi.json
base_url: http://internal-svc
auth: none # no auth required| Value | Behaviour |
|---|---|
none |
No Authorization header added to upstream requests |
bearer_passthrough |
Copies the caller's Authorization: Bearer <token> to every upstream request |
| Variable | Default | Description |
|---|---|---|
HOST |
0.0.0.0 |
Bind address |
PORT |
8001 |
Listen port |
LOG_LEVEL |
info |
uvicorn log level |
Each API gets its own endpoint. Example .mcp.json for Claude Code:
{
"mcpServers": {
"horde": {
"type": "http",
"url": "http://localhost:8001/horde/mcp",
"headers": {
"Authorization": "Bearer ${HORDE_API_TOKEN}"
}
}
}
}Note:
bearer_passthroughrequires the MCP client to supply the token — the gateway forwards it verbatim. The env var is substituted by Claude Code at request time.
MCP Client (token=T) ──▶ POST /horde/mcp
│
BearerPassthroughMiddleware
extracts T into contextvars
│
FastMCP (from_openapi)
dispatches tool call
│
TokenPropagatingClient.send()
injects Authorization: Bearer T
│
upstream API ◀──────────────────
Token isolation is per-request via contextvars — concurrent calls from different clients never cross-contaminate.
Implementation note: fastmcp builds HTTP requests externally via
RequestDirectorand callsclient.send(request)directly, bypassingbuild_request(). Token injection must happen insend(), notbuild_request().
make setup # create .venv and install deps
make test # run unit tests
make test-docker # run Docker integration tests (requires Docker)
make lint # run ruff
make run # start gateway on :8001| Path | Description |
|---|---|
/<name>/mcp |
MCP streamable-HTTP endpoint for the named API |
/healthz |
Returns {"status": "ok", "apis": [...]} |
/.well-known/mcp.json |
Service discovery — lists all servers with their URLs and transport |
/debug/headers |
Shows incoming headers and whether the bearer token context var is set |