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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

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

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/R0Wi/mcp-gateway'

If you have feedback or need assistance with the MCP directory API, please join our Discord server