Skip to content

OAuth 2.1 + Streamable HTTP for MCP (Wave 2)

ADR-024 — OAuth 2.1 + Streamable HTTP for MCP (Wave 2)

Status: Accepted — 2026-04-24 Authors: Engineering (Claude + Mishaal) Supersedes: ADR-012 “NHI primitives deferred” (partial — OAuth 2.0 → 2.1 path documented) Plan reference: Wave 2 of the Cloud-Native v2 Engineering Plan tasks 2.1–2.10.

Context

MCP spec 2025-11-25 (current latest as of 2026-04-24; see docs/architecture/research/mcp-cutting-edge-2026-04-24.md) tightens three items that directly touch the V5 gateway /mcp endpoint:

  1. Transport evolution. Streamable HTTP is now the only normatively supported HTTP transport. SSE (2024-11-05) is formally deprecated — new servers MUST NOT ship SSE-only paths. Of 216 commercial servers live in Anthropic’s registry today, 211 (97%) speak Streamable HTTP; the four SSE holdouts are stragglers (Plaid, WordPress preview, Buildkite).
  2. Authorization. OAuth 2.1 (draft-ietf-oauth-v2-1-13) is the floor — PKCE S256 only, RFC 8707 resource indicators, RFC 9728 Protected Resource Metadata, and typically RFC 9068 JWT access tokens. “token passthrough” is an explicit anti-pattern.
  3. Elicitation. URL mode (§client/elicitation) lets servers kick off third-party OAuth flows inline in-chat instead of requiring users to paste tokens manually.

The V5 gateway shipped an OAuth 2.0 subset in March 2026 for Claude Web, ChatGPT, Cursor, and Perplexity (bearer auth + PKCE S256 + auth_code grant + opaque access tokens in KV). That implementation was spec-correct for the 2024-11-05 MCP revision but lacks:

  • RFC 9728 Protected Resource Metadata document
  • RFC 8707 resource indicator enforcement
  • RFC 9068 JWT access tokens (self-validating, aud/iss/exp in the token)
  • refresh-token rotation
  • revocation endpoint
  • scope granularity (everything was implicit “all-28-tools”)
  • URL-mode elicitation
  • server-card advertisement
  • per-invocation scope/auth-method telemetry

Wave 2 closes all of the above in the same PR so we don’t partially-migrate.

Decision

We implement Wave 2 tasks 2.1–2.10 under a single OAuth 2.1 authorization server + Streamable HTTP transport on the existing gateway, with a 30-day bearer-auth backward-compat window during which both auth methods are accepted so no n8n workflow or legacy caller breaks on merge.

Concretely (files shipped in this PR)

#SurfaceFile(s)
2.1Streamable HTTP transport with content negotiationsrc/handlers/mcp-streamable.ts, content-negotiation plumbing in src/index.ts
2.2OAuth 2.1 AS endpoints (register, authorize, token, revoke, discovery)src/handlers/oauth-server.ts — extended in place; JWT helper in src/lib/jwt.ts
2.3D1 persistence for clients + refresh tokensmigrations/0004_oauth_clients.sql
2.4MCP Elicitation URL modesrc/handlers/oauth-server.ts (/oauth/elicit/{flow}, buildUrlElicitationRequiredError())
2.6Per-tenant MCP Server Portal scaffold + runbookportal/, docs/runbooks/mcp-server-portal-deploy.md
2.7Anthropic MCP Registry submission scriptscripts/register-mcp.ts
2.8.well-known/mcp-server-cardsrc/handlers/mcp-server-card.ts
2.9Per-tool OAuth scopes + enforcement middlewaresrc/core/oauth-scopes.ts, src/core/tool-scopes.ts, src/middleware/oauth-scope-enforce.ts
2.10MCP call telemetry → Analytics Engine mcp_invocationssrc/lib/mcp-telemetry.ts; dataset binding in wrangler.toml

Scope model

Four coarse scopes, named by intent rather than per-provider:

  • mcp:read — read-only MCP tools (queries, reports, search)
  • mcp:write — mutating MCP tools (CRM writes, emails, LLM invocations)
  • admin:read — read-only admin surface
  • admin:write — mutating admin surface

Per-tool mapping lives in src/core/tool-scopes.ts; unknown tools default to mcp:write (fail-safe). src/middleware/oauth-scope-enforce.ts enforces the mapping at the mcp.ts tool dispatcher level — tools themselves are untouched, maintaining invariant “28 tool registrations unchanged”.

Access-token format (RFC 9068)

JWTs signed HS256 with a binding-level secret (env.OAUTH_JWT_SECRET, falls back to OAUTH_STATE_SECRET in test fixtures). Claims:

{
"iss": "https://ascend-gateway-v5.ascendgtm.workers.dev",
"sub": "<tenant_id>",
"aud": "https://ascend-gateway-v5.ascendgtm.workers.dev/mcp",
"iat": <unix>,
"exp": <iat + 3600>,
"scope": "mcp:read mcp:write",
"client_id": "<oauth client_id>",
"jti": "<uuid>",
"token_type": "access_token"
}

Short-lived (1h) + hashed KV record indexed on SHA-256(token) for revocation + jti tracking. Refresh tokens are opaque (two UUIDs joined) and rotated on every use per OAuth 2.1 §4.3.1 — the previous token is immediately deleted from KV when exchanged.

Backward-compat window

For 30 days starting on merge date, the following are all accepted as input:

  • Authorization: Bearer <legacy-static-token> (SHA-256 lookup in tenant_auth:{hash})
  • Authorization: Bearer <opaque-oauth-1-token> (pre-Wave-2 tokens still in oauth_token:{hash})
  • Authorization: Bearer <rfc9068-jwt> (new Wave-2 JWTs)

The scope-enforcement middleware has ALLOW_LEGACY_BEARER = true which short-circuits scope checks for the two legacy paths. When the 30-day window closes (tracked in docs/projects/LEDGER.md), that flag flips to false and legacy auth stops working — we will have migrated every caller by then.

Tenant scope derivation (hard constraint)

Tenant scope is ALWAYS derived from the authenticated principal:

  • OAuth 2.1 token: sub claim
  • Legacy bearer: SHA-256-indexed lookup in tenant_auth

Tenant identifier MUST NOT come from the request body or a path parameter. portal/src/index.ts adds an X-Tenant-Slug header that the gateway can cross-check against the authenticated tenant, but the authoritative tenant remains the one recovered from the token.

Consequences

Positive

  • Spec-correct MCP in 2026 — listing in the Anthropic registry becomes a submission decision, not a code decision.
  • Client onboarding goes from “paste your bearer token” to a normal OAuth consent flow (one-liner claude mcp add --transport http ascend ...).
  • Scope metadata unblocks multi-tenant portals (Wave 2 task 2.6) because Cloudflare Access can key policies on scopes surfaced in the token.
  • Elicitation URL mode fixes the HubSpot / Salesforce / Google onboarding UX that used to require a 12-step manual guide.
  • Per-invocation mcp_invocations telemetry lets us answer enterprise audit questions without joining D1 + KV — critical for PE-portfolio compliance.

Negative / accepted

  • 30-day dual-auth window is operational noise: every test and client adapter has to consider both code paths until we cut over. Tracked in LEDGER with a re-eval trigger rather than a date.
  • D1 writes on every /oauth/token call — cold path, non-blocking, behind waitUntil. Still one additional connection per OAuth issuance.
  • JWT secret rotation is manual in this wave (set a new OAUTH_JWT_SECRET + invalidate existing access tokens). Automated rotation → Wave 4 or when Secrets Store hits GA.
  • Anthropic registry private submission is not programmatically accepted today (per research doc §4). We ship a script that attempts the upload and falls back to emitting the submission payload for a manual PR to github.com/modelcontextprotocol/registry.

Neutral

  • The V5 gateway keeps its existing 28-tool registrations intact — Wave 2 adds plumbing without touching tool implementations.
  • createMcpHandler from the Agents SDK still powers the request-handling core. mcp-streamable.ts is a thin spec-strict wrapper around it; when the wider ecosystem catches up, we can drop the wrapper.

Alternatives rejected

  1. Use @cloudflare/workers-oauth-provider directly. Evaluated but would require a rewrite of the existing bearer-backed /oauth/authorize HTML consent page (the CF wrapper assumes a delegated IdP like GitHub or Google). Keeping our bespoke consent page preserves the “paste-your-bearer-token” migration path tenants already know. We can adopt the CF wrapper later once the bearer path is fully deprecated — zero additional code complexity today.
  2. Wait for Anthropic to accept visibility: private in the registry. Status quo — research confirms there’s no current API for this. Waiting would block Wave 2 indefinitely.
  3. Deprecate bearer auth on merge date. Would break every n8n workflow calling /mcp + every Claude Desktop config in the wild. 30-day window is the minimum viable compat tail.
  4. Full SSE deprecation in this wave. The Agents SDK’s createMcpHandler implements Streamable HTTP internally, so we get the spec-correct behavior without removing the legacy fallback code in mcp.ts. Touching SSE fallback is deferred to Wave 3 polish.
  5. Per-provider OAuth scopes (e.g., hubspot:read, ga4:read). Rejected — explodes the consent UI and gives external clients visibility into our internal per-provider credential scheme. Coarse scopes (mcp:read, mcp:write) + server-side per-tenant credential brokering is the spec-recommended pattern (research doc §5 “no token passthrough”).

Validation

  • New unit tests: OAuth server (20+ cases), MCP streamable content negotiation (8+), scope enforcement (6+).
  • npm run typecheck clean.
  • npm test — existing 567 + new tests all pass.
  • wrangler deploy --dry-run bundle succeeds with the new MCP_INVOCATIONS Analytics Engine binding.

Follow-ups

  • Flip ALLOW_LEGACY_BEARER to false in src/middleware/oauth-scope-enforce.ts after the 30-day window closes.
  • Migrate to RS256 JWT signing + JWKS publication when we split issuer and resource server (multi-region deployment).
  • Adopt @cloudflare/workers-oauth-provider at a future wave when it supports a pluggable consent page (tracks the upstream defaultHandler API surface).
  • Wire AI Gateway in front of LLM providers (Wave 3 task 3.2).