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:
- 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
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)
| # | 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
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)
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 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)
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
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
- 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.
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
- 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
- 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
- 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).