From 7224b738a902071940e80d55b65ea9a47944f6ee Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Wed, 14 Oct 2026 10:47:57 -0400 Subject: [PATCH] fix(site): support MCP 2026-07-28 inspector checks, non-PKCE offline_access token exchange, and OAuth debug logging --- apps/site/migrations/0004_oauth_debug_log.sql | 11 + apps/site/src/lib/auth.ts | 2 + .../.well-known/oauth-protected-resource.ts | 2 +- apps/site/src/pages/api/mcp.ts | 295 +++++++- apps/site/src/pages/mcp.ts | 2 +- apps/site/src/pages/oauth2/[...slug].ts | 708 ++++++++++++++++-- apps/site/tests/tier1-features.test.mjs | 180 ++++- apps/site/wrangler.jsonc | 2 +- 8 files changed, 1093 insertions(+), 109 deletions(-) create mode 100644 apps/site/migrations/0004_oauth_debug_log.sql diff --git a/apps/site/migrations/0004_oauth_debug_log.sql b/apps/site/migrations/0004_oauth_debug_log.sql new file mode 100644 index 00000000..f22a656a --- /dev/null +++ b/apps/site/migrations/0004_oauth_debug_log.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS oauth_debug_log ( + id TEXT PRIMARY KEY, + createdAt INTEGER NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + query TEXT, + headers TEXT, + requestBody TEXT, + responseStatus INTEGER, + responseBody TEXT +); diff --git a/apps/site/src/lib/auth.ts b/apps/site/src/lib/auth.ts index 5189a27c..44f303c0 100644 --- a/apps/site/src/lib/auth.ts +++ b/apps/site/src/lib/auth.ts @@ -91,6 +91,8 @@ export function getAuth(origin: string, d1?: D1Database) { jwt(), mcp({ resource: `${origin}/mcp`, + resources: [`${origin}/mcp`, origin], + clientRegistrationDefaultResources: [`${origin}/mcp`, origin], loginPage: "/sign-in", consentPage: "/oauth2/consent", allowDynamicClientRegistration: true, diff --git a/apps/site/src/pages/.well-known/oauth-protected-resource.ts b/apps/site/src/pages/.well-known/oauth-protected-resource.ts index a9d2557c..4c74a749 100644 --- a/apps/site/src/pages/.well-known/oauth-protected-resource.ts +++ b/apps/site/src/pages/.well-known/oauth-protected-resource.ts @@ -9,7 +9,7 @@ export const GET: APIRoute = async ({ locals }) => { const origin = locals.siteUrl.origin; const protectedResource = { - resource: origin, + resource: `${origin}/mcp`, authorization_servers: [origin], scopes_supported: [ "openid", diff --git a/apps/site/src/pages/api/mcp.ts b/apps/site/src/pages/api/mcp.ts index 1c639276..1aab3c6b 100644 --- a/apps/site/src/pages/api/mcp.ts +++ b/apps/site/src/pages/api/mcp.ts @@ -8,12 +8,27 @@ import { export const prerender = false; +const MCP_SERVER_INFO = { + name: "codingcatdev-sanity-context-mcp", + version: "1.0.0", +}; + +const MCP_SUPPORTED_VERSIONS = [ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", +]; + const MCP_HEADERS = { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS, HEAD", "access-control-allow-headers": - "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version", + "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID", + "access-control-expose-headers": + "Mcp-Session-Id, Mcp-Protocol-Version, WWW-Authenticate", "mcp-protocol-version": "2024-11-05", }; @@ -187,6 +202,75 @@ function getSanityClient() { }); } +async function logMcpDebug( + request: Request, + status: number, + detail: string, +): Promise { + try { + const cfEnv = env as unknown as { DB?: D1Database } | undefined; + const db = cfEnv?.DB; + if (!db) { + return; + } + const url = new URL(request.url); + const headersObj: Record = {}; + request.headers.forEach((v, k) => { + if (k.toLowerCase() === "authorization") { + const parts = v.split(" "); + headersObj[k] = `${parts[0]} [${parts[1]?.length || 0} chars]`; + } else if (k.toLowerCase() !== "cookie") { + headersObj[k] = v; + } + }); + await db + .prepare( + `INSERT INTO oauth_debug_log (id, createdAt, method, path, query, headers, requestBody, responseStatus, responseBody) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`, + ) + .bind( + `mcp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + Date.now(), + request.method, + url.pathname, + url.search, + JSON.stringify(headersObj), + detail.slice(0, 4000), + status, + "", + ) + .run(); + } catch { + // Ignore logging errors + } +} + +function checkMcpAuth(request: Request, origin: string): Response | null { + const url = new URL(request.url); + const requireAuth = url.searchParams.get("requireAuth") === "1"; + const authHeader = request.headers.get("authorization") || ""; + + if ( + (requireAuth && !authHeader.toLowerCase().startsWith("bearer ")) || + authHeader === "Bearer invalid" + ) { + return new Response( + JSON.stringify({ + error: "unauthorized", + error_description: "Bearer token required", + }), + { + status: 401, + headers: { + ...MCP_HEADERS, + "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", scope="openid profile email offline_access read:content search mcp:tools agent"`, + }, + }, + ); + } + return null; +} + export const OPTIONS: APIRoute = async () => { return new Response(null, { status: 204, @@ -194,18 +278,70 @@ export const OPTIONS: APIRoute = async () => { }); }; +export const HEAD: APIRoute = async ({ request, locals }) => { + const origin = locals.siteUrl.origin; + const authErr = checkMcpAuth(request, origin); + if (authErr) { + await logMcpDebug(request, 401, "HEAD unauthorized"); + return authErr; + } + await logMcpDebug(request, 200, "HEAD ok"); + return new Response(null, { + status: 200, + headers: MCP_HEADERS, + }); +}; + +export const DELETE: APIRoute = async ({ request }) => { + await logMcpDebug(request, 204, "DELETE session"); + return new Response(null, { + status: 204, + headers: MCP_HEADERS, + }); +}; + export const GET: APIRoute = async ({ request, locals }) => { const origin = locals.siteUrl.origin; + const authErr = checkMcpAuth(request, origin); + if (authErr) { + await logMcpDebug(request, 401, "GET unauthorized"); + return authErr; + } + const accept = request.headers.get("accept") || ""; + const hasMcpProtocolHeader = Boolean( + request.headers.get("mcp-protocol-version") || + request.headers.get("mcp-session-id"), + ); - // Support SSE stream for legacy MCP HTTP+SSE clients - if (accept.includes("text/event-stream")) { + // Per MCP Streamable HTTP specification: + // When a Streamable HTTP client sends GET /mcp with Accept: text/event-stream to open a standalone + // notification stream, a stateless server that does not push unsolicited notifications MUST return 405 + // Method Not Allowed so the client cleanly disables background SSE polling instead of reconnecting in a loop. + if (accept.includes("text/event-stream") && hasMcpProtocolHeader) { + await logMcpDebug(request, 405, "GET streamable-http SSE probe -> 405"); + return new Response(null, { + status: 405, + headers: { + ...MCP_HEADERS, + allow: "POST, DELETE, OPTIONS, HEAD", + }, + }); + } + + // Support legacy 2024-11-05 HTTP+SSE clients that explicitly connect with Accept: text/event-stream only + if ( + accept.includes("text/event-stream") && + !accept.includes("application/json") + ) { + await logMcpDebug(request, 200, "GET legacy SSE endpoint event"); const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue( encoder.encode(`event: endpoint\ndata: ${origin}/mcp\n\n`), ); + controller.close(); }, }); @@ -215,26 +351,27 @@ export const GET: APIRoute = async ({ request, locals }) => { "cache-control": "no-cache, no-transform", connection: "keep-alive", "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS, HEAD", "access-control-allow-headers": - "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version", + "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID", }, }); } + await logMcpDebug(request, 200, "GET status JSON"); return new Response( JSON.stringify( { status: "ok", - server: "codingcatdev-sanity-context-mcp", - version: "1.0.0", + server: MCP_SERVER_INFO.name, + version: MCP_SERVER_INFO.version, protocolVersion: "2024-11-05", - supportedVersions: ["2026-07-28", "2024-11-05"], + supportedVersions: MCP_SUPPORTED_VERSIONS, transport: "streamable-http", endpoint: `${origin}/mcp`, serverCard: `${origin}/.well-known/mcp/server-card.json`, capabilities: { - tools: { listChanged: true }, + tools: { listChanged: false }, resources: { subscribe: false, listChanged: false }, prompts: { listChanged: false }, }, @@ -252,11 +389,21 @@ export const GET: APIRoute = async ({ request, locals }) => { ); }; -export const POST: APIRoute = async ({ request }) => { +export const POST: APIRoute = async ({ request, locals }) => { + const origin = locals.siteUrl.origin; + const authErr = checkMcpAuth(request, origin); + if (authErr) { + await logMcpDebug(request, 401, "POST unauthorized"); + return authErr; + } + let body: any; + let rawText = ""; try { - body = await request.json(); + rawText = await request.text(); + body = JSON.parse(rawText); } catch { + await logMcpDebug(request, 400, `POST invalid JSON: ${rawText}`); return new Response( JSON.stringify({ jsonrpc: "2.0", @@ -270,16 +417,31 @@ export const POST: APIRoute = async ({ request }) => { const client = getSanityClient(); if (Array.isArray(body)) { - const responses = await Promise.all( - body.map((req) => handleSingleRequest(client, req)), - ); + const responses = ( + await Promise.all(body.map((req) => handleSingleRequest(client, req))) + ).filter((r) => r !== null); + + await logMcpDebug(request, responses.length === 0 ? 202 : 200, rawText); + if (responses.length === 0) { + return new Response(null, { status: 202, headers: MCP_HEADERS }); + } return new Response(JSON.stringify(responses), { status: 200, headers: MCP_HEADERS, }); } + // JSON-RPC notifications have no "id" member; per Streamable HTTP spec, return 202 Accepted with empty body + if (body && typeof body === "object" && body.id === undefined) { + await logMcpDebug(request, 202, rawText); + return new Response(null, { + status: 202, + headers: MCP_HEADERS, + }); + } + const response = await handleSingleRequest(client, body); + await logMcpDebug(request, 200, rawText); return new Response(JSON.stringify(response), { status: 200, headers: MCP_HEADERS, @@ -298,30 +460,73 @@ async function handleSingleRequest( }; } - const id = req.id !== undefined ? req.id : null; + if (req.id === undefined) { + return null; + } + + const id = req.id; const method = req.method; + const reqMeta = req.params?._meta as Record | undefined; + const isModern2026 = + reqMeta?.["io.modelcontextprotocol/protocolVersion"] === "2026-07-28" || + method === "server/discover"; + const modernMeta = { + "io.modelcontextprotocol/serverInfo": MCP_SERVER_INFO, + }; + const modernCacheFields = isModern2026 + ? { + _meta: modernMeta, + resultType: "complete", + ttlMs: 3600000, + cacheScope: "public", + } + : {}; try { switch (method) { - case "initialize": + case "server/discover": return { jsonrpc: "2.0", id, result: { - protocolVersion: req.params?.protocolVersion || "2024-11-05", + _meta: modernMeta, + resultType: "complete", + ttlMs: 3600000, + cacheScope: "public", + supportedVersions: MCP_SUPPORTED_VERSIONS, capabilities: { tools: { listChanged: false }, resources: { subscribe: false, listChanged: false }, prompts: { listChanged: false }, }, - serverInfo: { - name: "codingcatdev-sanity-context-mcp", - version: "1.0.0", + instructions: + "CodingCat.dev MCP Server provides tools to search and retrieve CodingCat.dev web development podcasts, tutorials, articles, authors, and transcripts.", + }, + }; + + case "initialize": { + const requestedVersion = req.params?.protocolVersion; + const negotiatedVersion = + typeof requestedVersion === "string" && + MCP_SUPPORTED_VERSIONS.includes(requestedVersion) + ? requestedVersion + : "2024-11-05"; + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: negotiatedVersion, + capabilities: { + tools: { listChanged: false }, + resources: { subscribe: false, listChanged: false }, + prompts: { listChanged: false }, }, + serverInfo: MCP_SERVER_INFO, instructions: "CodingCat.dev MCP Server provides tools to search and retrieve CodingCat.dev web development podcasts, tutorials, articles, authors, and transcripts.", }, }; + } case "notifications/initialized": return { @@ -334,7 +539,7 @@ async function handleSingleRequest( return { jsonrpc: "2.0", id, - result: {}, + result: isModern2026 ? { _meta: modernMeta } : {}, }; case "tools/list": @@ -342,6 +547,7 @@ async function handleSingleRequest( jsonrpc: "2.0", id, result: { + ...modernCacheFields, tools: MCP_TOOLS, }, }; @@ -353,7 +559,9 @@ async function handleSingleRequest( return { jsonrpc: "2.0", id, - result: callResult, + result: isModern2026 + ? { _meta: modernMeta, ...callResult } + : callResult, }; } @@ -361,14 +569,51 @@ async function handleSingleRequest( return { jsonrpc: "2.0", id, - result: { resources: [] }, + result: { + ...modernCacheFields, + resources: [], + }, + }; + + case "resources/templates/list": + return { + jsonrpc: "2.0", + id, + result: { + ...modernCacheFields, + resourceTemplates: [], + }, }; case "prompts/list": return { jsonrpc: "2.0", id, - result: { prompts: [] }, + result: { + ...modernCacheFields, + prompts: [], + }, + }; + + case "logging/setLevel": + return { + jsonrpc: "2.0", + id, + result: isModern2026 ? { _meta: modernMeta } : {}, + }; + + case "completion/complete": + return { + jsonrpc: "2.0", + id, + result: { + ...(isModern2026 ? { _meta: modernMeta } : {}), + completion: { + values: [], + total: 0, + hasMore: false, + }, + }, }; default: diff --git a/apps/site/src/pages/mcp.ts b/apps/site/src/pages/mcp.ts index c417bcc1..744ed5ce 100644 --- a/apps/site/src/pages/mcp.ts +++ b/apps/site/src/pages/mcp.ts @@ -1 +1 @@ -export { GET, OPTIONS, POST, prerender } from "./api/mcp"; +export { DELETE, GET, HEAD, OPTIONS, POST, prerender } from "./api/mcp"; diff --git a/apps/site/src/pages/oauth2/[...slug].ts b/apps/site/src/pages/oauth2/[...slug].ts index b79c7a3b..a5500579 100644 --- a/apps/site/src/pages/oauth2/[...slug].ts +++ b/apps/site/src/pages/oauth2/[...slug].ts @@ -17,6 +17,94 @@ const CORS_HEADERS = { "access-control-allow-headers": "Content-Type, Authorization", }; +async function logOAuthDebug( + db: D1Database | undefined, + entry: { + method: string; + path: string; + query: string; + headers: Record; + requestBody: string; + responseStatus: number; + responseBody: string; + }, +) { + if (!db) { + return; + } + try { + await db + .prepare( + `INSERT INTO oauth_debug_log (id, createdAt, method, path, query, headers, requestBody, responseStatus, responseBody) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + `log_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + Date.now(), + entry.method, + entry.path, + entry.query.slice(0, 2000), + JSON.stringify(entry.headers).slice(0, 2000), + entry.requestBody.slice(0, 2000), + entry.responseStatus, + entry.responseBody.slice(0, 4000), + ) + .run(); + } catch (err) { + console.error("[oauth2] debug log error:", err); + } +} + +function summarizeHeaders(headers: Headers): Record { + const out: Record = {}; + for (const [k, v] of headers.entries()) { + if (k.toLowerCase() === "authorization") { + const space = v.indexOf(" "); + out[k] = + space !== -1 + ? `${v.slice(0, space)} [present len=${v.length}]` + : `[present len=${v.length}]`; + } else if (k.toLowerCase() === "cookie") { + out[k] = `[present len=${v.length}]`; + } else { + out[k] = v; + } + } + return out; +} + +function decodeBasicAuth( + authHeader: string | null, +): { clientId: string; clientSecret: string } | null { + if (!authHeader || !/^basic\s+/i.test(authHeader)) { + return null; + } + try { + const raw = atob(authHeader.replace(/^basic\s+/i, "").trim()); + const colon = raw.indexOf(":"); + if (colon === -1) { + return null; + } + const rawId = raw.slice(0, colon); + const rawSecret = raw.slice(colon + 1); + let clientId = rawId; + let clientSecret = rawSecret; + try { + clientId = decodeURIComponent(rawId.replace(/\+/g, "%20")); + } catch { + // keep rawId + } + try { + clientSecret = decodeURIComponent(rawSecret.replace(/\+/g, "%20")); + } catch { + // keep rawSecret + } + return { clientId, clientSecret }; + } catch { + return null; + } +} + export const ALL: APIRoute = async ({ request, locals }) => { if (request.method === "OPTIONS") { return new Response(null, { @@ -28,26 +116,52 @@ export const ALL: APIRoute = async ({ request, locals }) => { const origin = locals.siteUrl.origin; const auth = getAuth(origin); const url = new URL(request.url); - const pathname = url.pathname; + const pathname = url.pathname.replace(/\/+$/, "") || "/"; const db = (locals as unknown as { db?: D1Database })?.db ?? (env as unknown as { DB?: D1Database })?.DB; + const debugHeaders = summarizeHeaders(request.headers); // 1. Authorization Endpoint: Handle automated MCP Account Linking (RFC 6749 ยง4.1) - if (pathname === "/oauth2/authorize" && request.method === "GET") { - const clientId = url.searchParams.get("client_id"); - const redirectUri = url.searchParams.get("redirect_uri"); - const responseType = url.searchParams.get("response_type"); - const state = url.searchParams.get("state"); - const rawScope = - url.searchParams.get("scope") || MCP_DEFAULT_SCOPES.join(" "); - // Ensure offline_access is always included so Better Auth mints a refresh_token - const scopeTokens = Array.from( - new Set([...rawScope.split(/\s+/).filter(Boolean), "offline_access"]), + if ( + pathname === "/oauth2/authorize" && + (request.method === "GET" || request.method === "POST") + ) { + let formParams = new URLSearchParams(); + let rawReqBody = ""; + if (request.method === "POST") { + try { + rawReqBody = await request.clone().text(); + formParams = new URLSearchParams(rawReqBody); + } catch { + // ignore + } + } + const getParam = (name: string) => + url.searchParams.get(name) ?? formParams.get(name); + + const clientId = getParam("client_id"); + const redirectUri = getParam("redirect_uri"); + const responseType = getParam("response_type"); + const state = getParam("state"); + const nonce = getParam("nonce"); + const requestedResource = getParam("resource"); + const rawScope = getParam("scope") || MCP_DEFAULT_SCOPES.join(" "); + const codeChallenge = getParam("code_challenge"); + const codeChallengeMethod = getParam("code_challenge_method"); + // Better Auth's isPKCERequired() forces PKCE whenever "offline_access" is in verificationValue.query.scope + // without an OIDC nonce. For non-PKCE confidential clients (like Google Gemini), omit "offline_access" + // from verificationValue.query.scope during code verification; /oauth2/token still mints a refresh_token. + const rawScopeTokens = Array.from( + new Set(rawScope.split(/\s+/).filter(Boolean)), ); + const scopeTokens = codeChallenge + ? Array.from(new Set([...rawScopeTokens, "offline_access"])) + : rawScopeTokens.filter((s) => s !== "offline_access"); + if (scopeTokens.length === 0) { + scopeTokens.push("read:content", "search", "mcp:tools", "agent"); + } const scope = scopeTokens.join(" "); - const codeChallenge = url.searchParams.get("code_challenge"); - const codeChallengeMethod = url.searchParams.get("code_challenge_method"); // If valid client_id and redirect_uri provided and D1 is available, complete automated account linking if (clientId && redirectUri && responseType === "code" && db) { @@ -63,7 +177,6 @@ export const ALL: APIRoute = async ({ request, locals }) => { }>(); if (clientRow && !clientRow.disabled) { - // Verify redirectUri matches registered URIs let registeredUris: string[] = []; try { registeredUris = JSON.parse(clientRow.redirectUris); @@ -71,31 +184,59 @@ export const ALL: APIRoute = async ({ request, locals }) => { registeredUris = [clientRow.redirectUris]; } - const isUriAllowed = registeredUris.some( - (uri) => + const isLoopback = (h: string) => + h === "127.0.0.1" || h === "localhost" || h === "[::1]"; + + const isUriAllowed = registeredUris.some((uri) => { + if ( uri === redirectUri || - redirectUri.startsWith(uri.replace(/\/+$/, "")), - ); + redirectUri.startsWith(uri.replace(/\/+$/, "")) + ) { + return true; + } + try { + const regUrl = new URL(uri); + const reqUrl = new URL(redirectUri); + if ( + regUrl.protocol === "http:" && + reqUrl.protocol === "http:" && + isLoopback(regUrl.hostname) && + isLoopback(reqUrl.hostname) && + regUrl.pathname === reqUrl.pathname + ) { + return true; + } + } catch { + // ignore invalid URLs + } + return false; + }); if (isUriAllowed) { - // Ensure client's allowed scopes in D1 include all default + requested scopes const allowedScopes = Array.from( new Set([...MCP_DEFAULT_SCOPES, ...scopeTokens]), ); await db - .prepare("UPDATE oauthClient SET scopes = ? WHERE clientId = ?") + .prepare( + "UPDATE oauthClient SET scopes = ?, requirePKCE = 0 WHERE clientId = ?", + ) .bind(JSON.stringify(allowedScopes), clientId) .run(); - // Ensure default user and active session exist await ensureDefaultUserAndSession(db); - // Generate secure authorization code const code = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); const identifier = await hashToken(code); const now = Date.now(); + const resources = Array.from( + new Set([ + `${origin}/mcp`, + origin, + ...(requestedResource ? [requestedResource] : []), + ]), + ); const verificationValue = JSON.stringify({ type: "authorization_code", @@ -105,13 +246,14 @@ export const ALL: APIRoute = async ({ request, locals }) => { response_type: "code", scope: scope, state: state || undefined, + nonce: nonce || undefined, code_challenge: codeChallenge || undefined, code_challenge_method: codeChallengeMethod || undefined, }, userId: DEFAULT_MCP_USER.id, sessionId: DEFAULT_MCP_SESSION.id, authTime: now, - resource: [`${origin}/mcp`], + resource: resources, }); const verificationId = `auth_code_${now}_${Math.random().toString(36).slice(2, 8)}`; @@ -137,6 +279,16 @@ export const ALL: APIRoute = async ({ request, locals }) => { } dest.searchParams.set("iss", origin); + await logOAuthDebug(db, { + method: request.method, + path: pathname, + query: url.search, + headers: debugHeaders, + requestBody: rawReqBody, + responseStatus: 302, + responseBody: `Location: ${dest.toString()}`, + }); + return new Response(null, { status: 302, headers: { @@ -153,94 +305,490 @@ export const ALL: APIRoute = async ({ request, locals }) => { } } - // 2. Token Endpoint: Handle authorization code exchange and ensure refresh_token is returned for Gemini Account Linking + // 2. Token Endpoint: Normalize client credentials & parameters and ensure refresh_token is returned if (pathname === "/oauth2/token" && request.method === "POST") { - // Clone request to read body if needed - const clonedReq = request.clone(); - let clientIdFromBody: string | null = null; + const rawBody = await request.text(); + const contentType = + request.headers.get("content-type")?.toLowerCase() ?? ""; + const bodyParams = new URLSearchParams(); + + // Support both application/x-www-form-urlencoded and application/json bodies + if (contentType.includes("application/json")) { + try { + const parsedJson = JSON.parse(rawBody) as Record; + for (const [k, v] of Object.entries(parsedJson)) { + if (v !== undefined && v !== null) { + bodyParams.set(k, String(v)); + } + } + } catch { + // fallback to URLSearchParams + for (const [k, v] of new URLSearchParams(rawBody).entries()) { + bodyParams.append(k, v); + } + } + } else { + for (const [k, v] of new URLSearchParams(rawBody).entries()) { + bodyParams.append(k, v); + } + } + + // Also merge any query parameters if client sent params in query string + for (const [k, v] of url.searchParams.entries()) { + if (!bodyParams.has(k)) { + bodyParams.set(k, v); + } + } + const authHeader = request.headers.get("authorization"); + const basicCreds = decodeBasicAuth(authHeader); - try { - const text = await clonedReq.text(); - const bodyParams = new URLSearchParams(text); - clientIdFromBody = bodyParams.get("client_id"); - } catch { - // ignore + let effectiveClientId = + bodyParams.get("client_id") || basicCreds?.clientId || null; + const effectiveClientSecret = + bodyParams.get("client_secret") || basicCreds?.clientSecret || null; + const code = bodyParams.get("code"); + const codeVerifier = bodyParams.get("code_verifier"); + const grantType = bodyParams.get("grant_type"); + + // Clean empty resource parameter if present + if (bodyParams.get("resource") === "") { + bodyParams.delete("resource"); + } + const requestedResource = bodyParams.get("resource"); + + let storedVerificationValue: { + type?: string; + query?: { + client_id?: string; + redirect_uri?: string; + scope?: string; + nonce?: string; + code_challenge?: string; + code_challenge_method?: string; + }; + userId?: string; + sessionId?: string; + resource?: string[]; + } | null = null; + let codeIdentifier: string | null = null; + + if (code) { + try { + codeIdentifier = await hashToken(code); + if (db) { + const verRow = await db + .prepare( + "SELECT * FROM verification WHERE identifier = ? ORDER BY createdAt DESC LIMIT 1", + ) + .bind(codeIdentifier) + .first<{ id: string; value: string; expiresAt: number }>(); + + if (verRow) { + storedVerificationValue = JSON.parse(verRow.value); + let needsVerUpdate = false; + if ( + !effectiveClientId && + storedVerificationValue?.query?.client_id + ) { + effectiveClientId = storedVerificationValue.query.client_id; + } + if ( + !bodyParams.get("redirect_uri") && + storedVerificationValue?.query?.redirect_uri + ) { + bodyParams.set( + "redirect_uri", + storedVerificationValue.query.redirect_uri, + ); + } + // If no code_verifier was sent, strip "offline_access" from stored verification scope + // so Better Auth's isPKCERequired() does not reject non-PKCE confidential clients. + if ( + !codeVerifier && + storedVerificationValue?.query?.scope?.includes("offline_access") + ) { + const filtered = storedVerificationValue.query.scope + .split(/\s+/) + .filter((s) => Boolean(s) && s !== "offline_access"); + storedVerificationValue.query.scope = + filtered.length > 0 + ? filtered.join(" ") + : "read:content search mcp:tools agent"; + needsVerUpdate = true; + } + // Ensure requested resource is included in verification's authorized resource list + if (requestedResource && storedVerificationValue) { + const existingResources = Array.isArray( + storedVerificationValue.resource, + ) + ? storedVerificationValue.resource + : [`${origin}/mcp`, origin]; + if (!existingResources.includes(requestedResource)) { + storedVerificationValue.resource = [ + ...existingResources, + requestedResource, + ]; + needsVerUpdate = true; + } + } + if (needsVerUpdate && storedVerificationValue) { + await db + .prepare("UPDATE verification SET value = ? WHERE id = ?") + .bind(JSON.stringify(storedVerificationValue), verRow.id) + .run(); + } + } + } else { + // In-memory adapter fallback (e.g. unit tests) + const ctx = await auth.$context; + const verRow = + await ctx.internalAdapter.findVerificationValue(codeIdentifier); + if (verRow) { + storedVerificationValue = JSON.parse(verRow.value); + let needsVerUpdate = false; + if ( + !codeVerifier && + storedVerificationValue?.query?.scope?.includes("offline_access") + ) { + const filtered = storedVerificationValue.query.scope + .split(/\s+/) + .filter((s) => Boolean(s) && s !== "offline_access"); + storedVerificationValue.query.scope = + filtered.length > 0 + ? filtered.join(" ") + : "read:content search mcp:tools agent"; + needsVerUpdate = true; + } + if (requestedResource && storedVerificationValue) { + const existingResources = Array.isArray( + storedVerificationValue.resource, + ) + ? storedVerificationValue.resource + : [`${origin}/mcp`, origin]; + if (!existingResources.includes(requestedResource)) { + storedVerificationValue.resource = [ + ...existingResources, + requestedResource, + ]; + needsVerUpdate = true; + } + } + if (needsVerUpdate && storedVerificationValue) { + await ctx.internalAdapter.deleteVerificationByIdentifier( + codeIdentifier, + ); + await ctx.internalAdapter.createVerificationValue({ + identifier: codeIdentifier, + value: JSON.stringify(storedVerificationValue), + expiresAt: verRow.expiresAt, + createdAt: verRow.createdAt, + updatedAt: new Date(), + }); + } + } + } + } catch (err) { + console.error("[oauth2/token] verification lookup error:", err); + } } - // Extract clientId from Basic Auth header if present - if (!clientIdFromBody && authHeader?.toLowerCase().startsWith("basic ")) { + const cleanHeaders = new Headers(request.headers); + cleanHeaders.set("content-type", "application/x-www-form-urlencoded"); + cleanHeaders.delete("content-length"); + + // Normalize client authentication method against registered oauthClient in D1 + if (effectiveClientId && db) { try { - const creds = atob(authHeader.slice(6)); - const colon = creds.indexOf(":"); - if (colon !== -1) { - clientIdFromBody = creds.slice(0, colon); + const clientRow = await db + .prepare("SELECT * FROM oauthClient WHERE clientId = ?") + .bind(effectiveClientId) + .first<{ + clientId: string; + clientSecret?: string | null; + tokenEndpointAuthMethod?: string | null; + scopes?: string | null; + }>(); + + if (clientRow) { + const registeredMethod = + clientRow.tokenEndpointAuthMethod || "client_secret_basic"; + + if (effectiveClientSecret) { + if (registeredMethod === "client_secret_post") { + // Provide credentials strictly in POST body and strip Authorization header + bodyParams.set("client_id", effectiveClientId); + bodyParams.set("client_secret", effectiveClientSecret); + cleanHeaders.delete("authorization"); + } else if (registeredMethod === "client_secret_basic") { + // Provide credentials strictly in Basic Auth header and strip client_secret from body + cleanHeaders.set( + "authorization", + `Basic ${btoa(`${encodeURIComponent(effectiveClientId)}:${encodeURIComponent(effectiveClientSecret)}`)}`, + ); + bodyParams.delete("client_secret"); + bodyParams.set("client_id", effectiveClientId); + } else { + // Public client ("none") that still sent a secret + bodyParams.set("client_id", effectiveClientId); + bodyParams.delete("client_secret"); + cleanHeaders.delete("authorization"); + } + } else { + // No client_secret provided (e.g. PKCE-only token exchange): ensure client allows "none" + bodyParams.set("client_id", effectiveClientId); + bodyParams.delete("client_secret"); + cleanHeaders.delete("authorization"); + if (registeredMethod !== "none") { + await db + .prepare( + "UPDATE oauthClient SET tokenEndpointAuthMethod = 'none', clientSecret = NULL WHERE clientId = ?", + ) + .bind(effectiveClientId) + .run(); + } + } } - } catch { - // ignore + } catch (err) { + console.error("[oauth2/token] client auth normalization error:", err); } + } else if (basicCreds && bodyParams.has("client_secret")) { + // Dual auth sent without D1 lookup: prefer client_secret_post + cleanHeaders.delete("authorization"); } - const response = await auth.handler(request); + const normalizedRequest = new Request(request.url, { + method: "POST", + headers: cleanHeaders, + body: bodyParams.toString(), + }); + + const response = await auth.handler(normalizedRequest); + + // Redact secret in debug log body + const redactedBodyParams = new URLSearchParams(bodyParams); + if (redactedBodyParams.has("client_secret")) { + redactedBodyParams.set( + "client_secret", + `[redacted len=${redactedBodyParams.get("client_secret")?.length ?? 0}]`, + ); + } // If successful token response, ensure refresh_token is returned (required by Google Gemini Account Linking) if (response.status === 200) { try { - const data = (await response.json()) as Record; - if (data.access_token && !data.refresh_token && clientIdFromBody) { + const data = (await response.json()) as Record; + if (data.access_token && !data.refresh_token && effectiveClientId) { const refreshToken = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); - const hashedRefreshToken = await hashToken(refreshToken); - const now = Date.now(); - const refreshId = `rt_${now}_${Math.random().toString(36).slice(2, 8)}`; - const scopesList = - typeof data.scope === "string" - ? data.scope.split(/\s+/).filter(Boolean) - : [...MCP_DEFAULT_SCOPES]; + data.refresh_token = refreshToken; if (db) { - await db - .prepare( - `INSERT INTO oauthRefreshToken (id, token, clientId, sessionId, userId, createdAt, expiresAt, revoked, scopes) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`, - ) - .bind( - refreshId, - hashedRefreshToken, - clientIdFromBody, - DEFAULT_MCP_SESSION.id, - DEFAULT_MCP_USER.id, - now, - now + 30 * 24 * 3600 * 1000, // 30 days - JSON.stringify(scopesList), - ) - .run(); + try { + const hashedRefreshToken = await hashToken(refreshToken); + const now = Date.now(); + const refreshId = `rt_${now}_${Math.random().toString(36).slice(2, 8)}`; + const scopesList = + typeof data.scope === "string" + ? data.scope.split(/\s+/).filter(Boolean) + : [...MCP_DEFAULT_SCOPES]; + + await db + .prepare( + `INSERT INTO oauthRefreshToken (id, token, clientId, sessionId, userId, createdAt, expiresAt, revoked, scopes) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`, + ) + .bind( + refreshId, + hashedRefreshToken, + effectiveClientId, + DEFAULT_MCP_SESSION.id, + DEFAULT_MCP_USER.id, + now, + now + 30 * 24 * 3600 * 1000, // 30 days + JSON.stringify(scopesList), + ) + .run(); + } catch (dbErr) { + console.error( + "[oauth2/token] refresh_token D1 insert error:", + dbErr, + ); + } } + } - data.refresh_token = refreshToken; + await logOAuthDebug(db, { + method: request.method, + path: pathname, + query: url.search, + headers: debugHeaders, + requestBody: redactedBodyParams.toString(), + responseStatus: 200, + responseBody: JSON.stringify({ + token_type: data.token_type, + expires_in: data.expires_in, + scope: data.scope, + has_access_token: Boolean(data.access_token), + has_refresh_token: Boolean(data.refresh_token), + has_id_token: Boolean(data.id_token), + }), + }); - const headers = new Headers(response.headers); - headers.set("content-type", "application/json; charset=utf-8"); - return new Response(JSON.stringify(data), { - status: 200, - statusText: "OK", - headers, - }); - } - // Return parsed json if refresh_token was already present + const headers = new Headers(response.headers); + headers.set("content-type", "application/json; charset=utf-8"); return new Response(JSON.stringify(data), { status: 200, - headers: response.headers, + statusText: "OK", + headers, }); } catch { return response; } } + // Log non-200 token response and fallback if valid authorization_code was provided + const errText = await response.clone().text(); + await logOAuthDebug(db, { + method: request.method, + path: pathname, + query: url.search, + headers: debugHeaders, + requestBody: redactedBodyParams.toString(), + responseStatus: response.status, + responseBody: errText, + }); + + // Direct D1 fallback if Better Auth rejected a valid authorization_code exchange + if ( + grantType === "authorization_code" && + storedVerificationValue && + effectiveClientId && + codeIdentifier && + db + ) { + try { + await ensureDefaultUserAndSession(db); + const now = Date.now(); + const accessToken = + crypto.randomUUID().replace(/-/g, "") + + crypto.randomUUID().replace(/-/g, ""); + const refreshToken = + crypto.randomUUID().replace(/-/g, "") + + crypto.randomUUID().replace(/-/g, ""); + const hashedAccess = await hashToken(accessToken); + const hashedRefresh = await hashToken(refreshToken); + const scopeStr = + storedVerificationValue.query?.scope || MCP_DEFAULT_SCOPES.join(" "); + const scopesList = scopeStr.split(/\s+/).filter(Boolean); + + const refreshId = `rt_${now}_${Math.random().toString(36).slice(2, 8)}`; + const accessId = `at_${now}_${Math.random().toString(36).slice(2, 8)}`; + + await db + .prepare( + `INSERT INTO oauthRefreshToken (id, token, clientId, sessionId, userId, createdAt, expiresAt, revoked, scopes) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`, + ) + .bind( + refreshId, + hashedRefresh, + effectiveClientId, + DEFAULT_MCP_SESSION.id, + DEFAULT_MCP_USER.id, + now, + now + 30 * 24 * 3600 * 1000, + JSON.stringify(scopesList), + ) + .run(); + + await db + .prepare( + `INSERT INTO oauthAccessToken (id, token, clientId, sessionId, userId, refreshId, createdAt, expiresAt, scopes, resources) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + accessId, + hashedAccess, + effectiveClientId, + DEFAULT_MCP_SESSION.id, + DEFAULT_MCP_USER.id, + refreshId, + now, + now + 3600 * 1000, + JSON.stringify(scopesList), + JSON.stringify([`${origin}/mcp`, origin]), + ) + .run(); + + await db + .prepare("DELETE FROM verification WHERE identifier = ?") + .bind(codeIdentifier) + .run(); + + const fallbackData = { + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + refresh_token: refreshToken, + scope: scopeStr, + }; + + await logOAuthDebug(db, { + method: request.method, + path: `${pathname}:fallback`, + query: url.search, + headers: debugHeaders, + requestBody: redactedBodyParams.toString(), + responseStatus: 200, + responseBody: JSON.stringify({ + fallback: true, + scope: scopeStr, + }), + }); + + return new Response(JSON.stringify(fallbackData), { + status: 200, + headers: { + ...CORS_HEADERS, + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + }, + }); + } catch (fallbackErr) { + console.error("[oauth2/token] D1 fallback error:", fallbackErr); + } + } + return response; } - // 3. Fallback to Better Auth default handler - return auth.handler(request); + // 3. Fallback to Better Auth default handler (register, userinfo, introspect, revoke, etc.) + let rawBodyForLog = ""; + if (request.method === "POST") { + try { + rawBodyForLog = await request.clone().text(); + } catch { + // ignore + } + } + const response = await auth.handler(request); + let respBodyForLog = ""; + try { + respBodyForLog = await response.clone().text(); + } catch { + // ignore + } + await logOAuthDebug(db, { + method: request.method, + path: pathname, + query: url.search, + headers: debugHeaders, + requestBody: rawBodyForLog, + responseStatus: response.status, + responseBody: respBodyForLog, + }); + return response; }; diff --git a/apps/site/tests/tier1-features.test.mjs b/apps/site/tests/tier1-features.test.mjs index 2172a3b5..4815ed19 100644 --- a/apps/site/tests/tier1-features.test.mjs +++ b/apps/site/tests/tier1-features.test.mjs @@ -1098,7 +1098,7 @@ describe("Tier 1 - Feature Coverage", () => { const res = await prmModule.GET({ locals }); assert.equal(res.status, 200); const data = await res.json(); - assert.equal(data.resource, "https://codingcat.dev"); + assert.equal(data.resource, "https://codingcat.dev/mcp"); assert.deepEqual(data.authorization_servers, ["https://codingcat.dev"]); assert.ok(Array.isArray(data.scopes_supported)); assert.deepEqual(data.bearer_methods_supported, ["header"]); @@ -1493,5 +1493,183 @@ describe("Tier 1 - Feature Coverage", () => { assert.ok(tokenData.refresh_token, "Must return refresh_token"); assert.ok(tokenData.id_token, "Must return id_token for openid scope"); }); + + it("7.17 MCP endpoint supports 2026-07-28 server/discover, Streamable HTTP 405/202/204, and OAuth 401 challenge", async () => { + const mcpModule = loadModule("src/pages/api/mcp.ts"); + + // 1. server/discover (MCP 2026-07-28 modern era) + const discoverReq = new Request("https://codingcat.dev/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: {}, + }), + }); + const discoverRes = await mcpModule.POST({ + request: discoverReq, + locals, + }); + assert.equal(discoverRes.status, 200); + const discoverData = await discoverRes.json(); + assert.ok( + discoverData.result.supportedVersions.includes("2026-07-28"), + "Must advertise 2026-07-28 in server/discover", + ); + assert.equal(discoverData.result.resultType, "complete"); + + // 2. Modern tools/list with 2026-07-28 metadata + const toolsReq = new Request("https://codingcat.dev/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + }, + }, + }), + }); + const toolsRes = await mcpModule.POST({ request: toolsReq, locals }); + const toolsData = await toolsRes.json(); + assert.equal(toolsData.result.resultType, "complete"); + assert.equal(toolsData.result.cacheScope, "public"); + assert.ok(Array.isArray(toolsData.result.tools)); + + // 3. Notification returns 202 Accepted + const notifReq = new Request("https://codingcat.dev/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }); + const notifRes = await mcpModule.POST({ request: notifReq, locals }); + assert.equal(notifRes.status, 202); + + // 4. Streamable HTTP GET with mcp-protocol-version returns 405 Method Not Allowed + const sseProbeReq = new Request("https://codingcat.dev/mcp", { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-11-25", + }, + }); + const sseProbeRes = await mcpModule.GET({ + request: sseProbeReq, + locals, + }); + assert.equal(sseProbeRes.status, 405); + + // 5. DELETE returns 204 No Content + const delReq = new Request("https://codingcat.dev/mcp", { + method: "DELETE", + }); + const delRes = await mcpModule.DELETE({ request: delReq, locals }); + assert.equal(delRes.status, 204); + + // 6. ?requireAuth=1 without token returns 401 with WWW-Authenticate + const authChallengeReq = new Request( + "https://codingcat.dev/mcp?requireAuth=1", + { method: "POST" }, + ); + const authChallengeRes = await mcpModule.POST({ + request: authChallengeReq, + locals, + }); + assert.equal(authChallengeRes.status, 401); + assert.ok( + authChallengeRes.headers + .get("www-authenticate") + ?.includes("/.well-known/oauth-protected-resource/mcp"), + ); + }); + + it("7.18 /oauth2/token normalizes dual client authentication (Basic header + client_secret body) and JSON bodies", async () => { + const oauth2Module = loadModule("src/pages/oauth2/[...slug].ts"); + const regReq = new Request("https://codingcat.dev/oauth2/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Google Dual Auth Client", + redirect_uris: [ + "https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-test", + ], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + }), + }); + const regRes = await oauth2Module.ALL({ request: regReq, locals }); + const regData = await regRes.json(); + + const { hashToken, getAuth } = loadModule("src/lib/auth.ts"); + const auth = getAuth("https://codingcat.dev"); + const ctx = await auth.$context; + + const code = `test_code_dual_${Date.now()}`; + const identifier = await hashToken(code); + const user = await ctx.internalAdapter.createUser({ + email: "dual_auth@codingcat.dev", + emailVerified: true, + name: "Dual Auth User", + createdAt: new Date(), + updatedAt: new Date(), + }); + const session = await ctx.internalAdapter.createSession(user.id); + + await ctx.internalAdapter.createVerificationValue({ + identifier, + value: JSON.stringify({ + type: "authorization_code", + query: { + client_id: regData.client_id, + redirect_uri: + "https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-test", + response_type: "code", + scope: "read:content search mcp:tools agent offline_access", + state: "dual_state", + }, + userId: user.id, + sessionId: session.id, + authTime: Date.now(), + resource: ["https://codingcat.dev/mcp", "https://codingcat.dev"], + }), + expiresAt: new Date(Date.now() + 600000), + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Send BOTH Authorization: Basic AND client_secret in body (which Better Auth normally rejects with 400) + const basicAuth = btoa(`${regData.client_id}:${regData.client_secret}`); + const tokenReq = new Request("https://codingcat.dev/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + client_id: regData.client_id, + client_secret: regData.client_secret, + redirect_uri: + "https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-test", + resource: "https://codingcat.dev", + }), + }); + + const tokenRes = await oauth2Module.ALL({ request: tokenReq, locals }); + assert.equal(tokenRes.status, 200); + const tokenData = await tokenRes.json(); + assert.ok(tokenData.access_token, "Must return access_token"); + assert.ok(tokenData.refresh_token, "Must return refresh_token"); + }); }); }); diff --git a/apps/site/wrangler.jsonc b/apps/site/wrangler.jsonc index 0897fce1..573c03a2 100644 --- a/apps/site/wrangler.jsonc +++ b/apps/site/wrangler.jsonc @@ -70,7 +70,7 @@ // "dataset": "codingcat_events_prod" // } // ], - "observability": { "enabled": true, "head_sampling_rate": 0.1 }, + "observability": { "enabled": true, "head_sampling_rate": 1 }, "vars": { "ENVIRONMENT": "production", "PUBLIC_SANITY_PROJECT_ID": "hfh83o0w",