A production-ready gateway for exposing Model Context Protocol (MCP) servers to AI coding assistants (Claude Code, OpenCode, etc.) with Azure Entra ID authentication.
┌──────────────┐ ┌─────────────────────────────────┐ ┌──────────────────┐
│ AI Client │────▶│ Caddy (TLS + Routing) :443 │ │ MCP Containers │
│ (Claude, │ │ │ │ (via ToolHive) │
│ OpenCode) │ │ /.well-known/* → OAuth meta │ │ │
│ │ │ /oauth2/authorize → auth-proxy │────▶│ github :9100 │
│ │ │ /oauth2/token → token-proxy │ │ atlassian :9101 │
│ │ │ /register → static JSON │ │ custom :9102 │
│ │ │ /mcp → workload proxy│ │ ... │
│ │ │ /* → ToolHive API │ └──────────────────┘
└──────────────┘ └─────────────────────────────────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ auth-proxy │ │ token-proxy │
│ :9090 │ │ :9091 │
│ │ │ │
│ Strips: │ │ Strips: │
│ - resource │ │ - resource │
│ - prompt │ │ │
│ │ │ Swaps: │
└──────┬──────┘ │ access_token│
│ │ ← id_token │
▼ └──────┬──────┘
┌─────────────┐ │
│ Azure Entra │◀────────────────────┘
│ ID (v2.0) │
└─────��───────┘
MCP clients authenticate to remote servers using RFC 9728 OAuth discovery. When the OAuth provider is Azure Entra ID, several incompatibilities arise:
| Problem | Root Cause | Solution |
|---|---|---|
resource parameter rejected |
Entra v2.0 doesn't support the resource parameter that some MCP SDKs send |
auth-proxy strips it from authorize requests; token-proxy strips it from token exchange |
| Forced admin consent screen | MCP SDKs send prompt=consent |
auth-proxy strips the prompt parameter |
| Token signature verification fails | MS Graph access tokens use a non-standard JWT format (nonce in header) that standard OIDC libraries can't verify | token-proxy swaps the access_token with the id_token in responses |
| Redirect URI mismatch | MCP clients use dynamic localhost ports for callbacks | Register redirect URIs as "Mobile and desktop applications" platform in Entra (ignores port) |
| Component | Port | Purpose |
|---|---|---|
| Caddy | 443 | TLS termination, routing, RFC 9728 OAuth metadata |
| auth-proxy | 9090 | Strips resource and prompt from authorize requests |
| token-proxy | 9091 | Strips resource from token exchange, swaps access_token with id_token |
| ToolHive | 8080 | MCP server lifecycle management (Podman/Docker containers) |
| Workload proxies | 9100+ | Per-server MCP endpoints with OIDC auth + Cedar authorization |
- Linux server (RHEL 8+/9+, Ubuntu 20.04+)
- Caddy v2.7+
- ToolHive (
thv) binary - Podman (rootless) or Docker
- Python 3.6+
- Azure Entra ID app registration
- TLS certificate for your domain
Create an app registration:
# Note these values — you'll need them throughout
TENANT_ID="your-tenant-id"
CLIENT_ID="your-client-id"
APP_NAME="Enterprise MCP Gateway"Configure the app:
- Authentication → Add platform → Mobile and desktop applications
- Redirect URI:
http://127.0.0.1/mcp/oauth/callback - Also add:
http://localhost/mcp/oauth/callback
- Redirect URI:
- API Permissions → Add:
email,offline_access,openid,profile,User.Read- Grant admin consent for your tenant
- Token configuration (optional) → Add
groupsclaim for Cedar authorization
# Clone this repo
git clone https://github.com/YOUR_ORG/enterprise-mcp-gateway.git
cd enterprise-mcp-gateway
# Copy and configure
cp .env.example .env
# Edit .env with your Entra tenant ID, client ID, domain, etc.
source .env
# Generate configs from templates
./scripts/setup.sh
# Install systemd services
./scripts/install-services.shPlace your TLS certificate and key:
mkdir -p ~/tls
cp /path/to/cert.pem ~/tls/cert.pem
cp /path/to/key.pem ~/tls/key.pem
chmod 600 ~/tls/key.pem# Enable and start all services
systemctl --user enable --now oauth2-proxy token-proxy toolhive
# Start Caddy (system service with access to port 443)
sudo systemctl enable --now caddy-mcp# Run an MCP server with a fixed proxy port
thv run \
--proxy-port 9100 \
--transport stdio \
-e GITHUB_PERSONAL_ACCESS_TOKEN="ghp_..." \
--permission network \
ghcr.io/github/github-mcp-server:latest \
github
# Verify it's running
thv list{
"mcp": {
"my-gateway": {
"type": "remote",
"url": "https://your-domain.example.com/mcp"
}
}
}Then authenticate:
opencode mcp auth my-gateway{
"mcpServers": {
"my-gateway": {
"type": "url",
"url": "https://your-domain.example.com/mcp"
}
}
}Copy .env.example to .env and configure:
# Azure Entra ID
ENTRA_TENANT_ID="your-tenant-id"
ENTRA_CLIENT_ID="your-client-id"
# Server
GATEWAY_DOMAIN="mcp.example.com"
TLS_CERT_PATH="$HOME/tls/cert.pem"
TLS_KEY_PATH="$HOME/tls/key.pem"
# Proxy ports (for corporate environments)
HTTPS_PROXY=""
HTTP_PROXY=""
NO_PROXY="localhost,127.0.0.1"
# MCP workload ports (fixed)
MCP_PORT_GITHUB=9100
MCP_PORT_ATLASSIAN=9101ToolHive supports Cedar policies for fine-grained access control. See authz/ for examples:
// Allow users in a specific Entra group to call MCP tools
permit(
principal,
action == Action::"call_tool",
resource
) when {
principal.claim_groups.contains("your-entra-group-id")
};
Note: To use group-based policies, configure your Entra app to include the
groupsclaim in the id_token via Token configuration → Add groups claim.
"SSE error: Non-200 status code (404)"
- The Caddy catch-all is routing to ToolHive's management API instead of a workload proxy
- Ensure
/mcproute points to the workload proxy port (e.g., 9100), not ToolHive serve (8080)
"Invalid or expired state parameter - potential CSRF attack"
- Multiple auth processes are running simultaneously
- Kill all auth processes and retry with a single instance
"AADSTS9010010: resource parameter doesn't match scopes"
- The
resourceparameter is leaking through to Entra - Verify both
auth-proxyandtoken-proxyare running and Caddy routes to them
Token signature verification fails
- MS Graph access tokens have a non-standard JWT format
- Verify
token-proxyis swapping access_token with id_token
Need admin approval / consent screen
- The
prompt=consentparameter is not being stripped - Verify
auth-proxystrips thepromptparameter - Ensure admin consent is granted for all API permissions in Entra
# Check service status
systemctl --user status oauth2-proxy token-proxy toolhive
# View proxy logs
journalctl --user -u oauth2-proxy -f
journalctl --user -u token-proxy -f
# Test endpoints directly
curl -sk https://your-domain/health
curl -sk https://your-domain/.well-known/oauth-authorization-server | jq
curl -sk https://your-domain/.well-known/oauth-protected-resource | jq
# Test MCP with a token
TOKEN="your-jwt-token"
curl -sk -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
https://your-domain/mcp├── proxy/
│ ├── auth-proxy.py # OAuth authorize proxy
│ └── token-proxy.py # OAuth token exchange proxy
├── caddy/
│ └── Caddyfile.template # Caddy config template
├── systemd/
│ ├── oauth2-proxy.service # auth-proxy systemd unit
│ ├── token-proxy.service # token-proxy systemd unit
│ └── toolhive.service.template # ToolHive systemd unit template
├── authz/
│ ├── example-policy.cedar # Example Cedar policy (role-based)
│ └── example-authz.json # Example ToolHive authz config (group-based)
├── scripts/
│ ├── setup.sh # Generate configs from templates
│ └── install-services.sh # Install systemd services
├── .env.example # Configuration template
└── README.md
- Client discovers OAuth metadata via RFC 9728 (
/.well-known/oauth-protected-resource→/.well-known/oauth-authorization-server) - Client registers via
/register(returns pre-configured Entra client ID) - Authorization code flow through
auth-proxy(strips incompatible params) → Entra → callback - Token exchange through
token-proxy(stripsresource, swaps access_token with id_token) → Entra - MCP requests with Bearer token → Caddy → workload proxy (OIDC validation) → MCP container
- TLS is required — Caddy handles termination
- All proxy components bind to
127.0.0.1(localhost only) - No secrets in config files — use environment variables or secret managers
- Cedar policies provide fine-grained authorization per MCP server
- The id_token swap is safe because the id_token audience matches the client_id and is verified by standard OIDC
MIT