mcp-gateway
Allows connecting a GitHub MCP backend via OAuth and exposing its tools under a github_ namespace through the gateway.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-gatewayUse the GitHub backend to list my open pull requests."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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" --> MoreBuilt 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
/register—claude mcp addworks with no pre-shared credentialsClient ID Metadata Documents (CIMD) — HTTPS URLs as client IDs, including
private_key_jwtclient authentication, advertised viaclient_id_metadata_document_supported: trueAuthorization Server Metadata (RFC 8414) + OIDC discovery alias
Protected Resource Metadata (RFC 9728); 401 responses carry
WWW-Authenticate: Bearer resource_metadata="…"as Claude's connector requiresResource 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_statustool
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 -ddocker 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-passwordConnect Claude Code (CLI)
claude mcp add --transport http gateway https://mcp.example.com/mcpClaude 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 |
| – | no credentials sent |
|
|
|
|
| static headers (API keys etc.) |
|
| 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 fromconfig.yamlas${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 overMCP_GATEWAY_ENCRYPTION_KEYwhen set, andconfig.yamldoesn't need any change either way. This is the Docker Composesecrets:convention — it keeps the raw key out ofdocker inspectanddocker compose configoutput, and is what KubernetesSecretvolumes, Vault Agent, and most KMS sidecars all expect too.docker-compose.ymlhas 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.txtThis 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.yamlThis 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-gatewaydocker-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 debugEndpoints
Path | Purpose |
| MCP endpoint (Streamable HTTP) |
| RFC 9728 protected resource metadata |
| RFC 8414 AS metadata (+ OIDC alias) |
| OAuth 2.1 endpoints (PKCE, DCR, revocation) |
| login + consent (Svelte 5) |
| backend connection status / connect / disconnect |
| the gateway's own CIMD document (upstream leg) |
| upstream OAuth connect flow |
| 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
cryptographypackage, 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-styleencryption_keyis 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,Secureon 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
/registertraffic.Security headers (CSP,
X-Frame-Options: DENY,Referrer-Policy,X-Content-Type-Options) are set on every response.X-Forwarded-*headers are trusted only fromserver.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_keyis 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.yamlThe 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" --> Upstream3src/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 SDKOAuthClientProvider(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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Self-hosted federated MCP gateway: one OAuth 2.1 MCP server in front of N apps, user-level scopes.
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
The Remote MCP server acts as a standardized bridge between LLM applications (like Claude, ChatGPT, and Cursor) and external services, enabling AI agents to access external tools and resources. Its primary capability is providing a centralized search tool to discover other MCP servers and their respective tools. Unlike local implementations, it runs remotely with OAuth authentication and permission controls for security.
Related MCP Servers
- FlicenseNot gradedqualityNot gradedmaintenanceAggregates 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-
- FlicenseNot gradedqualityBmaintenanceMulti-tenant MCP server with OAuth 2.1 authorization, enabling tenant-scoped tool access and audit logging.-
- AlicenseAqualityCmaintenanceA 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.512MIT
- AlicenseNot gradedqualityCmaintenanceAuthenticating reverse proxy for MCP servers providing credential isolation, OAuth2 token management, and composite tool aggregation.BSD Zero Clause