Skip to content

ADR: MCP-Native OAuth (auto-connect any MCP server)

Status: Accepted · Milestone: M6 · Epic: COA-1874 · 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 an auth_mode='mcp' connector, a plain upstream stays as-is.
  • Gated on OAUTH_CALLBACK_URL being configured (the DCR redirect_uri); unset → probe skipped (pre-M6 behavior), so rollout is controlled by deploy config.
  • 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 (COA-1880). Re-discovery also triggers when the stored authorization_server no longer matches the upstream's advertised one.

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 state over the session cookie), and spreads PKCE state across two services. Rejected for simplicity + robustness.

4. Resource indicator (RFC 8707) + scope selection

  • The canonical resource comes from the protected-resource metadata (falling back to the probed MCP URL) and is sent on both /authorize and /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_supported when advertised; otherwise omit scope and 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 (COA-1325) and token refresh (COA-1565) are unchanged — they already carry the per-user vault token.

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 the sub-issue tests.

Implementation sequencing (sub-issues)

Issue Slice Status
COA-1876 Metadata discovery (WWW-Authenticate → protected-resource → auth-server)
COA-1877 Dynamic Client Registration (RFC 7591)
COA-1878 PKCE (RFC 7636) + resource indicator (RFC 8707) in the consent core
COA-1879 Auto-config connector registration by URL
COA-1880 Wire discovery + DCR + PKCE into the per-user consent flow (verifier store)
COA-1881 End-to-end tests against a mock spec-compliant provider
COA-1882 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).