Skip to content

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”

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.

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.

Install dependency:

Terminal window
pnpm add @cloudflare/workers-oauth-provider

wrangler.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?: KVNamespace
OAUTH_PROVIDER?: OAuthHelpers // injected by OAuthProvider at runtime

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: allCubes from src/api/cubes/index.ts, createDatabaseWithHyperdrive from src/db/client.ts, schema from src/db/schema/index.ts

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 existing validateApiKey() from src/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 /mcp from SPA catch-all exclusions (line 348, no longer needed — OAuthProvider handles it)
  • Change default export from fetch: app.fetch to fetch: oauthProvider.fetch
  • Keep queue and scheduled exports 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:

  1. env.OAUTH_PROVIDER.parseAuthRequest(request) → get oauthReqInfo
  2. Store oauthReqInfo in encrypted cookie (10 min TTL)
  3. Check sessionId cookie — if no session, redirect to /login?mode=mcp&returnUrl=/mcp/authorize
  4. If session exists, validate and get user’s tenants (same query as cli-auth.ts line 166-177)
  5. Lookup client metadata: env.OAUTH_PROVIDER.lookupClient(clientId)
  6. If 1 tenant → render consent page with that tenant
  7. If N tenants → render consent page with tenant selector dropdown

POST /mcp/authorize/approve:

  1. Validate session, recover oauthReqInfo from cookie
  2. Get selected tenantId from form body
  3. Verify user has tenant access
  4. Map role → permissions (owner → ['admin','read','write'], else ['read','write'])
  5. Call env.OAUTH_PROVIDER.completeAuthorization({ request: oauthReqInfo, userId, props: { tenantId, userId, permissions }, scope: ['mcp:read'] })
  6. 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/approve

New 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

  • Rate limit /mcp/authorize and /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
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
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
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)
  1. API key auth unchanged: curl -H "Authorization: Bearer gai_..." https://server/mcp returns cube metadata
  2. OAuth discovery: GET /.well-known/oauth-protected-resource returns metadata with authorization_servers
  3. OAuth flow: MCP Inspector can complete the full authorize → token → query flow
  4. Multi-tenant: User with multiple tenants sees tenant picker during authorization
  5. Existing routes: All /api/*, /auth/*, /cubejs-api/* routes work unchanged
  6. TypeCheck: pnpm typecheck passes
  7. Build: pnpm build succeeds