Skip to main content
Glama
R0Wi

mcp-gateway

by R0Wi

MCP Gateway

A lightweight, self-hosted MCP aggregator gateway: one public MCP endpoint in front of any number of protected backend MCP servers, with a spec-compliant OAuth 2.1 authorization server facing the MCP client — the piece most existing gateways are missing.

flowchart LR
    Client["Claude Code / Claude.ai"]

    subgraph Gateway["MCP Gateway"]
        MCP["/mcp\n(Streamable HTTP)"]
    end

    GitHub["GitHub MCP"]
    Docs["Microsoft Learn MCP"]
    More["…more backends"]

    Client -- "OAuth 2.1\n(DCR/CIMD + PKCE)" --> MCP
    MCP -- "own credentials" --> GitHub
    MCP -- "own credentials" --> Docs
    MCP -- "own credentials" --> More

Built with FastAPI + FastMCP, configured by a single YAML file, stores its state in a single encrypted SQLite database, and ships as one small standalone container — no reverse proxy required, though you can put one in front of it for TLS. Prebuilt images are published at ghcr.io/r0wi/mcp-gateway; building from source is only needed if you want to change the code.

Features

Client-facing (MCP authorization spec, 2025-11-25):

  • OAuth 2.1 authorization code flow with mandatory PKCE (S256)

  • Dynamic Client Registration (RFC 7591) at /registerclaude mcp add works with no pre-shared credentials

  • Client ID Metadata Documents (CIMD) — HTTPS URLs as client IDs, including private_key_jwt client authentication, advertised via client_id_metadata_document_supported: true

  • Authorization Server Metadata (RFC 8414) + OIDC discovery alias

  • Protected Resource Metadata (RFC 9728); 401 responses carry WWW-Authenticate: Bearer resource_metadata="…" as Claude's connector requires

  • Resource indicators (RFC 8707) accepted and bound to issued tokens

  • Short-lived opaque access tokens, rotating refresh tokens, single-use authorization codes — all stored hashed; client records encrypted at rest

  • Loopback redirect URIs match port-agnostically (Claude Code CLI registers one port and authorizes with another); non-loopback URIs require exact registration

  • Small Svelte 5 login + consent UI (single local identity from the config file)

Backend-facing:

  • none — public servers (e.g. Microsoft Learn MCP)

  • bearer — static token injection (Authorization: Bearer …, e.g. PATs)

  • headers — arbitrary static headers (API keys)

  • oauth — full OAuth client per the MCP spec: metadata discovery, CIMD when the upstream AS supports it (the gateway hosts its own client metadata document), DCR fallback, PKCE, automatic token refresh. Connected once via the browser; tokens persisted encrypted (Fernet) in SQLite.

  • The client's gateway token is never forwarded upstream (no token passthrough, as the spec demands); backends only ever see credentials the gateway holds.

Aggregation:

  • Tools/resources/prompts namespaced per backend: github_create_issue, msdocs_microsoft_docs_search, …

  • Live proxying over Streamable HTTP; a down or not-yet-connected backend only removes its own tools instead of breaking the gateway

  • Built-in gateway_status tool

Related MCP server: MCP OAuth Test

Quick start

cp config.example.yaml config.yaml
$EDITOR config.yaml                                   # set public_url, users, backends
cp .env.example .env
$EDITOR .env                                           # set MCP_GATEWAY_ENCRYPTION_KEY (openssl rand -base64 32)
docker compose up -d

docker compose up -d pulls the prebuilt ghcr.io/r0wi/mcp-gateway image by default — no build step required. If you want to run from source instead (e.g. to test local changes), uncomment the build: . line in docker-compose.yml and comment out image:.

The gateway runs standalone and listens on :8000; docker compose picks up MCP_GATEWAY_ENCRYPTION_KEY from .env automatically. Put it behind a reverse proxy of your choice for TLS, or expose the port directly. For a file-based alternative to .env (recommended for production), see Encryption key.

Generate a password hash for the config file:

docker compose run --rm mcp-gateway mcp-gateway hash-password

Connect Claude Code (CLI)

claude mcp add --transport http gateway https://mcp.example.com/mcp

Claude Code discovers the gateway's authorization server, registers itself via DCR (or uses its CIMD client ID), and opens your browser: log in with a user from config.yaml, approve, done. No tokens to paste.

Connect Claude.ai / Claude Code web (custom connector)

Add https://mcp.example.com/mcp as a custom connector. The browser redirect to https://claude.ai/api/mcp/auth_callback goes through the same login/consent flow.

Connect OAuth backends

Open https://mcp.example.com/ui/backends, sign in, and press Connect next to each OAuth backend (e.g. GitHub MCP). You'll be redirected to the backend's authorization server once; afterwards the gateway refreshes tokens automatically.

Configuration

Everything lives in one YAML file (see config.example.yaml). Values support ${ENV_VAR} / ${ENV_VAR:-default} expansion.

server:
  public_url: https://mcp.example.com   # behind your reverse proxy

auth:
  encryption_key: ${MCP_GATEWAY_ENCRYPTION_KEY}   # encrypts secrets at rest
  users:
    - username: admin
      password_hash: "$2b$12$…"          # mcp-gateway hash-password
  access_token_expiry_seconds: 3600
  refresh_token_expiry_seconds: 2592000

storage:
  path: /data/gateway.db                 # SQLite; the only state

backends:
  github:                                # → tools namespaced github_*
    url: https://api.githubcopilot.com/mcp/
    auth:
      type: oauth
      # GitHub's authorization server supports neither CIMD nor DCR, so
      # register a GitHub OAuth App and provide its credentials directly:
      client_id: ${GITHUB_OAUTH_CLIENT_ID}
      client_secret: ${GITHUB_OAUTH_CLIENT_SECRET}
  microsoft-docs:                        # → tools namespaced microsoft-docs_*
    url: https://learn.microsoft.com/api/mcp
    auth: { type: none }
  something-with-a-pat:
    url: https://example.com/mcp
    auth: { type: bearer, token: "${SOME_PAT}" }

Adding a backend is config-only — no code changes.

Backend auth reference

type

fields

behaviour

none

no credentials sent

bearer

token

Authorization: Bearer <token> on every request

headers

headers: {Name: value}

static headers (API keys etc.)

oauth

scopes, prefer_dcr, client_id, client_secret

full OAuth client: CIMD → DCR fallback, PKCE, refresh, encrypted store

For oauth backends the gateway hosts its own Client ID Metadata Document at <public_url>/oauth/client-metadata.json and uses it as its client ID whenever the upstream AS advertises CIMD support (requires an HTTPS public_url); otherwise it falls back to Dynamic Client Registration. If the upstream AS supports neither (e.g. GitHub's), set client_id (and client_secret, if the app is confidential) to use a pre-registered OAuth client instead — CIMD/DCR are skipped entirely.

Encryption key

auth.encryption_key protects everything the gateway stores at rest: registered OAuth client records and upstream backends' access/refresh tokens (see Security for how). Two ways to supply it:

  • MCP_GATEWAY_ENCRYPTION_KEY (default) — a plain environment variable, referenced from config.yaml as ${MCP_GATEWAY_ENCRYPTION_KEY}. Simple, and fine for a self-hosted single-admin deployment.

  • MCP_GATEWAY_ENCRYPTION_KEY_FILE — path to a file containing the key, e.g. /run/secrets/encryption_key. Takes precedence over MCP_GATEWAY_ENCRYPTION_KEY when set, and config.yaml doesn't need any change either way. This is the Docker Compose secrets: convention — it keeps the raw key out of docker inspect and docker compose config output, and is what Kubernetes Secret volumes, Vault Agent, and most KMS sidecars all expect too. docker-compose.yml has a commented-out example; switching is a few uncommented lines, no rebuild required.

Internally, the key you supply is a Key Encryption Key (KEK): it never encrypts data directly. On first run the gateway generates a random Data Encryption Key (DEK) that does the actual encrypting, and stores the DEK wrapped by the KEK. This means rotating encryption_key only has to re-wrap that one small DEK, not re-encrypt the whole database:

openssl rand -base64 32 > new-key.txt
mcp-gateway rotate-key -c config.yaml --old-key-file old-key.txt --new-key-file new-key.txt

This updates the database in place (an O(1) operation, regardless of how much data it holds); update MCP_GATEWAY_ENCRYPTION_KEY/MCP_GATEWAY_ENCRYPTION_KEY_FILE to new-key.txt's contents and restart the gateway afterwards. If --old-key-file doesn't match the key currently protecting the database, the command fails with an error and changes nothing.

If you lose the key

There is no recovery path — that's what "encrypted" means. Every registered OAuth client would need to re-register (most do this automatically via DCR/CIMD on next use) and every OAuth backend would need reconnecting via /ui/backends. Back the key up the way you'd back up any other irreplaceable credential — a password manager, a sealed secret in your org's vault — not just as a file that only exists on the host running the gateway.

Database migrations

Schema changes are managed with Alembic (migration scripts live in src/mcp_gateway/migrations/). mcp-gateway run applies any pending migrations automatically before the server starts -- this covers both a brand-new database (created from scratch on first run) and upgrading an older one, so there's nothing to do for a normal deployment.

If you'd rather apply migrations as an explicit step -- e.g. as part of a deploy pipeline, or before scaling up multiple replicas against the same database -- run:

mcp-gateway migrate -c config.yaml

This only touches the database (it doesn't start the server) and is safe to run before and after mcp-gateway run: migrations are idempotent and tracked in the database itself (an alembic_version table), so applying them twice, or having both migrate and the next run see an already-up-to-date database, is a no-op.

Logging

The gateway logs to stdout/stderr (docker logs, docker compose logs -f), at INFO by default: startup/shutdown, config summary, login attempts, OAuth authorize/consent/token issuance, upstream backend connect/disconnect, and backend mount status. DEBUG adds finer-grained detail (client construction, token rotation, CIMD refreshes, storage housekeeping). No credentials or tokens are ever logged, at any level.

Set the level via the MCP_GATEWAY_LOG_LEVEL environment variable (debug, info, warning, error, or critical):

# .env (picked up by docker compose)
MCP_GATEWAY_LOG_LEVEL=debug
# or inline
docker compose run --rm -e MCP_GATEWAY_LOG_LEVEL=debug mcp-gateway

docker-compose.yml already forwards this variable to the container, defaulting to info when unset.

Outside Docker, --log-level on mcp-gateway run works the same way and takes precedence over the env var:

mcp-gateway run -c config.yaml --log-level debug

Endpoints

Path

Purpose

/mcp

MCP endpoint (Streamable HTTP)

/.well-known/oauth-protected-resource[/mcp]

RFC 9728 protected resource metadata

/.well-known/oauth-authorization-server

RFC 8414 AS metadata (+ OIDC alias)

/authorize, /token, /register, /revoke

OAuth 2.1 endpoints (PKCE, DCR, revocation)

/ui/authorize

login + consent (Svelte 5)

/ui/backends

backend connection status / connect / disconnect

/oauth/client-metadata.json

the gateway's own CIMD document (upstream leg)

/oauth/connect/<backend>, /oauth/callback

upstream OAuth connect flow

/healthz

liveness

Security

A quick tour of the primitives in use and what they're for, before the detailed list below:

  • Secrets at rest (registered OAuth client records, upstream backends' access/refresh tokens) are encrypted with Fernet — AES-128-CBC plus HMAC-SHA256, authenticated symmetric encryption from Python's well-audited cryptography package, not hand-rolled crypto. See Encryption key for how the key itself is supplied, wrapped (envelope encryption), and rotated.

  • Passwords: local users' passwords are hashed with bcrypt (mcp-gateway hash-password); a passphrase-style encryption_key is stretched into a Fernet key with scrypt (n=2**17, current OWASP guidance) plus a random per-database salt, rather than used directly.

  • Tokens: access/refresh tokens and authorization codes are never stored in recoverable form — only a SHA-256 hash of each, the same way a password would be.

  • Sessions: browser login sessions are signed cookies (itsdangerous), not server-side session IDs, but are still revocable — logout records the session in SQLite so it stops validating immediately, not just when the cookie expires.

None of this protects a fully compromised gateway process — a key that's decrypting and using secrets on every request necessarily has to be in that process's memory. What it protects against is the database file leaking without the process: a misdirected backup, a shared volume snapshot, a support bundle.

Hardening measures

  • PKCE (S256) is mandatory; authorization codes are single-use and expire in 5 min.

  • Refresh tokens rotate on every use (OAuth 2.1 public-client requirement).

  • The consent screen names the client and the exact redirect target, and warns on loopback redirects (CIMD localhost-impersonation guidance from the spec).

  • Tokens issued to MCP clients are never forwarded to backends, and backend credentials never reach MCP clients.

  • Sessions are HttpOnly, SameSite=Lax, Secure on HTTPS.

  • Login and Dynamic Client Registration (/register) are rate-limited per source IP; password checks run off the event loop so a flood of attempts can't stall the server.

  • Anonymous DCR/CIMD client registrations that never complete an authorization are reclaimed after 24h; storage doesn't grow unbounded from unauthenticated /register traffic.

  • Security headers (CSP, X-Frame-Options: DENY, Referrer-Policy, X-Content-Type-Options) are set on every response.

  • X-Forwarded-* headers are trusted only from server.trusted_proxy_ips (default: loopback). Set this to your reverse proxy's address if you run one — see Configuration.

  • No credentials are logged. A decrypt failure caused by a mismatched encryption_key is logged loudly at startup rather than silently treated as missing data — see Encryption key.

Development

uv venv && uv pip install -e ".[dev]"     # or: pip install -e ".[dev]"
(cd ui && npm install && npm run build)   # build the Svelte UI
pytest                                    # 71 tests incl. full e2e OAuth flows
mcp-gateway run -c config.yaml

The test suite spins up real gateways (and a second instance acting as an OAuth-protected upstream) and drives complete DCR/CIMD + PKCE flows over HTTP.

Adding a migration

There's no SQLAlchemy ORM layer in this project (see src/mcp_gateway/storage.py), so migrations are plain, hand-written DDL rather than autogenerated from models. Point the Alembic CLI at a scratch database (alembic.ini at the repo root is set up for exactly this):

alembic -x db-path=/tmp/dev.db revision -m "describe the change"

then edit the generated file under src/mcp_gateway/migrations/versions/ using op.execute(...) / op.add_column(...) etc., following the existing revisions there. Since the schema was already out in the wild before this project adopted Alembic, a migration that alters an existing table (unlike CREATE TABLE IF NOT EXISTS, which is always safe to repeat) should guard itself with an existence check -- see 0002_session_revocation_and_client_ttl.py for the pattern. Run it against the scratch database to sanity-check it (alembic -x db-path=/tmp/dev.db upgrade head), then add a regression test in tests/test_db_migrations.py.

Architecture

flowchart TB
    MCPClient["MCP client\n(Claude Code / Claude.ai)"]
    Browser["Browser\n(login / consent / backends UI)"]

    subgraph Frontend["Frontend — ui/ (Svelte 5 + Vite SPA)"]
        UI["Login · Consent · Backend connections"]
    end

    subgraph Backend["Backend — FastAPI + FastMCP (app.py / web.py)"]
        subgraph ClientFacing["Client-facing — oauth_server.py"]
            AS["OAuth 2.1 authorization server\nDCR · CIMD · PKCE · metadata"]
            Consent["Login / consent transaction flow"]
        end

        subgraph Aggregation["Aggregation — gateway.py"]
            Proxy["FastMCP server\nnamespaced tool/resource proxying"]
        end

        subgraph UpstreamLayer["Upstream — upstream.py"]
            BackendClients["Backend clients\nnone · bearer · headers · oauth"]
        end

        subgraph Security["Security"]
            Storage["Encrypted SQLite\n(Fernet, hashed tokens, bcrypt)"]
        end
    end

    Upstream1["GitHub MCP"]
    Upstream2["Microsoft Learn MCP"]
    Upstream3["…more backends"]

    MCPClient -- "/mcp (Streamable HTTP)\nBearer token" --> AS
    Browser -- "/ui/authorize, /ui/backends" --> UI
    UI -- "JSON API" --> Consent
    AS --> Consent
    AS -- "issues/validates tokens" --> Proxy
    Consent -- "clients, sessions" --> Storage
    Proxy -- "routes per backend namespace" --> BackendClients
    BackendClients -- "tokens, client records" --> Storage
    BackendClients -- "own credentials" --> Upstream1
    BackendClients -- "own credentials" --> Upstream2
    BackendClients -- "own credentials" --> Upstream3
  • src/mcp_gateway/oauth_server.py — the client-facing OAuth AS. Builds on the MCP SDK's authorization-server handlers and FastMCP's CIMD manager rather than hand-rolling protocol code; the gateway adds SQLite persistence, the login/consent transaction flow, and token issuance/rotation policy.

  • src/mcp_gateway/upstream.py — backend clients. OAuth backends use the official SDK OAuthClientProvider (discovery, CIMD/DCR, refresh) with encrypted SQLite token storage and a browser-driven connect flow.

  • src/mcp_gateway/gateway.py — FastMCP server; each backend is mounted as a live proxy under its namespace.

  • src/mcp_gateway/app.py / web.py — FastAPI app: JSON API for the UI, upstream callback, CIMD document, static Svelte app; the FastMCP app (MCP endpoint + OAuth routes + well-known) is mounted at the root.

  • ui/ — Svelte 5 + Vite SPA (login, consent, backends).

Single-instance by design (SQLite + in-memory connect flows). Runs standalone; put it behind a reverse proxy of your own if you want TLS termination, and back up one file.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    Not graded
    maintenance
    Aggregates multiple MCP servers behind a single, secure endpoint with unified tool/resource discovery, OAuth authentication, and resilient request routing. Enables users to manage and interact with multiple MCP backends through one centralized interface with load balancing and circuit breakers.
    2
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Multi-tenant MCP server with OAuth 2.1 authorization, enabling tenant-scoped tool access and audit logging.
    -
  • A
    license
    A
    quality
    C
    maintenance
    A federated MCP gateway that consolidates multiple plain-HTTP backends into a single, OAuth-protected MCP server, enabling agents to access diverse tools through one endpoint with centralized authentication and audit.
    5
    12
    MIT