Content
# mcp-broker
[](https://github.com/FirneyGroup/mcp-broker/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.python.org/downloads/)
OAuth token broker and reverse proxy for remote MCP server connections.
> Built and maintained by [Firney](https://firney.com). Apache 2.0 licensed.
AI agents need to call tools on remote MCP servers (Notion, HubSpot, Reddit, Twitter/X), but those servers require OAuth credentials. The broker sits between your agent and remote servers, handling OAuth 2.1 flows and injecting tokens transparently so agents never see credentials.
```mermaid
flowchart LR
Agent[MCP Client] -->|X-Broker-Key + X-App-Id| Broker[mcp-broker]
Broker -->|Authorization: Bearer| Remote[Remote MCP Server]
```
Works with any MCP-compatible client (Claude Desktop, Claude Code, Google ADK, custom agents).
## Is this for me?
**Use mcp-broker if you:**
- Operate multiple AI agents or apps that need OAuth access to the same set of third-party services (Notion, HubSpot, Google Workspace, etc.)
- Want per-app credential isolation — compromising one app's broker key should not expose the others
- Prefer agents that never touch raw OAuth tokens or client secrets
- Need to drop in new OAuth providers without redeploying every agent that uses them
- Want to author custom MCP tools — wrap a Python SDK or expose internal APIs via a native connector, without standing up a separate MCP server
**Skip mcp-broker if you:**
- Have a single agent with a single hardcoded credential — a `.env` var is simpler
- Need a full identity provider with user authentication — use Keycloak, Auth0, or similar
## Table of Contents
- [Quickstart](#quickstart)
- [How It Works](#how-it-works)
- [Features](#features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Usage](#usage)
- [Docker](#docker)
- [Configuration](#configuration)
- [Key Management](#key-management)
- [Inbound OAuth 2.1 (claude.ai)](#inbound-oauth-21-claudeai)
- [Adding a Connector](#adding-a-connector)
- [API Reference](#api-reference)
- [Testing](#testing)
- [Security](#security)
- [Scaling & Multi-Instance](#scaling--multi-instance)
- [API Stability](#api-stability)
- [Contributing](#contributing)
## Quickstart
Stand up the broker, connect your first OAuth provider (Notion — no credentials needed thanks to RFC 7591 dynamic registration), and make your first proxied MCP request in about five minutes.
```bash
# 1. Clone and install
git clone https://github.com/FirneyGroup/mcp-broker.git
cd mcp-broker
uv sync --extra dev
# 2. Write a fresh .env with the three required secrets
cat > .env <<EOF
BROKER_ADMIN_KEY=$(python -c 'import secrets; print(secrets.token_urlsafe(32))')
BROKER_ENCRYPTION_KEY=$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')
BROKER_STATE_SECRET=$(python -c 'import secrets; print(secrets.token_urlsafe(32))')
EOF
# 3. Start from the example YAML (includes a demo app 'my_company:app1' and 'notion' connector)
cp settings.example.yaml settings.yaml
./start start &
# 4. Create a broker key for the demo app
./start create-key
# Choose 'my_company:app1' — copy the br_* key that prints
# 5. Connect Notion (opens browser for OAuth consent)
./start connect
# Choose 'notion'
# 6. Make your first proxied MCP call (paste the key from step 4 below)
export BROKER_KEY="<paste-broker-key-from-step-4>"
curl -s -X POST \
-H "X-Broker-Key: $BROKER_KEY" \
-H "X-App-Id: my_company:app1" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
http://localhost:8002/proxy/notion/mcp | python3 -m json.tool
```
You should see Notion's tool catalogue. Point any MCP client at `http://localhost:8002/proxy/notion/mcp` with the same headers and it can now call Notion without ever seeing an OAuth token.
Next: [connect more services](#adding-a-connector), [wire the broker into an agent](#api-reference), or [harden the deployment](#security).
## How It Works
```mermaid
sequenceDiagram
participant Agent as MCP Client
participant Broker as mcp-broker
participant Remote as Remote MCP Server
Agent->>Broker: Request + X-Broker-Key + X-App-Id
Note over Broker: 1. Validate X-Broker-Key<br/>2. Check scope + connector<br/>3. Look up OAuth token<br/>4. Inject Authorization header
Broker->>Remote: Request + Authorization: Bearer ...
Remote-->>Broker: Response (streamed)
Broker-->>Agent: Response (streamed)
```
**Proxy flow**: Your MCP client sends MCP requests to the broker with an `X-Broker-Key` header. The broker validates the key, looks up the stored OAuth token for that connector, injects it as a `Bearer` token, and forwards the request. The response streams back unchanged.
**OAuth flow**: An operator runs `./start connect` to initiate an OAuth consent flow in a browser. The broker handles PKCE, state signing, code exchange, and token storage. Once connected, the client can proxy requests without knowing about OAuth.
**Token lifecycle**: Tokens are encrypted at rest (MultiFernet) and refreshed automatically when they expire. A background loop proactively refreshes tokens approaching expiry.
**Native flow**: Some connectors (e.g. Twitter/X) implement MCP tools directly inside the broker — no upstream server. The broker validates, looks up the access token, and dispatches to a tool handler in-process. From the client's perspective the protocol is identical to the proxy flow.
## Features
- **OAuth 2.1 + PKCE** — Full authorization code flow with S256 code challenge for all connectors
- **OAuth discovery** — Automatic endpoint discovery ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) and dynamic client registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) for MCP servers that support it
- **OAuth 2.1 authorization server** — Acts as an AS for remote MCP clients ([claude.ai](https://claude.com/docs/connectors/building/authentication), MCP-spec compliant). RFC 7591 DCR, RFC 8414 + RFC 9728 discovery, PKCE-protected codes, family-revoke refresh rotation. Opt-in via `broker.oauth.enabled` — see [Inbound OAuth 2.1](#inbound-oauth-21-claudeai).
- **Transparent token injection** — Your agent sends requests to the broker; the broker adds Bearer tokens before forwarding
- **Automatic token refresh** — Expired tokens are refreshed with locking to prevent concurrent refresh races
- **Proactive token refresh** — Background loop + admin API refreshes tokens expiring within 10 minutes
- **Encrypted token storage** — Tokens and dynamic registration credentials encrypted at rest with MultiFernet (supports key rotation)
- **Signed OAuth state** — HMAC-signed state parameter with single-use nonces and 10-minute expiry
- **Streaming proxy** — Passes through SSE and Streamable HTTP responses without buffering
- **Pluggable connectors** — Add new OAuth providers by subclassing `BaseConnector` (remote upstreams) or `NativeConnector` (tools implemented in-process); both auto-register on import
- **Hashed API keys** — Per-app broker keys stored as SHA-256 hashes, managed via admin API
- **Scope enforcement** — Per-app scopes (`proxy`, `status`) and connector access control
- **Connect tokens** — Single-use, time-limited tokens for browser OAuth (avoids key exposure in URLs)
- **Multi-tenant** — Per-app OAuth credentials scoped by `client_id:app_id`
## Prerequisites
- Python >= 3.11
## Installation
```bash
git clone https://github.com/FirneyGroup/mcp-broker.git
cd mcp-broker
uv sync --extra dev # or: pip install -e ".[dev]"
```
Copy the example configuration files and fill in your secrets:
```bash
cp settings.example.yaml settings.yaml
cp .env.example .env
```
Generate an encryption key for token storage:
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Add the output to `BROKER_ENCRYPTION_KEY` in `.env`.
## Usage
Start the broker (requires bash — Linux/macOS):
```bash
./start start
```
Or without the start script (any OS):
```bash
PYTHONPATH=src uvicorn broker.main:app --port 8002 --reload
```
Create an API key for your app (broker must be running):
```bash
./start create-key
```
Save the returned `br_*` key — it cannot be retrieved later.
Connect an OAuth provider interactively:
```bash
./start connect
```
Show configuration snippets for your MCP client:
```bash
./start mcp-config # both auth shapes per connector (default)
./start mcp-config --auth=apikey # only the X-Broker-Key headers block
./start mcp-config --auth=oauth # only the OAuth-handshake URL block
```
Two auth paths exist on `/proxy/*` and the broker accepts both simultaneously — the client picks per-request by which header it sends:
| Audience | Auth shape printed | What the client sends |
|---|---|---|
| Trusted internal callers (gateway, ADK `McpServerConfig`, your own scripts) | `--auth=apikey` block — JSON snippet with headers | `X-App-Id` + `X-Broker-Key` (long-lived static secret) |
| Third-party MCP clients (claude.ai custom connectors, Cursor, Cline) | `--auth=oauth` block — URL only, plus broker.oauth state | `Authorization: Bearer mcp_at_...` (short-lived, refreshed automatically) — the client walks RFC 7591 DCR + the authorization-code flow on first connect, you approve a consent page once |
The OAuth block reads `broker.oauth.enabled` and `broker.oauth.allowed_redirect_uris` from `settings.yaml` and surfaces them in the output, so an operator can see at a glance whether the OAuth path is actionable today.
If `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` are set in your `.env` (e.g. when the broker is fronted by a Cloudflare tunnel with a service-token Access policy), the `--auth=apikey` block automatically includes the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers alongside `X-App-Id` and `X-Broker-Key`.
## Docker
Build and run with Docker Compose:
```bash
docker compose up -d
```
The broker runs on port 8002 with data persisted in `./data/`. Configuration is mounted read-only from `settings.yaml`, and secrets are loaded from `.env`.
The shipped `docker-compose.yml` runs the broker as a non-root user (`appuser`, UID 1000) on port 8002.
If you plan to use **sidecar connectors** (e.g. Google Workspace MCP, BigQuery):
1. Create the shared network once: `docker network create sidecar-internal`
2. Uncomment the `networks` blocks in `docker-compose.yml` so the broker joins `sidecar-internal`
3. Each sidecar lives under `sidecars/*` with its own `docker-compose.yml` and is deployed independently: `cd sidecars/<name> && docker compose up -d`
## Configuration
Configuration is split between `settings.yaml` (structure) and `.env` (secrets). The YAML file supports `${VAR_NAME}` interpolation from environment variables.
### Environment Variables
| Variable | Description |
|----------|-------------|
| `BROKER_ADMIN_KEY` | Bootstrap secret for admin API (`X-Admin-Key` header) |
| `BROKER_ENCRYPTION_KEY` | MultiFernet key for encrypting tokens at rest |
| `BROKER_STATE_SECRET` | HMAC secret for signing OAuth state parameters |
| `{CONNECTOR}_CLIENT_ID` | OAuth client ID (static connectors only, e.g. `HUBSPOT_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_ID`, `LINKEDIN_CLIENT_ID`, `REDDIT_CLIENT_ID`, `SLACK_CLIENT_ID`, `TWITTER_CLIENT_ID`) |
| `{CONNECTOR}_CLIENT_SECRET` | OAuth client secret (matching pairs for each static connector above) |
See `.env.example` for the full list of supported connector env vars.
Per-app broker keys are managed via the admin API (`./start create-key`), not stored in YAML or `.env`.
Discovery connectors (e.g. Notion) don't need client ID/secret env vars — credentials are obtained via dynamic registration.
### settings.yaml
```yaml
broker:
host: 0.0.0.0
port: 8002
log_level: INFO
connectors: [hubspot, notion, workspace_mcp]
admin_key: ${BROKER_ADMIN_KEY}
encryption_keys:
- ${BROKER_ENCRYPTION_KEY}
state_secret: ${BROKER_STATE_SECRET}
success_redirect_url: http://localhost:3000
store:
backend: sqlite # sqlite (default) | firestore
sqlite:
db_path: ./data/tokens.db
# For multi-instance deployments (e.g. Cloud Run), use Firestore Native mode —
# see "Scaling & Multi-Instance" below. Authenticates via Application Default
# Credentials; honours FIRESTORE_EMULATOR_HOST for local development.
# backend: firestore
# firestore:
# project_id: my-gcp-project # required
# database: "(default)" # optional — Firestore database name
# collection_prefix: "prod_" # optional — namespaces collections per environment
# Per-app auth config — scopes and connector access control
clients:
my_company:
app1:
scopes: [proxy, status]
allowed_connectors: [hubspot, notion] # empty list = all connectors
# Per-app OAuth credentials (static connectors only)
apps:
my_company:
app1:
hubspot:
client_id: ${HUBSPOT_CLIENT_ID}
client_secret: ${HUBSPOT_CLIENT_SECRET}
```
## Key Management
API keys are managed via the admin API or CLI commands. Keys are stored as SHA-256 hashes — the raw key is shown once on creation and cannot be retrieved.
### CLI Commands
CLI commands require bash (Linux/macOS). The `./start` script auto-creates a virtualenv on first run.
```bash
./start generate-admin-key # Generate BROKER_ADMIN_KEY (offline, no broker needed)
./start list-keys [url] # List all apps and key status
./start create-key [url] # Create key for an app (interactive)
./start rotate-key [url] # Rotate an existing key (interactive)
./start delete-key [url] # Delete a key (interactive, with confirmation)
```
All key commands (except `generate-admin-key`) require the broker to be running and `BROKER_ADMIN_KEY` set in `.env`. The optional `[url]` argument targets a remote broker instead of localhost.
### Admin API
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/admin/keys` | Create key (`{"app_key": "client:app"}`) |
| `GET` | `/admin/keys` | List all apps with `has_key` status |
| `POST` | `/admin/keys/{app_key}/rotate` | Rotate key (returns new key) |
| `DELETE` | `/admin/keys/{app_key}` | Delete key |
| `POST` | `/admin/connect-token` | Create single-use browser OAuth token |
| `POST` | `/admin/refresh` | Refresh all tokens expiring within 10 minutes |
| `POST` | `/admin/oauth/revoke/{app_key}` | Revoke an app's inbound OAuth tokens (kick claude.ai) — keeps the broker key |
| `DELETE` | `/admin/connections/{app_key}/{connector}` | Disconnect an app's upstream connector (operator-initiated) |
All admin endpoints require the `X-Admin-Key` header.
## Inbound OAuth 2.1 (claude.ai)
The broker can act as an **OAuth 2.1 authorization server** for remote MCP clients that follow the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) — notably [claude.ai's custom connectors](https://claude.com/docs/connectors/building/authentication). When enabled, the broker exposes RFC 7591 Dynamic Client Registration, RFC 8414 + RFC 9728 discovery, PKCE-protected authorization code flow, and family-revoke refresh rotation — and validates `Authorization: Bearer mcp_at_...` on every `/proxy/*` request.
This is **opt-in** (`broker.oauth.enabled: false` by default). The existing `X-App-Id` + `X-Broker-Key` header auth keeps working unchanged.
### Prerequisites
- **Public HTTPS URL.** Anthropic egresses from `160.79.104.0/21`; the broker must be reachable from the public internet over HTTPS.
- **Single-worker uvicorn — unless on the Firestore backend.** With the default `sqlite` backend the DCR rate limiter and OAuth flow state are in-memory, so the broker aborts at startup if `WEB_CONCURRENCY > 1` and `oauth.enabled=true`. With `store.backend: firestore` that state is shared across workers and instances, multi-worker is supported, and the abort does not fire (see [Scaling & Multi-Instance](#scaling--multi-instance)).
- **HTTP/1.1 origin protocol** if behind Cloudflare Tunnel. HTTP/2 lowercases header names; claude.ai does a case-sensitive lookup for `WWW-Authenticate` ([anthropics/claude-ai-mcp#219](https://github.com/anthropics/claude-ai-mcp/issues/219)) and silently fails discovery. In `cloudflared` config (or Zero Trust dashboard → Networks → Tunnels → Additional application settings → TLS):
```yaml
originRequest:
http2Origin: false
```
- **An `app_key` registered in `clients:`** with a broker key already provisioned (via `./start create-key` or `POST /admin/keys`). OAuth tokens are minted against this single `app_key` — every claude.ai-issued token grants access to that app's connectors.
### Configuration
Add to `settings.yaml` under `broker:`:
```yaml
broker:
oauth:
enabled: true
app_key: my_company:app1 # must exist in `clients:` below
db_path: ./data/inbound_oauth.db
# Redirect URIs accepted at DCR + consent. HTTPS-only, exact-match.
# Defaults to claude.ai's two callbacks; extend if you're integrating
# a different MCP client. Loopback / arbitrary-HTTPS support is a
# v1.5 item.
# allowed_redirect_uris:
# - https://claude.ai/api/mcp/auth_callback
# - https://claude.com/api/mcp/auth_callback
# IPs the broker trusts to set X-Forwarded-For (e.g. CF Tunnel egress,
# Tailscale Funnel, an nginx reverse proxy). Exact-match strings — no
# CIDR. Default-empty means the raw socket IP is used for per-IP rate
# limiting; only populate this if a trusted proxy sits between the
# broker and clients, otherwise an attacker could spoof XFF to bypass
# the limit.
# trusted_proxy_ips:
# - 198.51.100.42
# access_token_ttl_seconds: 3600
# refresh_token_ttl_seconds: 2592000 # 30 days
# code_ttl_seconds: 60
# dcr_rate_limit_per_ip: 10
# dcr_rate_limit_window_seconds: 900
```
The settings validator rejects an `app_key` that does not exist in `clients` (loud at startup).
Restart the broker. Inbound OAuth state lives in `data/inbound_oauth.db`, separate from `data/broker_keys.db` and `data/tokens.db`. (On the Firestore backend it lives in Firestore collections instead and `db_path` is ignored.)
### Verification
Run from outside the broker network (so edge auth, if any, surfaces):
```bash
BROKER=https://broker.example.com
# Public endpoints — should be 200 or 4xx, NEVER 403 from an edge-auth challenge
curl -fsS $BROKER/.well-known/oauth-authorization-server -o /dev/null -w "%{http_code}\n" # 200
curl -fsS $BROKER/.well-known/oauth-protected-resource/proxy/notion -o /dev/null -w "%{http_code}\n" # 200
curl -fsS $BROKER/oauth/register -X POST -H 'content-type: application/json' -d '{}' -o /dev/null -w "%{http_code}\n" # 400
curl -fsS $BROKER/oauth/token -X POST -d 'grant_type=authorization_code' -o /dev/null -w "%{http_code}\n" # 400
curl -fsS $BROKER/oauth/revoke -X POST -d 'token=x' -o /dev/null -w "%{http_code}\n" # 200
curl -fsS $BROKER/proxy/notion/mcp -o /dev/null -w "%{http_code}\n" # 401
# WWW-Authenticate case check (claude.ai bug #219)
curl -v $BROKER/proxy/notion/mcp 2>&1 | grep -i 'www-authenticate'
# MUST show "WWW-Authenticate: Bearer ..." with CAPITAL W. Lowercase → fix origin protocol.
```
If any public endpoint returns 403, the deployment's edge auth is blocking — exempt those paths before continuing.
### Adding the connector in claude.ai
1. Open claude.ai → Settings → Connectors → **Add custom connector**.
2. URL: `https://<broker-host>/proxy/<connector>/mcp` (e.g. `…/proxy/notion/mcp`).
3. Name: anything memorable.
4. Click **Connect**. Claude.ai will:
- Probe `/proxy/<connector>/mcp` → 401 with `WWW-Authenticate: Bearer resource_metadata=…`.
- Fetch the PRM at the `resource_metadata` URL.
- Fetch `/.well-known/oauth-authorization-server`.
- `POST /oauth/register` with `client_name: "Claude"` and redirect URI `https://claude.ai/api/mcp/auth_callback`.
- Redirect the user's browser to `/oauth/authorize?…`.
5. The broker renders a consent page; click **Approve**.
6. Claude.ai exchanges the auth code at `/oauth/token` and starts using bearer tokens for tool calls.
### Operator workflow notes
- **Disabling**: flip `oauth.enabled: false` and restart. Existing tokens stop validating; the legacy header-auth paths continue working. To fully revoke all issued state, also delete `data/inbound_oauth.db`.
- **Broker-key revocation cascades**: `DELETE /admin/keys/{app_key}` now also drops the app's `inbound_tokens` and `oauth_codes` rows, so re-provisioning the same `app_key` after a compromise cannot silently regain bearer access.
- **DCR row growth**: claude.ai re-registers a client on every fresh connection. Rate limit (10 per IP per 15 min) caps volume; for a busy deployment, run `DELETE FROM oauth_clients WHERE created_at < datetime('now', '-30 days')` periodically. (A scheduled cleanup is a v1.5 item.)
### Troubleshooting
| Symptom | Likely cause |
|---|---|
| claude.ai shows "Connection failed" with no further detail | Discovery silently failed. Check `WWW-Authenticate` case (`curl -v`). If lowercase, force HTTP/1.1 origin. |
| `/oauth/authorize` returns 400 `invalid_target` | The `resource` parameter claude.ai sent does not match `{public_url}/proxy/{connector}`. Confirm `broker.public_url` matches the hostname you pasted into claude.ai (including scheme + trailing slash). |
| `/oauth/token` returns 400 `invalid_grant` after a refresh | Either the refresh token expired, or replay was detected and the family was revoked. Reconnect from claude.ai to re-authorise. |
| Broker aborts at startup with `OAuth enabled but WEB_CONCURRENCY > 1` | On the `sqlite` backend the in-memory DCR rate limiter requires single-worker uvicorn. Drop `--workers` to 1, set `WEB_CONCURRENCY=1`, or switch to `store.backend: firestore` (which shares that state and lifts the restriction). |
| `/oauth/authorize` shows the consent page but POST fails | On the `sqlite` backend the consent page must reach the same broker instance (no load balancer in front splitting traffic) — single-instance only. On the Firestore backend flow state is shared, so any instance can serve the POST. |
### Known claude.ai bugs the broker works around
- **[#82](https://github.com/anthropics/claude-ai-mcp/issues/82)** — claude.ai ignores `authorization_endpoint` from AS metadata and derives `/oauth/authorize` from the MCP base URL. Works in our favour: the AS lives at the same host as `/proxy/*`.
- **[#219](https://github.com/anthropics/claude-ai-mcp/issues/219)** — case-sensitive `WWW-Authenticate` header lookup. Mitigated by forcing HTTP/1.1 to origin (see prerequisites).
- **[#52871](https://github.com/anthropics/claude-code/issues/52871)** — claude.ai's WHATWG URL parser adds a trailing slash to host-only resource URLs. `normalize_resource()` strips trailing slashes symmetrically before comparison so the mismatch never surfaces.
## Adding a Connector
All connectors auto-register via `__init_subclass__` on import. Four flavours exist — pick one before writing code:
| Flavour | When to use | Where the MCP server runs | Where credentials come from |
|---------|-------------|---------------------------|------------------------------|
| **Static** | Remote OAuth 2.1 MCP server with fixed endpoints | Remote (e.g. `https://mcp.example.com/mcp`) | `settings.yaml` `apps` section (client_id + client_secret) |
| **Discovery** | Remote MCP server supporting RFC 8414 + RFC 7591 | Remote | Auto-registered on first `/connect`; no `settings.yaml` entry |
| **Sidecar** | MCP server runs as a local Docker container next to the broker | Local (`http://<container>:8000/mcp`) | Either broker-managed OAuth (`auth_mode="broker"`) or sidecar-managed (`auth_mode="sidecar"`) |
| **Native** | No MCP server exists — wrap a provider's SDK / REST API in-process | In-process (broker serves MCP directly) | `settings.yaml` `apps` section; broker passes access tokens to each tool handler |
**Quick start:**
1. Read [AGENTS.md § Provider Onboarding](AGENTS.md#provider-onboarding) and pick a flavour by running the probes.
2. Copy the matching template from `src/connectors/_template/{flavour}/` to `src/connectors/{your_name}/`.
3. Rename the class, replace every `FILL_ME_IN`, and follow `SETUP.md` in the copied directory.
4. Add `{your_name}` to `broker.connectors` in `settings.example.yaml` (and `apps` entries for Static / Sidecar-broker / Native).
5. Add a test at `tests/test_{your_name}_connector.py`.
6. Run `pytest tests/ -v` — all green before opening a PR.
**Reference examples:**
- Static → `src/connectors/hubspot/adapter.py`
- Discovery → `src/connectors/notion/adapter.py` (HTTP Basic Auth + Notion-Version header)
- Sidecar → `src/connectors/workspace_mcp/adapter.py` + `sidecars/workspace-mcp/`
- Native → `src/connectors/twitter/adapter.py` (xdk SDK wrapped with `run_in_executor`)
**Reviewers check against** [AGENTS.md § Connector Rules](AGENTS.md#connector-rules-must) — every `MUST` in that section is enforced on PR review.
## API Reference
### Proxy
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET/POST/PUT/DELETE` | `/proxy/{connector}/{path}` | `X-Broker-Key` + `X-App-Id` | Forward request to remote MCP server with token injection |
### OAuth
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/oauth/{connector}/connect` | Headers or `connect_token` query param | Start outbound OAuth authorization flow (broker → MCP server) |
| `GET` | `/oauth/{connector}/callback` | Signed state | Outbound OAuth callback (called by upstream provider) |
| `POST` | `/oauth/{connector}/disconnect` | `X-Broker-Key` + `X-App-Id` | Delete stored upstream token |
| `POST` | `/oauth/register` | None (rate-limited per IP) | RFC 7591 Dynamic Client Registration — inbound OAuth (opt-in) |
| `GET`/`POST` | `/oauth/authorize` | PKCE + DCR-issued `client_id` | Inbound authorization code endpoint with consent page |
| `POST` | `/oauth/token` | `client_id` (+ secret for confidential) | Inbound token endpoint (auth_code + refresh_token grants) |
| `POST` | `/oauth/revoke` | `client_id` (+ secret for confidential) | RFC 7009 revocation |
| `GET` | `/.well-known/oauth-authorization-server` | None | RFC 8414 AS metadata |
| `GET` | `/.well-known/oauth-protected-resource/{path}` | None | RFC 9728 PRM (per-connector) |
The `/connect` endpoint supports two auth modes:
- **API client**: `X-App-Id` + `X-Broker-Key` headers
- **Browser**: `connect_token` query param (from `POST /admin/connect-token`, single-use, 5-minute TTL)
### Status
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/status` | `X-Broker-Key` + `X-App-Id` | List connections and token health for an app |
| `GET` | `/health` | None | Health check with registered connectors |
## Testing
```bash
./start test
```
Or directly:
```bash
uv run pytest tests/ -v
```
## Security
The broker implements defense-in-depth for OAuth credential management:
- **API keys hashed at rest** — SHA-256. Raw key shown once on creation, never retrievable.
- **Inbound OAuth tokens hashed at rest** — SHA-256, compared via `hmac.compare_digest`. Raw values surface only at issuance.
- **Refresh rotation with family revoke** — OAuth 2.1 §4.3.1; replay of a used refresh token deletes every access + refresh token in the family.
- **Strict redirect URI allowlist** — only `https://claude.ai/api/mcp/auth_callback` and `https://claude.com/api/mcp/auth_callback` accepted; arbitrary HTTPS rejected in v1.
- **Tokens encrypted at rest** — MultiFernet encryption with key rotation support (outbound upstream tokens; inbound tokens use one-way hashing instead since the broker never replays them)
- **PKCE (S256)** — All OAuth flows use Proof Key for Code Exchange
- **Signed state parameters** — HMAC-signed with single-use nonces and 10-minute expiry
- **Per-app key isolation** — Compromised broker key only affects that app's tokens
- **Scope enforcement** — `proxy` and `status` scopes checked at every endpoint
- **Connector access control** — `allowed_connectors` restricts which connectors an app can reach
- **Connect tokens** — Single-use, 5-minute TTL tokens for browser OAuth (avoids raw key in URLs, browser history, and proxy logs)
- **Identity substitution prevention** — Middleware cross-checks verified key against claimed `X-App-Id`
- **Internal headers stripped** — `X-Broker-Key`, `X-App-Id`, `Authorization` never forwarded to remote servers
- **Timing-safe comparison** — `hmac.compare_digest` for admin key validation
- **SSRF prevention** — Discovery rejects private/loopback addresses
### Securing the Admin API
The public OAuth and proxy endpoints (`/oauth/*`, `/proxy/*`) are built to face the internet directly — every request carries its own cryptographic auth (PKCE, bearer / broker-key, signed state). **`/admin/*` is not.** It bypasses the broker-key middleware and is gated only by a single static `X-Admin-Key` (timing-safe comparison), with **no rate limiting or lockout**. Those endpoints mint, rotate, and **delete** broker keys, mint connect tokens, and trigger token maintenance — i.e. full control over every app's access.
**Always keep `/admin/*` behind a network or identity gate. Never expose it raw to the public internet.** Choose one:
- **Edge zero-trust** — e.g. Cloudflare Access with a service-token policy (the broker forwards `CF-Access-Client-*` headers for trusted internal callers; see [Inbound OAuth → Verification](#verification)).
- **Platform IAM** — on Cloud Run, require an OIDC token (do not `allow-unauthenticated`), or set ingress to internal / internal-and-load-balancing so `/admin` is unreachable from the open internet. Drive any scheduled admin calls (e.g. `POST /admin/refresh`) with a Cloud Scheduler OIDC token rather than the static admin key alone.
- **Reverse proxy** — an IP allowlist and/or auth layer in front of `/admin/*`.
Use a strong random `BROKER_ADMIN_KEY` (the [Quickstart](#quickstart) generates one) and store/rotate it via a secrets manager — it is the one credential that, alone, grants full administrative control.
### Known Limitations
- **No rate limiting** — Consider [slowapi](https://github.com/laurents/slowapi) or an upstream reverse proxy for production. (Inbound DCR has its own per-IP limit; nothing else does.)
- **Single-instance state on the default backend** — With `store.backend: sqlite`, OAuth nonces, PKCE verifiers, and connect tokens are in-memory and the broker must run as a single instance. The Firestore backend moves this state to shared storage (see [Scaling & Multi-Instance](#scaling--multi-instance)).
See [SECURITY.md](SECURITY.md) for vulnerability reporting.
## Scaling & Multi-Instance
The broker has two storage backends, selected via `store.backend` in `settings.yaml`:
- **`sqlite`** (default) — zero-config, single-instance. State lives in local `.db` files plus in-memory dicts.
- **`firestore`** — Firestore Native mode, for multi-instance deployments (e.g. Cloud Run with multiple replicas or `WEB_CONCURRENCY > 1`). All persistent and flow state is shared across instances. Authenticates via Application Default Credentials; the service account needs Firestore read/write (`roles/datastore.user`). Honours `FIRESTORE_EMULATOR_HOST` for local development.
```yaml
store:
backend: firestore
firestore:
project_id: my-gcp-project
database: "(default)" # optional
collection_prefix: "prod_" # optional — namespaces collections per environment
```
### State by Backend
| Component | `sqlite` (default) | `firestore` |
|-----------|--------------------|-------------|
| Outbound token + registration store | SQLite + MultiFernet encryption, single-instance | Firestore + MultiFernet encryption, shared |
| Broker key store | SQLite + SHA-256 hashing, single-instance | Firestore + SHA-256 hashing, shared |
| Inbound OAuth store (codes, bearer/refresh tokens, DCR clients) | SQLite, single-instance | Firestore with atomic refresh rotation, shared |
| Outbound OAuth nonces + PKCE verifiers | In-memory, single-process | Firestore, shared |
| Connect tokens | In-memory, single-process | Firestore, shared |
| DCR rate limiter | In-memory, single-process | Firestore, shared |
| Token refresh locks | `asyncio.Lock`, per-process | `asyncio.Lock`, per-process |
| Discovery metadata cache | In-memory, per-instance | In-memory, per-instance |
The last two rows stay per-instance on both backends. The discovery cache is a pure cache — each instance re-fetches `.well-known` metadata independently. Refresh locks only serialize refreshes within one process; two instances can still refresh the same token concurrently, which is benign for providers that tolerate refresh-token reuse but may cause a transient `invalid_grant` (and re-auth) with strict-rotation providers.
The startup abort for inbound OAuth (`broker.oauth.enabled=true` + `WEB_CONCURRENCY > 1`) fires only on non-Firestore backends — see [Inbound OAuth prerequisites](#prerequisites-1).
## API Stability
**This project is pre-1.0.** Minor version bumps (`0.x` → `0.y`) may contain breaking changes to:
- The HTTP API surface (`/proxy`, `/oauth`, `/admin`, `/status`)
- The `BaseConnector` extension contract and `ConnectorMeta` fields
- `settings.yaml` schema and environment variable names
- The `./start` CLI subcommands and output formats
Patch bumps (`0.x.y` → `0.x.z`) are bug fixes and documentation only — safe to upgrade.
In production, **pin to a specific tag** (e.g. `git clone --branch v0.1.0` or `pip install mcp-broker==0.1.0` once published) rather than tracking `main`. The [CHANGELOG](CHANGELOG.md) documents every breaking change.
The 1.0 release will signal stable HTTP and connector APIs with semver-honest compatibility guarantees going forward.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, PR process, and code style. This project adopts the [Contributor Covenant v2.1](CODE_OF_CONDUCT.md). Security issues follow the private-reporting flow in [SECURITY.md](SECURITY.md).
## License
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details.
Copyright 2026 Firney Ltd.
Connection Info
You Might Also Like
everything-claude-code
Complete Claude Code configuration collection - agents, skills, hooks,...
markitdown
MarkItDown-MCP is a lightweight server for converting URIs to Markdown.
cc-switch
All-in-One Assistant for Claude Code, Codex & Gemini CLI across platforms.
servers
Model Context Protocol Servers
servers
Model Context Protocol Servers
Time
A Model Context Protocol server for time and timezone conversions.