import { getAuth, getHostedBaseUrl } from "@/lib/auth"; import { API_KEY_PREFIX } from "@/lib/auth-api-key"; import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource"; import { resolveExistingActiveHostedOrganization } from "@/server/auth/default-hosted-organization"; import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation"; import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context"; import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; function getApiKey(request: Request) { // Both branches require the oseo_ prefix: anything else (a Cloudflare OAuth // access token, a stray foreign x-api-key header) falls through to the // OAuth provider instead of being consumed here. const headerKey = request.headers.get("x-api-key"); if (headerKey?.startsWith(API_KEY_PREFIX)) return headerKey; const bearerToken = request.headers .get("Authorization") ?.replace(/^Bearer /i, ""); if (bearerToken?.startsWith(API_KEY_PREFIX)) return bearerToken; return null; } function apiKeyErrorResponse( error: { code?: string | null; message?: unknown; details?: unknown } | null, ) { const headers = new Headers({ "Content-Type": "application/json" }); const isLimited = error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED"; const isForbidden = error?.code === "FORBIDDEN"; if (isLimited) { // The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its // published error type omits `details`, so narrow at runtime. const details = error?.details; const tryAgainInMs = typeof details === "object" && details !== null && "tryAgainIn" in details ? details.tryAgainIn : undefined; if (typeof tryAgainInMs === "number" && Number.isFinite(tryAgainInMs)) { headers.set( "Retry-After", String(Math.max(1, Math.ceil(tryAgainInMs / 1000))), ); } } const code = isLimited ? error?.code === "RATE_LIMITED" ? "rate_limited" : "usage_exceeded" : isForbidden ? "account_access_revoked" : "invalid_api_key"; const description = isLimited ? typeof error?.message === "string" ? error.message : "API key request limit reached" : isForbidden ? typeof error?.message === "string" ? error.message : "This API key is no longer associated with an organization" : "The provided API key is invalid, expired, or disabled"; const status = isLimited ? 429 : isForbidden ? 403 : 401; // Bad credentials are client-side noise, so 401 logs at debug (mirroring the // OAuth path); a 429 means we actually cut a caller off, so warn. const line = `[mcp-api-key] ${status} ${code} (${error?.code ?? "no_hosted_user"})`; if (status === 429) { console.warn(line); } else { console.debug(line); } return new Response( JSON.stringify({ error: code, error_description: description }), { status, headers }, ); } export async function handleMcpApiKeyRequest( request: Request, env: unknown, ctx: ExecutionContext, ): Promise { const url = new URL(request.url); if (url.pathname !== MCP_ROUTE || request.method === "OPTIONS") return null; const apiKey = getApiKey(request); if (!apiKey) return null; try { // Keep API keys scoped to /mcp: verifyApiKey (rather than Better Auth's // enableSessionForAPIKeys mock sessions) means a key never becomes a // session that could reach account or organization endpoints. const authApi = getAuth().api; const result = await authApi.verifyApiKey({ body: { key: apiKey } }); if (!result.valid || !result.key) { return apiKeyErrorResponse(result.error); } const userId = result.key.referenceId; // Per-user request throttle. The binding is declared in alchemy.run.ts // (hosted prod only); local dev and self-host run without it and skip // limiting, which is fine single-user. This replaces the better-auth // plugin limiter, whose broken idle-gap window hard-blocked active MCP // clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo // best-effort, which is all this needs to be: credits bound spend, this // bounds runaway request volume. // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared as a rate limiter in alchemy.run.ts; absent outside hosted prod const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT; if (rateLimit) { const { success } = await rateLimit.limit({ key: userId }); if (!success) { return apiKeyErrorResponse({ code: "RATE_LIMITED", message: "Rate limit exceeded: 5000 requests per minute", details: { tryAgainIn: 60_000 }, }); } } const user = await AuthRepository.getHostedUser(userId); if (!user?.email) return apiKeyErrorResponse(null); // API keys bill the user's active organization. Keys are user-scoped and // the org derives from the project each tool call names (project-level // authz) — not key→org binding. Fail closed if the user has no existing // memberships; we never mint a default organization for an API key. const resolved = await resolveExistingActiveHostedOrganization(userId); if (!resolved) { return apiKeyErrorResponse({ code: "FORBIDDEN", message: "API key is not associated with an organization", }); } const { organizationId, role } = resolved; // clientId "api_key" satisfies the hosted transport's fail-closed props // schema and counts these calls as external MCP clients in telemetry. // orgScope "user": the key itself is the credential, not a key→org // binding — project-scoped tools authorize per call via the caller's // membership in the project's org, and organizationId above is only the // fallback for tools with no project argument. const props = createWorkersOAuthMcpProps({ userId, userEmail: user.email, organizationId, role, orgScope: "user", baseUrl: getHostedBaseUrl(), scopes: [...MCP_OAUTH_SCOPES], clientId: "api_key", }); await recordMcpAuthorized(organizationId); return await handleAuthenticatedOpenSeoMcpRequest(request, props, env, ctx); } catch (error) { // Without this, a throw here surfaces as a bare platform 500 with no log // breadcrumb. console.error("[mcp-api-key] 500 (internal):", error); return new Response( JSON.stringify({ error: "internal_error", error_description: "API key authentication failed", }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } }