MCP OAuth 2.1 Authorization
MCP OAuth 2.1 Authorization via @cloudflare/workers-oauth-provider
Section titled “MCP OAuth 2.1 Authorization via @cloudflare/workers-oauth-provider”Context
Section titled “Context”MCP clients (Claude Desktop, Claude.ai, Cursor, etc.) need to authenticate against the /mcp endpoint. Currently, only API keys work (Authorization: Bearer gai_...). The MCP spec requires OAuth 2.1 discovery so clients can automatically discover how to authenticate. The @cloudflare/workers-oauth-provider library handles the protocol, token management, and .well-known metadata automatically — we just need to wire it into the existing auth flow with multi-tenant support.
Architecture
Section titled “Architecture”Request → OAuthProvider.fetch() ├─ /.well-known/oauth-authorization-server → auto (metadata) ├─ /.well-known/oauth-protected-resource → auto (metadata) ├─ /oauth/token → auto (token exchange) ├─ /oauth/register → auto (client registration) ├─ /mcp (Bearer = OAuth token) → McpApiHandler (props from token) ├─ /mcp (Bearer = gai_...) → McpApiHandler (props from resolveExternalToken) └─ Everything else → existing Hono app (unchanged)The existing Hono app becomes the defaultHandler. All current routes (/api/*, /auth/*, /cubejs-api/*, webhooks, SPA) work exactly as before. The OAuthProvider only owns /mcp and the OAuth protocol endpoints.
Implementation Phases
Section titled “Implementation Phases”Phase 1: Infrastructure
Section titled “Phase 1: Infrastructure”Install dependency:
pnpm add @cloudflare/workers-oauth-providerwrangler.toml — add KV binding for OAuth state/token storage:
[[kv_namespaces]]binding = "OAUTH_KV"id = "<create via wrangler>"src/types/env.ts — add to CloudflareEnv:
OAUTH_KV?: KVNamespaceOAUTH_PROVIDER?: OAuthHelpers // injected by OAuthProvider at runtimePhase 2: MCP API Handler
Section titled “Phase 2: MCP API Handler”New file: src/api/mcp/mcp-api-handler.ts
A WorkerEntrypoint subclass that receives authenticated props and creates the cube app. This replaces the per-request cubeApiApp pattern for MCP requests.
- Receives
this.ctx.props={ tenantId, userId, permissions } - Creates DB via
createDatabaseWithHyperdrive(this.env.HYPERDRIVE, this.env.DATABASE_URL) - Builds security context from props (not session cookies)
- Creates
createCubeApp({ ..., mcp: { enabled: true } })and forwards the request - Reuses:
allCubesfromsrc/api/cubes/index.ts,createDatabaseWithHyperdrivefromsrc/db/client.ts, schema fromsrc/db/schema/index.ts
Phase 3: OAuthProvider Config + Dual Auth
Section titled “Phase 3: OAuthProvider Config + Dual Auth”New file: src/api/mcp/oauth-provider-config.ts
Factory function that creates the OAuthProvider instance:
new OAuthProvider({ apiRoute: '/mcp', apiHandler: McpApiHandler, defaultHandler: app, // existing Hono app authorizeEndpoint: '/mcp/authorize', tokenEndpoint: '/oauth/token', clientRegistrationEndpoint: '/oauth/register', accessTokenTTL: 3600, // 1 hour refreshTokenTTL: 2592000, // 30 days resolveExternalToken, // API key fallback})resolveExternalToken callback — enables API key auth alongside OAuth:
- If token starts with
gai_, validate via existingvalidateApiKey()fromsrc/api/routes/keys.ts - If valid, return
{ props: { tenantId, userId, permissions } } - If not valid, return
null(OAuthProvider returns 401) - This means
--header "Authorization: Bearer gai_..."continues to work for Claude Code users
src/api/index.ts changes:
- Remove
app.route('/mcp', cubeApiApp)(line 322-324) - Remove
/mcpfrom SPA catch-all exclusions (line 348, no longer needed — OAuthProvider handles it) - Change default export from
fetch: app.fetchtofetch: oauthProvider.fetch - Keep
queueandscheduledexports unchanged
Phase 4: MCP Authorization Flow (Hono routes)
Section titled “Phase 4: MCP Authorization Flow (Hono routes)”New file: src/api/mcp/auth-handler.ts — Hono router for consent + tenant selection
Follows the existing CLI auth pattern from src/api/routes/auth/cli-auth.ts:
GET /mcp/authorize:
env.OAUTH_PROVIDER.parseAuthRequest(request)→ getoauthReqInfo- Store
oauthReqInfoin encrypted cookie (10 min TTL) - Check
sessionIdcookie — if no session, redirect to/login?mode=mcp&returnUrl=/mcp/authorize - If session exists, validate and get user’s tenants (same query as
cli-auth.tsline 166-177) - Lookup client metadata:
env.OAUTH_PROVIDER.lookupClient(clientId) - If 1 tenant → render consent page with that tenant
- If N tenants → render consent page with tenant selector dropdown
POST /mcp/authorize/approve:
- Validate session, recover
oauthReqInfofrom cookie - Get selected
tenantIdfrom form body - Verify user has tenant access
- Map role → permissions (owner →
['admin','read','write'], else['read','write']) - Call
env.OAUTH_PROVIDER.completeAuthorization({ request: oauthReqInfo, userId, props: { tenantId, userId, permissions }, scope: ['mcp:read'] }) Response.redirect(redirectTo)
Mount on Hono app in index.ts:
import { mcpAuthRouter } from './mcp/auth-handler.js'app.route('/mcp', mcpAuthRouter) // handles /mcp/authorize, /mcp/authorize/approveNew file: src/api/mcp/consent-page.ts — HTML renderer
Server-rendered consent page (same pattern as cli-auth.ts line 50-131):
- Shows client name/description (from
lookupClient) - Shows tenant selector if multiple tenants
- “Authorize” button submits POST to
/mcp/authorize/approve - Clean, minimal CSS matching the CLI auth page style
Phase 5: Polish
Section titled “Phase 5: Polish”- Rate limit
/mcp/authorizeand/oauth/token(reuse existing rate limiter pattern) - Track MCP auth events in Analytics Engine via
trackEvent() - Test with Claude Desktop, Claude Code, and MCP Inspector
Files to Modify
Section titled “Files to Modify”| File | Change |
|---|---|
src/api/index.ts |
Remove /mcp route from Hono app; wrap fetch with OAuthProvider; mount mcpAuthRouter |
src/types/env.ts |
Add OAUTH_KV, OAUTH_PROVIDER to CloudflareEnv |
wrangler.toml |
Add OAUTH_KV binding |
package.json |
Add @cloudflare/workers-oauth-provider dependency |
New Files
Section titled “New Files”| File | Purpose |
|---|---|
src/api/mcp/mcp-api-handler.ts |
WorkerEntrypoint — creates cube app from OAuth token props |
src/api/mcp/oauth-provider-config.ts |
OAuthProvider factory with resolveExternalToken for API key fallback |
src/api/mcp/auth-handler.ts |
Hono router for /mcp/authorize consent + tenant selection flow |
src/api/mcp/consent-page.ts |
Server-rendered HTML consent/tenant-picker page |
Reused Existing Code
Section titled “Reused Existing Code”| What | From |
|---|---|
validateApiKey() |
src/api/routes/keys.ts |
validateSession() |
src/api/auth/sessions.ts |
createDatabaseWithHyperdrive() |
src/db/client.ts |
allCubes |
src/api/cubes/index.ts |
| Tenant lookup query | src/api/routes/auth/cli-auth.ts (line 166-177) |
| Consent page HTML pattern | src/api/routes/auth/cli-auth.ts (line 50-131) |
| Role → permissions mapping | src/api/middleware/auth.ts (line 338-339) |
Verification
Section titled “Verification”- API key auth unchanged:
curl -H "Authorization: Bearer gai_..." https://server/mcpreturns cube metadata - OAuth discovery:
GET /.well-known/oauth-protected-resourcereturns metadata withauthorization_servers - OAuth flow: MCP Inspector can complete the full authorize → token → query flow
- Multi-tenant: User with multiple tenants sees tenant picker during authorization
- Existing routes: All
/api/*,/auth/*,/cubejs-api/*routes work unchanged - TypeCheck:
pnpm typecheckpasses - Build:
pnpm buildsucceeds
