diff --git a/docs/oauth-cutover.md b/docs/oauth-cutover.md index e1acf927..7c3ca667 100644 --- a/docs/oauth-cutover.md +++ b/docs/oauth-cutover.md @@ -1,21 +1,29 @@ # Canonical OAuth discovery release -This change only updates production protected-resource discovery. It does not -move MCP traffic, proxy OAuth requests, modify registrations, or remove -compatibility code. Review approval is not deployment approval. +This change corrects protected-resource discovery and its unauthenticated +challenge. It does not move MCP traffic, proxy OAuth requests, modify +registrations, or remove compatibility code. Review approval is not deployment +approval. ## Contract -- Resource remains `https://mcp.onkernel.com`; MCP remains hosted at - `https://mcp.onkernel.com/mcp`. +- Resource is `https://mcp.onkernel.com/mcp`, exactly matching the MCP endpoint. + The previous origin-only value was incorrect: RFC 9728 section 3.3 requires + the resource to match the identifier used to derive the metadata URL. - `/.well-known/oauth-protected-resource/mcp` advertises `authorization_servers: ["https://auth.onkernel.com"]` and canonical `/authorize`, `/token`, and `/register` endpoints. Responses use `no-store`. + Unauthenticated MCP requests reach the route's bearer-token validation rather + than Clerk's page protection and include this path-specific URL in the + `WWW-Authenticate` challenge's `resource_metadata` parameter. The root + `/.well-known/oauth-protected-resource` endpoint is not provided; it is not + the discovery URL for the `/mcp` resource. - Legacy authorization-server metadata and all existing TypeScript OAuth routes, picker/consent pages, token verification, and Redis behavior remain unchanged. There is no legacy Go relay or MCP DNS change. -- Local, staging, and preview discovery retain their own origin. There is no - client-specific opt-in discovery header. +- Local, staging, and preview discovery retain their own origin and use `/mcp` + as the resource path. Their authorization server remains their own origin. + There is no client-specific opt-in discovery header. ## Compatibility dependency @@ -37,6 +45,22 @@ Existing credentials must remain available: preserve Clerk applications, durable registrations, static clients, shared token context, and both issuer endpoints. Token validation and refresh behavior are unchanged by this metadata update. +Deploy client issuer/registration/resource pinning and explicit reconnect +handling **before** the metadata correction. Clients that rediscover during an +OAuth callback or refresh must not silently move existing credentials to the +newly advertised issuer or replace their original resource binding. A client +that previously rejected the mismatched metadata may start accepting canonical +discovery after this fix; that does not migrate its retained legacy registration. + +The Go broker accepts HTTPS resources including `/mcp`, but token exchange +compares the submitted resource exactly with the resource stored in the +authorization transaction. Preserve the origin-only resource for old in-flight +transactions; use `/mcp` for new authorizations based on corrected discovery. +The retained TypeScript token route forwards the caller's resource unchanged, +including when omitted. Do not rewrite stored transactions or credentials. +Local regression tests are not live registration, login, token, or refresh +compatibility tests; those remain separate, explicitly authorized rollout checks. + ## Forward deployment order 1. Obtain explicit release approval after the applicable acceptance checks and @@ -45,16 +69,20 @@ Token validation and refresh behavior are unchanged by this metadata update. Go service, registry, shared token context, static CLI overlay, dashboard picker/consent, and canonical Clerk callback are usable. Keep the existing MCP deployment and all legacy routes in service. -2. Merge/apply the separately reviewed **auth DNS-only** change to the existing - production API load balancer. Leave MCP DNS on Vercel. Verify public auth - DNS/TLS convergence, canonical issuer/endpoints, and callback reachability. - With the current 300-second TTL, allow two observed TTLs (10 minutes); recheck - the actual TTL and public resolvers at execution time. During this interval, - MCP discovery still advertises the working legacy TypeScript path. -3. Only after step 2 is verified, merge/deploy this MCP PR. Treat merging as a - possible production deployment. Verify canonical protected-resource metadata, - unchanged resource identity, unchanged legacy authorization-server metadata - and routes, and the agreed cached-client outcome. +2. Verify the separately approved **auth DNS-only** cutover to the existing + production API load balancer; do not reapply it if already complete. Leave + MCP DNS on Vercel. Verify public auth DNS/TLS convergence, canonical + issuer/endpoints, and callback reachability. If that cutover is still pending, + follow its separately reviewed deployment procedure. With a 300-second TTL, + allow two observed TTLs (10 minutes); recheck the actual TTL and public + resolvers at execution time. The incorrect origin-only resource is not a + safe mechanism for keeping clients on legacy discovery. +3. Only after step 2 and the client pinning/reconnect prerequisite are verified, + merge/deploy this MCP PR. Treat merging as a possible production deployment. + Verify the exact `/mcp` resource identity at path-specific discovery, its + unauthenticated challenge, canonical authorization server, unchanged legacy + authorization-server metadata and routes, and the agreed cached-client + outcome. Discovery-first is not safe merely because the auth hostname already resolves: that hostname must serve the intended canonical service and callbacks, not the @@ -68,8 +96,10 @@ clients from an auth DNS change. production discovery selection in `src/lib/oauth-discovery.ts` back to `MCP_ORIGIN`, and updates its test. Deploy that change first; verify the served protected-resource JSON advertises only the legacy origin again. Retain - `no-store`, resource identity, and all existing routes. Do not reset main, - revert unrelated commits, or promote an old whole MCP deployment. + `no-store`, the corrected `/mcp` resource identity, and all existing routes. + Change only the authorization-server selection, not the resource or challenge. + Do not reset main, revert unrelated commits, or promote an old whole MCP + deployment. 2. Keep auth DNS on Go while accounting for cached canonical registrations and in-flight Go authorizations/codes. Metadata rollback affects future discovery; it cannot erase cached issuers or registrations. The old TypeScript token @@ -84,6 +114,6 @@ clients from an auth DNS change. observed TTLs, and verify public routing plus the retained auth deployment. Preserve registration and credential data; rollback requires no data cleanup. -If the failure occurs before step 3 of the forward sequence, MCP discovery needs -no rollback: it still points at the legacy service. Auth DNS rollback still has -the cached-canonical/in-flight constraints above. +If the resource correction has not been deployed, it needs no rollback. Any +separate issuer-selection or auth DNS rollback still has the cached-canonical +and in-flight constraints above. diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index e902ffe7..14793cc4 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; +import { oauthResourceMetadata } from "@/lib/oauth-discovery"; process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; @@ -28,7 +29,7 @@ mock.module("@/lib/mcp/analytics", () => ({ })); const originalCreateKernelClient = defaultMcpDependencies.createKernelClient; -const { POST, connectionScopeFailureResponse } = await import("./route"); +const { GET, POST, connectionScopeFailureResponse } = await import("./route"); function initializeRequest(token = "sk_opaque_key") { return new Request("https://mcp.example.test/sse", { @@ -72,6 +73,46 @@ afterEach(() => { defaultMcpDependencies.createKernelClient = originalCreateKernelClient; }); +describe("unauthenticated discovery", () => { + for (const method of ["GET", "POST"]) { + test.each([ + [ + "https://mcp.onkernel.com", + "mcp.onkernel.com", + "https://mcp.onkernel.com", + ], + ["http://localhost:3002", "mcp.onkernel.com", "https://mcp.onkernel.com"], + ["http://localhost:3002", "localhost:3002", "http://localhost:3002"], + [ + "https://localhost:3000", + "mcp-staging.onkernel.com", + "https://mcp-staging.onkernel.com", + ], + ])( + `${method} challenges point to path-specific discovery at %s (Host: %s)`, + async (origin, host, publicOrigin) => { + const req = new nextServer.NextRequest(`${origin}/mcp`, { + method, + headers: { Host: host, "X-Forwarded-Host": "ignored.example" }, + }); + const response = await (method === "GET" ? GET : POST)(req); + expect(response.status).toBe(401); + const challenge = response.headers.get("WWW-Authenticate"); + expect(challenge).toContain('Bearer realm="OAuth"'); + expect(challenge).toContain('error="invalid_token"'); + const metadataUrl = challenge?.match( + /resource_metadata="([^"]+)"/, + )?.[1]; + expect(metadataUrl).toBe( + `${publicOrigin}/.well-known/oauth-protected-resource/mcp`, + ); + const metadata = oauthResourceMetadata(new Request(metadataUrl!), {}); + expect(metadata.resource).toBe(`${publicOrigin}/mcp`); + }, + ); + } +}); + describe("connection scope failures through the handler", () => { test("answers a refused credential with 401 rather than a server error", async () => { failingAuthContext(Object.assign(new Error("revoked"), { status: 401 })); @@ -243,10 +284,13 @@ describe("vault entitlement routing", () => { describe("connectionScopeFailureResponse", () => { test("names an inactive project instead of blaming the credential", async () => { - const response = connectionScopeFailureResponse({ - status: "rejected", - statusCode: 404, - }); + const response = connectionScopeFailureResponse( + new Request("https://mcp.example.test/mcp"), + { + status: "rejected", + statusCode: 404, + }, + ); expect(response.status).toBe(404); expect(response.headers.get("WWW-Authenticate")).toBeNull(); @@ -258,10 +302,16 @@ describe("connectionScopeFailureResponse", () => { }); test("challenges a refused credential so clients re-authenticate", () => { - const response = connectionScopeFailureResponse({ - status: "rejected", - statusCode: 401, - }); + const response = connectionScopeFailureResponse( + new Request("https://mcp.example.test/mcp"), + { + status: "rejected", + statusCode: 401, + }, + ); + expect(response.headers.get("WWW-Authenticate")).toContain( + 'resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"', + ); expect(response.headers.get("WWW-Authenticate")).toContain( 'error="invalid_token"', diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 5bfea6ce..b7b41ece 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -6,6 +6,10 @@ import { import { verifyToken } from "@clerk/nextjs/server"; import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; +import { + OAUTH_RESOURCE_METADATA_PATH, + oauthResourceMetadataUrl, +} from "@/lib/oauth-discovery"; import { captureMcpConnectionScopeFailure, flushMcpAnalytics, @@ -60,15 +64,17 @@ function errorResponse( // Helper function to create authentication error response function createAuthErrorResponse( + req: Request, error: string = "invalid_token", description: string = "Missing or invalid access token", ): Response { return errorResponse(401, error, description, { - "WWW-Authenticate": `Bearer realm="OAuth", error="${error}", error_description="${description}"`, + "WWW-Authenticate": `Bearer realm="OAuth", error="${error}", error_description="${description}", resource_metadata="${oauthResourceMetadataUrl(req)}"`, }); } export function connectionScopeFailureResponse( + req: Request, failure: Exclude, ): Response { if (failure.status === "rejected") { @@ -90,6 +96,7 @@ export function connectionScopeFailureResponse( ); case 401: return createAuthErrorResponse( + req, "invalid_token", "The Kernel API rejected this credential", ); @@ -169,7 +176,7 @@ async function handleMcpRequestWithIdentity({ if (connection.status === "invalid") { throw new Error("Unable to resolve Kernel connection scope"); } - return connectionScopeFailureResponse(connection); + return connectionScopeFailureResponse(req, connection); } // Recheck with the current credential on every request, including tools/call. const vaults = await resolveMcpVaultAccess({ token, signal: req.signal }); @@ -194,7 +201,7 @@ async function handleMcpRequestWithIdentity({ }), { required: true, - resourceMetadataPath: "/.well-known/oauth-protected-resource/mcp", + resourceMetadataPath: OAUTH_RESOURCE_METADATA_PATH, }, ); return await authHandler(req); @@ -211,6 +218,7 @@ async function handleAuthenticatedRequest( : null; if (!token) { return createAuthErrorResponse( + req, "invalid_token", "Missing or invalid access token", ); @@ -239,6 +247,7 @@ async function handleAuthenticatedRequest( }); if (!payload.sub) { return createAuthErrorResponse( + req, "invalid_token", "Invalid token: No user ID found in token payload", ); @@ -246,6 +255,7 @@ async function handleAuthenticatedRequest( userId = payload.sub; } catch (authError) { return createAuthErrorResponse( + req, "invalid_token", `Invalid token: ${authError instanceof Error ? authError.message : "Authentication failed"}`, ); diff --git a/src/app/token/route.test.ts b/src/app/token/route.test.ts index 2dad6c47..82fbb3d5 100644 --- a/src/app/token/route.test.ts +++ b/src/app/token/route.test.ts @@ -115,6 +115,33 @@ function dependencies({ } describe("POST /token", () => { + for (const grantType of ["authorization_code", "refresh_token"]) { + test.each([ + "https://mcp.example.test", + "https://mcp.example.test/mcp", + undefined, + ])( + `preserves the existing resource %s during ${grantType}`, + async (resource) => { + const deps = dependencies(); + const response = await tokenRequest( + request({ + grant_type: grantType, + client_id: "client_1", + ...(grantType === "authorization_code" + ? { code: "code_1", code_verifier: "verifier_1" } + : { refresh_token: "refresh-old" }), + ...(resource ? { resource } : {}), + }), + deps.value, + ); + expect(response.status).toBe(200); + expect(deps.calls.exchanges).toHaveLength(1); + expect(deps.calls.exchanges[0].get("resource")).toBe(resource ?? null); + }, + ); + } + test("issues an organization-wide token and persists both contexts", async () => { const deps = dependencies(); const response = await tokenRequest( diff --git a/src/lib/oauth-discovery-route.test.ts b/src/lib/oauth-discovery-route.test.ts new file mode 100644 index 00000000..ae8bacfe --- /dev/null +++ b/src/lib/oauth-discovery-route.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, mock, test } from "bun:test"; +import { NextRequest } from "next/server"; + +const clerkMetadata = { + resource: "https://clerk.example.test", + authorization_servers: ["https://clerk.example.test"], + jwks_uri: "https://clerk.example.test/.well-known/jwks.json", +}; +mock.module("@clerk/mcp-tools/next", () => ({ + protectedResourceHandlerClerk: () => async () => Response.json(clerkMetadata), +})); + +const { GET, OPTIONS } = await import( + "@/app/.well-known/oauth-protected-resource/mcp/route" +); + +describe("/.well-known/oauth-protected-resource/mcp", () => { + test.each([ + ["https://mcp.onkernel.com", "https://auth.onkernel.com"], + ["http://localhost:3002", "http://localhost:3002"], + ["https://mcp-staging.onkernel.com", "https://mcp-staging.onkernel.com"], + ])("serves uncached metadata bound to %s/mcp", async (origin, issuer) => { + const resource = new URL(`${origin}/mcp`); + const discoveryUrl = new URL(resource); + discoveryUrl.pathname = `/.well-known/oauth-protected-resource${resource.pathname}`; + const response = await GET(new NextRequest(discoveryUrl)); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(await response.json()).toEqual({ + resource: resource.href, + authorization_servers: [issuer], + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + scopes_supported: ["openid"], + jwks_uri: clerkMetadata.jwks_uri, + }); + }); + + test("keeps discovery available to cross-origin clients", async () => { + const response = await OPTIONS(); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "GET, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type, Authorization", + ); + }); +}); diff --git a/src/lib/oauth-discovery.test.ts b/src/lib/oauth-discovery.test.ts index 5e286c8d..7b277e70 100644 --- a/src/lib/oauth-discovery.test.ts +++ b/src/lib/oauth-discovery.test.ts @@ -1,8 +1,45 @@ import { describe, expect, it } from "bun:test"; -import { oauthResourceMetadata } from "./oauth-discovery"; +import { + oauthResourceMetadata, + oauthResourceMetadataUrl, +} from "./oauth-discovery"; describe("OAuth protected-resource discovery", () => { - it("advertises canonical OAuth without changing the MCP resource identity", () => { + it.each([ + ["https://mcp.onkernel.com", undefined, "https://mcp.onkernel.com"], + ["http://localhost:3002", "mcp.onkernel.com", "https://mcp.onkernel.com"], + ["http://localhost:3002", undefined, "http://localhost:3002"], + [ + "https://localhost:3000", + "mcp-staging.onkernel.com", + "https://mcp-staging.onkernel.com", + ], + ["https://preview.example", undefined, "https://preview.example"], + ])( + "binds RFC 9728 path discovery to the resource at %s (Host: %s)", + (requestOrigin, host, publicOrigin) => { + const resource = new URL(`${publicOrigin}/mcp`); + const expectedMetadataUrl = new URL(resource); + expectedMetadataUrl.pathname = `/.well-known/oauth-protected-resource${resource.pathname}`; + const headers = host ? { Host: host } : undefined; + const endpointRequest = new Request(`${requestOrigin}/mcp`, { headers }); + expect(oauthResourceMetadataUrl(endpointRequest)).toBe( + expectedMetadataUrl.href, + ); + + const metadataRequest = new Request( + `${requestOrigin}${expectedMetadataUrl.pathname}`, + { headers }, + ); + const metadata = oauthResourceMetadata(metadataRequest, { + resource: publicOrigin, + }); + expect(metadata.resource).toBe(resource.href); + expect(metadata.resource).not.toBe(resource.origin); + }, + ); + + it("advertises canonical OAuth for the exact MCP endpoint", () => { expect( oauthResourceMetadata( new Request( @@ -18,7 +55,7 @@ describe("OAuth protected-resource discovery", () => { }, ), ).toEqual({ - resource: "https://mcp.onkernel.com", + resource: "https://mcp.onkernel.com/mcp", authorization_servers: ["https://auth.onkernel.com"], authorization_endpoint: "https://auth.onkernel.com/authorize", token_endpoint: "https://auth.onkernel.com/token", @@ -42,7 +79,7 @@ describe("OAuth protected-resource discovery", () => { ), {}, ); - expect(metadata.resource).toBe("https://mcp.onkernel.com"); + expect(metadata.resource).toBe("https://mcp.onkernel.com/mcp"); expect(metadata.authorization_servers).toEqual([ "https://auth.onkernel.com", ]); @@ -63,7 +100,7 @@ describe("OAuth protected-resource discovery", () => { ), {}, ); - expect(metadata.resource).toBe(`https://${host}`); + expect(metadata.resource).toBe(`https://${host}/mcp`); expect(metadata.authorization_servers).toEqual([`https://${host}`]); expect(metadata.authorization_endpoint).toBe(`https://${host}/authorize`); expect(metadata.token_endpoint).toBe(`https://${host}/token`); @@ -78,8 +115,11 @@ describe("OAuth protected-resource discovery", () => { "https://preview.example", "https://mcp.onkernel.com.evil.example", ]) { - const metadata = oauthResourceMetadata(new Request(`${origin}/mcp`), {}); - expect(metadata.resource).toBe(origin); + const metadata = oauthResourceMetadata( + new Request(`${origin}/.well-known/oauth-protected-resource/mcp`), + {}, + ); + expect(metadata.resource).toBe(`${origin}/mcp`); expect(metadata.authorization_servers).toEqual([origin]); expect(metadata.authorization_endpoint).toBe(`${origin}/authorize`); } diff --git a/src/lib/oauth-discovery.ts b/src/lib/oauth-discovery.ts index 7eeefe13..dc7bfdc8 100644 --- a/src/lib/oauth-discovery.ts +++ b/src/lib/oauth-discovery.ts @@ -1,23 +1,33 @@ const MCP_ORIGIN = "https://mcp.onkernel.com"; const OAUTH_ORIGIN = "https://auth.onkernel.com"; -export function oauthResourceMetadata( - request: Request, - clerkMetadata: Record, -): Record { +export const OAUTH_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource/mcp"; + +function mcpOrigin(request: Request): string { const url = new URL(request.url); const host = request.headers.get("host"); if (host) { url.port = ""; url.host = host; } - const isProduction = url.host === "mcp.onkernel.com"; - const resource = isProduction ? MCP_ORIGIN : url.origin; - const authorizationServer = isProduction ? OAUTH_ORIGIN : url.origin; + return url.host === "mcp.onkernel.com" ? MCP_ORIGIN : url.origin; +} + +export function oauthResourceMetadataUrl(request: Request): string { + return `${mcpOrigin(request)}${OAUTH_RESOURCE_METADATA_PATH}`; +} + +export function oauthResourceMetadata( + request: Request, + clerkMetadata: Record, +): Record { + const origin = mcpOrigin(request); + const authorizationServer = origin === MCP_ORIGIN ? OAUTH_ORIGIN : origin; return { ...clerkMetadata, - resource, + resource: `${origin}/mcp`, authorization_servers: [authorizationServer], authorization_endpoint: `${authorizationServer}/authorize`, token_endpoint: `${authorizationServer}/token`, diff --git a/src/proxy.test.ts b/src/proxy.test.ts new file mode 100644 index 00000000..78d66479 --- /dev/null +++ b/src/proxy.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, mock, test } from "bun:test"; +import { NextRequest } from "next/server"; + +let handleRequest: ( + auth: { protect: () => Promise }, + request: NextRequest, +) => Promise; +const clerk = await import("@clerk/nextjs/server"); +mock.module("@clerk/nextjs/server", () => ({ + ...clerk, + clerkMiddleware: (handler: typeof handleRequest) => { + handleRequest = handler; + return handler; + }, +})); +await import("./proxy"); + +describe("MCP discovery through middleware", () => { + test.each(["GET", "POST", "OPTIONS"])( + "lets unauthenticated %s /mcp reach the bearer-token gate", + async (method) => { + const protect = mock(async () => {}); + await handleRequest( + { protect }, + new NextRequest("https://mcp.example.test/mcp", { method }), + ); + expect(protect).not.toHaveBeenCalled(); + }, + ); + + test("lets API keys reach the MCP route's credential validation", async () => { + const protect = mock(async () => {}); + await handleRequest( + { protect }, + new NextRequest("https://mcp.example.test/mcp", { + method: "POST", + headers: { Authorization: "Bearer sk_test_key" }, + }), + ); + expect(protect).not.toHaveBeenCalled(); + }); + + test.each(["GET", "POST", "OPTIONS"])( + "does not bypass Clerk for %s on other /mcp-prefixed paths", + async (method) => { + const protect = mock(async () => {}); + await handleRequest( + { protect }, + new NextRequest("https://mcp.example.test/mcp-other", { + method, + headers: { Authorization: "Bearer sk_test_key" }, + }), + ); + expect(protect).toHaveBeenCalledTimes(1); + }, + ); + + test("keeps organization selection protected by Clerk", async () => { + const protect = mock(async () => {}); + await handleRequest( + { protect }, + new NextRequest("https://mcp.example.test/select-org"), + ); + expect(protect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 24b5a0c1..cc5852ac 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,10 +1,11 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; -import { isValidJwtFormat } from "@/lib/auth-utils"; // Public routes that don't require authentication const isPublicRoute = createRouteMatcher([ "/", "/(.well-known)(.*)", + // The MCP route validates bearer tokens and returns OAuth discovery challenges. + "/mcp", "/register", "/authorize", "/oauth-consent", @@ -17,15 +18,6 @@ const isPublicRoute = createRouteMatcher([ export default clerkMiddleware(async (auth, req) => { if (isPublicRoute(req)) return; - if (req.nextUrl.pathname.startsWith("/mcp")) { - if (req.method === "OPTIONS") return; - const authheader = req.headers.get("Authorization"); - if (authheader?.startsWith("Bearer ")) { - const token = authheader.substring(7).trim(); - // If it's NOT a JWT format, treat it as an API key - if (!isValidJwtFormat(token)) return; - } - } await auth.protect(); });