ADR: MCP-Native OAuth (auto-connect any MCP server)¶
Status: Accepted · Milestone: M6 · Supersedes nothing (extends M4)
Context¶
M4 gave the Gateway per-user OAuth: an admin pre-registers an OAuth app with a provider and
pastes client_id + client_secret + authorize_url + token_url into a connector's
oauth_app_config. That works against a provider's API OAuth (e.g. api.notion.com), but not
against the provider's own MCP server. A live probe (2026-08-06) confirmed that
mcp.notion.com, mcp.linear.app, and api.githubcopilot.com/mcp all implement the MCP
Authorization spec instead:
OAuth 2.1 + PKCE (RFC 7636) + Dynamic Client Registration (RFC 7591) + protected-resource metadata discovery (RFC 9728) + authorization-server metadata (RFC 8414) + resource indicators (RFC 8707).
A Notion API token (ntn_…) is rejected by mcp.notion.com (invalid_token) — different
audience / auth server. So M4's static path cannot connect a provider's MCP server at all.
Goal: teach the Gateway the MCP Authorization spec so an admin registers a connector by URL alone and any spec-compliant MCP server works with zero per-provider config — while every existing static connector keeps working unchanged.
Decision¶
1. Connector shape — auth_mode inside the encrypted config¶
We keep auth_type='oauth' and add an auth_mode discriminator inside the Fernet-encrypted
auth_config_encrypted blob (not a new column — avoids a migration and keeps the secret-bearing
fields in one encrypted place):
auth_mode |
Meaning | Key fields stored (encrypted) |
|---|---|---|
absent / static |
Classic M4 OAuth app | client_id, client_secret, authorize_url, token_url, scopes, token_auth, token_request_format |
mcp |
Auto-configured spec server | authorize_url, token_url (discovered), resource, authorization_server, scopes, client_id (+ client_secret if confidential), token_endpoint_auth_method, registration_access_token, registration_client_uri, token_auth |
The consent core (connectors/oauth.py) reads the same config dict for both modes, so
build_authorize_url / exchange_code / refresh_user_token are mode-agnostic. A public
(PKCE-only) DCR client sets token_auth="none" — client_id in the token body, no secret.
2. When discovery + DCR run — eager, at registration¶
Discovery (RFC 9728 → RFC 8414) and DCR (RFC 7591) run at connector registration, alongside the existing tool discovery, and the resolved config is cached (encrypted) on the connector:
- A credential-less register (no
oauth_app_config) is probed; a spec OAuth resource is auto-upgraded to anauth_mode='mcp'connector, a plain upstream stays as-is. - Gated on
OAUTH_CALLBACK_URLbeing configured (the DCRredirect_uri); unset → probe skipped (pre-M6 behavior), so rollout is controlled by deploy config. - What counts as a spec OAuth resource: a
401to an unauthenticatedGET. When the server refusesGETwith another 4xx (the streamable HTTP transport allows a 405), an unauthenticatedinitializeis tried and a401there counts. When that is served too, the server counts if it publishes protected-resource metadata at the well-known path for its own URL, naming an authorization server and the sameresource. A server that needs no authentication publishes none and is registered as a plain server. - Auto-config is best-effort: discovery/DCR failure flags the connector
status='error'rather than rejecting registration — mirroring tool discovery. - Re-registration: on
invalid_client/ expired DCR client at consent time, re-run DCR once and retry. The exchange matches on the RFC 6749 error code the provider sent, never on message text. Re-discovery also triggers when the storedauthorization_serverno longer matches the upstream's advertised one. - No Dynamic Client Registration: a spec OAuth resource whose auth server offers no DCR endpoint
(or refuses DCR) is registered and flagged
status='error'. An admin fixes it by supplyingclient_idandclient_secretin the Console; the endpoints are re-derived from discovery and merged, so the admin never has to know them. - Tool discovery at registration: a
401/403fromtools/liston anoauthconnector is expected before any account is connected, so the connector staysactive; tools are discovered when the first user completes consent. A non-OAuth connector answering401/403, and any unreachable upstream, is still flaggederror.
Rejected alternative — lazy (first-consent) discovery: pushes network latency + failure onto the end user's connect click and complicates the browser-facing flow. Eager keeps consent fast and surfaces misconfiguration to the admin at registration.
3. code_verifier storage — short-lived server-side, keyed by signed state¶
PKCE requires the code_verifier (generated at authorize time) to survive until the token exchange,
and it must never reach the browser. The BFF is stateless on Cloud Run and its state is
Ed25519-signed but browser-visible, so the verifier cannot ride in state.
Decision: the transformer generates the PKCE pair, stores the code_verifier in a
short-lived server-side record keyed by the signed state (TTL ~10 min, single-use, deleted on
exchange), and returns only the authorize URL (carrying the code_challenge) to the BFF. On
callback the BFF passes state back; the transformer looks up + consumes the verifier. The secret
stays entirely server-side in the transformer, next to the other OAuth material.
Rejected alternatives:
- Signed-state-carried verifier — would expose the verifier to the browser (state is a visible query param); signing ≠ encryption. Rejected on security grounds.
- httpOnly SameSite=None cookie on the BFF — viable, but adds cross-site-cookie fragility (the
same class of issue that already forced us to trust signed
stateover the session cookie), and spreads PKCE state across two services. Rejected for simplicity + robustness.
4. Resource indicator (RFC 8707) + scope selection¶
- The canonical
resourcecomes from the protected-resource metadata (falling back to the probed MCP URL) and is sent on both/authorizeand/token(and on refresh where the auth server requires it) so tokens are audience-bound to this MCP server. - Scopes: use the auth server's
scopes_supportedwhen advertised; otherwise omitscopeand let the server apply its default. No per-provider scope config.
5. Backward-compatibility guarantees¶
- A connector with
oauth_app_config(static creds) is untouched — same authorize URL, same exchange, no PKCE unless opted in. The Notion API app connector keeps working verbatim. - All new consent-core parameters (
code_challenge,resource,code_verifier) are optional; absent them the behavior is byte-for-byte M4. - The mcp-proxy forward path and token refresh are unchanged: they already carry the per-user vault
token. The Gateway resolves that token for every forwarded method of an
oauthconnector, not onlytools/call, because an OAuth upstream rejectsinitializeandtools/listwithout it. On a discovery method, a user with no connected account gets a JSON-RPC error carrying the consent_metainstead of the provider's opaque401(atools/callgets the input-required result); the policy gate still runs before the vault is read for atools/call.
6. A stored header — auth_type='static_header'¶
An upstream that checks one fixed token (a read-only server that answers 401 to a call without it)
cannot be reached with each person's own OAuth. For it a connector has auth_type='static_header': an
admin stores a header name and value once, and the Gateway sends that header on every request it
forwards to the connector's upstream.
The accepted auth_type values are none, oauth and static_header; each has a behaviour in the
Gateway, and a test holds the registry's list equal to the Gateway's. There is no api_key type.
Setting it in the dashboard. An organisation admin sets the header on Protect, Connectors:
- Registering. In the "Add custom connector" dialog, below Transport, choose "Header" in the
"Authentication" select (the default, "None, or OAuth when the server asks for it", registers the
server as before), then fill "Header name" (
Authorizationby default) and "Header value". The value is a password field and is never shown again. - Replacing. The connector page of a
static_headerconnector has an "Organization credential" section. It says "Value stored" when a value is held, and "Replace" opens a form for a new name and a new value, sent together. The section never shows the value, and does not show the name either, because the API returns neither. - A refusal is shown as the registry wrote it: a name the Gateway never sends (
422), or no persistent encryption key to store the value under (503). A request that never arrived is reported as such, with nothing saved. - The header can be set from the dashboard only. A token issued to an MCP client cannot register a connector with a header or change one.
Setting it through the API.
POST /api/v1/connectors
{ "name": "excel", "upstream_url": "https://…/mcp",
"auth_type": "static_header",
"static_header": { "name": "Authorization", "value": "Bearer …" } }
PATCH replaces it with auth_type and static_header together; setting any other auth_type deletes
it.
- Stored Fernet-encrypted in the connector row's
auth_config_encrypted, under the same key asoauth_app_config. Registration and replacement fail with503whenCOALEX_CONNECTOR_AUTH_ENCRYPTION_KEYis not set, because a value stored under a key that does not survive a restart would be lost without anything saying so. - Never read back. The routes that return a connector (get, list, create, update) carry
has_authandheader_name, the name of the header, which is not a secret and has a column of its own, filled when the header is stored. They never carry the value.header_nameisnullfor a connector of any otherauth_typeand for a header stored before that column existed, until the header is replaced; the connector page then says the name is not shown. A validation error on the connector routes does not echo the request. - Refused names. A name the Gateway would never send is refused at registration:
Host,Content-Length,Content-Type,Accept, the hop-by-hop headers (Connection,Keep-Alive,Transfer-Encoding,TE,Trailer,Upgrade, theProxy-*headers) and the MCP protocol headers (Mcp-Method,Mcp-Name,Mcp-Protocol-Version,Mcp-Session-Id). A value must be visible ASCII on one line. - One internal hop. The value leaves the registry only on
POST /v1/connectors/{id}/resolve-static-header, which the Gateway calls on every request and which is refused to an agent's SDK key and to a dashboard session. The Gateway keeps nothing between requests, so replacing or deleting the header applies to the next call. It never logs the answer, and tool discovery, which the registry runs itself, scrubs the value from any error text.
What an upstream receives. Every forwarded request (initialize, tools/list, tools/call, and the
replay of an approved call) carries the stored header, set by the Gateway after the caller's headers are
filtered. A header of the same name from the caller is dropped, and the caller's own Authorization
(the proxy token) is never forwarded. A redirect to another origin does not carry the header. When the
header cannot be read, the call fails and nothing is forwarded. Per-tool policy applies unchanged: a
blocked tool is refused and a require_approval tool is held before the header is read.
Who may call. The proxy token is the only control. Any caller holding a token scoped to the connector
acts with the stored credential. The Gateway does not verify which agent calls: the agent id and the
request id are the caller's word. Issue one token per consumer, so that each can be revoked alone
(DELETE /v1/connectors/{id}/tokens/{token_id}).
Audit. Each proxied call on the connector writes a row whose policy_inputs names the
proxy_token_prefix of the token that made it, upstream_auth: static_header and
agent_verified: false. The caller's request id is recorded with request_id_source: caller when it sent
one, and the row of an approved call's replay adds approved_by, the person who approved it. The row never
says the agent is verified.
Consequences¶
- An admin pastes an MCP URL → a working, per-user-auth connector, no client credentials entered.
- One code path serves both static and MCP-native connectors; the divergence is data, not branches.
- New surfaces to operate: the DCR client lifecycle (re-registration) and the short-lived verifier store (TTL/cleanup). Both are server-side and covered by tests.
Implementation sequencing¶
| Slice | Status |
|---|---|
| Metadata discovery (WWW-Authenticate → protected-resource → auth-server) | ✅ |
| Dynamic Client Registration (RFC 7591) | ✅ |
| PKCE (RFC 7636) + resource indicator (RFC 8707) in the consent core | ✅ |
| Auto-config connector registration by URL | ✅ |
| Wire discovery + DCR + PKCE into the per-user consent flow (verifier store) | ✅ |
| End-to-end tests against a mock spec-compliant provider | ✅ |
| Gate: connect Notion + Linear zero-config, two users, per-user attribution | ✅ |
References¶
MCP Authorization spec; RFC 9728 (protected-resource metadata), RFC 8414 (auth-server metadata), RFC 7591 (Dynamic Client Registration), RFC 8707 (resource indicators), OAuth 2.1 / RFC 7636 (PKCE).