OAuth 2.1 + Streamable HTTP for MCP (Wave 2)
ADR-024 — OAuth 2.1 + Streamable HTTP for MCP (Wave 2)
Section titled “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
Section titled “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:
- 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). - 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.
- 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
Section titled “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)
Section titled “Concretely (files shipped in this PR)”| # | Surface | File(s) |
|---|---|---|
| 2.1 | Streamable HTTP transport with content negotiation | src/handlers/mcp-streamable.ts, content-negotiation plumbing in src/index.ts |
| 2.2 | OAuth 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.3 | D1 persistence for clients + refresh tokens | migrations/0004_oauth_clients.sql |
| 2.4 | MCP Elicitation URL mode | src/handlers/oauth-server.ts (/oauth/elicit/{flow}, buildUrlElicitationRequiredError()) |
| 2.6 | Per-tenant MCP Server Portal scaffold + runbook | portal/, docs/runbooks/mcp-server-portal-deploy.md |
| 2.7 | Anthropic MCP Registry submission script | scripts/register-mcp.ts |
| 2.8 | .well-known/mcp-server-card | src/handlers/mcp-server-card.ts |
| 2.9 | Per-tool OAuth scopes + enforcement middleware | src/core/oauth-scopes.ts, src/core/tool-scopes.ts, src/middleware/oauth-scope-enforce.ts |
| 2.10 | MCP call telemetry → Analytics Engine mcp_invocations | src/lib/mcp-telemetry.ts; dataset binding in wrangler.toml |
Scope model
Section titled “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 surfaceadmin: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)
Section titled “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
Section titled “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 intenant_auth:{hash})Authorization: Bearer <opaque-oauth-1-token>(pre-Wave-2 tokens still inoauth_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)
Section titled “Tenant scope derivation (hard constraint)”Tenant scope is ALWAYS derived from the authenticated principal:
- OAuth 2.1 token:
subclaim - 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
Section titled “Consequences”Positive
Section titled “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_invocationstelemetry lets us answer enterprise audit questions without joining D1 + KV — critical for PE-portfolio compliance.
Negative / accepted
Section titled “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
Section titled “Neutral”- The V5 gateway keeps its existing 28-tool registrations intact — Wave 2 adds plumbing without touching tool implementations.
createMcpHandlerfrom the Agents SDK still powers the request-handling core.mcp-streamable.tsis a thin spec-strict wrapper around it; when the wider ecosystem catches up, we can drop the wrapper.
Alternatives rejected
Section titled “Alternatives rejected”- Use
@cloudflare/workers-oauth-providerdirectly. Evaluated but would require a rewrite of the existing bearer-backed/oauth/authorizeHTML 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. - Wait for Anthropic to accept
visibility: privatein the registry. Status quo — research confirms there’s no current API for this. Waiting would block Wave 2 indefinitely. - 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. - Full SSE deprecation in this wave. The Agents SDK’s
createMcpHandlerimplements Streamable HTTP internally, so we get the spec-correct behavior without removing the legacy fallback code inmcp.ts. Touching SSE fallback is deferred to Wave 3 polish. - 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
Section titled “Validation”- New unit tests: OAuth server (20+ cases), MCP streamable content negotiation (8+), scope enforcement (6+).
npm run typecheckclean.npm test— existing 567 + new tests all pass.wrangler deploy --dry-runbundle succeeds with the newMCP_INVOCATIONSAnalytics Engine binding.
Follow-ups
Section titled “Follow-ups”- Flip
ALLOW_LEGACY_BEARERtofalseinsrc/middleware/oauth-scope-enforce.tsafter 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-providerat a future wave when it supports a pluggable consent page (tracks the upstreamdefaultHandlerAPI surface). - Wire AI Gateway in front of LLM providers (Wave 3 task 3.2).