From 9e8124f44639479a5777200f1b4c5fd5f95a4690 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 7 Jul 2026 15:03:05 -0600 Subject: [PATCH 1/2] Prefer request origin for MCP OAuth resource metadata. APP_BASE_URL remains the fallback for background work so email/workflows keep a stable public host after account moves without breaking clients that connect via heykody.dev. Co-authored-by: Cursor --- .../architecture/request-lifecycle.md | 5 ++- docs/contributing/setup-manifest.md | 20 ++++++---- .../worker/src/app/app-base-url.node.test.ts | 39 +++++++++++++++++++ packages/worker/src/app/app-base-url.ts | 22 ++++++++++- .../worker/src/email/package-subscriptions.ts | 1 - packages/worker/src/execute-maintenance.ts | 3 +- packages/worker/src/mcp-auth.workers.test.ts | 15 ++++--- .../generated-ui-app-base-url.node.test.ts | 25 ++++++------ .../src/package-runtime/package-workflows.ts | 2 - 9 files changed, 98 insertions(+), 34 deletions(-) create mode 100644 packages/worker/src/app/app-base-url.node.test.ts diff --git a/docs/contributing/architecture/request-lifecycle.md b/docs/contributing/architecture/request-lifecycle.md index 76c9adf4fb..e898e724a0 100644 --- a/docs/contributing/architecture/request-lifecycle.md +++ b/docs/contributing/architecture/request-lifecycle.md @@ -17,7 +17,10 @@ library’s built-in handler for that path advertises `resource` as the request our own metadata on that URL keeps the RFC 8707 `resource` value consistent for clients (e.g. some MCP stacks) that discover metadata from the 401 `resource_metadata` URL and would otherwise get `invalid_target` at the token -endpoint. +endpoint. Protected-resource metadata and MCP auth challenges resolve the origin +from the inbound request URL (via `getAppBaseUrl`) so clients connecting through +`heykody.dev` or a workers.dev host get matching resource values. `APP_BASE_URL` +is only the fallback for background work with no request URL. ## Routing order diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md index 9ecb289384..1d7b9015d6 100644 --- a/docs/contributing/setup-manifest.md +++ b/docs/contributing/setup-manifest.md @@ -75,10 +75,11 @@ automatically: - `COOKIE_SECRET` (generate with `openssl rand -hex 32`) - `SECRET_STORE_KEY` (required; generate with `openssl rand -base64 48`) -- `APP_BASE_URL` (optional; defaults to request origin for most app URLs, - example `https://app.example.com`; also sets the canonical public origin used - for MCP auth metadata, generated UI resources, and email links. Password reset - email sends require this value and use `kody@` as the sender.) +- `APP_BASE_URL` (optional; used as the fallback public origin when no request + URL is available — e.g. workflows and email. Example `https://heykody.dev`. + Most request-scoped app/MCP URLs use the inbound request origin so OAuth + metadata matches the host the client connected to. Password reset email sends + require this value and use `kody@` as the sender.) - `APP_COMMIT_SHA` (optional; set automatically by deploy workflows for version-aware `/health` checks) - `CLOUDFLARE_ACCOUNT_ID` (required for the Cloudflare Email Service REST API @@ -121,8 +122,10 @@ Configure these GitHub Actions secrets and variables for workflows: - `COOKIE_SECRET` (same format as local) - `SECRET_STORE_KEY` (same format as local; required for deploys) - `APP_BASE_URL` (optional GitHub Actions **variable**, used by the production - deploy as the canonical public app origin, the password-reset email sender - hostname, and written into the generated Worker `vars` config before deploy) + deploy as the fallback public app origin when no request URL is available — + workflows, password-reset email sender hostname — and written into the + generated Worker `vars` config before deploy. Request-scoped MCP/app URLs use + the inbound request origin.) - `AI_GATEWAY_ID` (optional for production deploys; enables AI Gateway routing for Workers AI embeddings) - `AI_GATEWAY_ID_PREVIEW` (optional for preview deploys; enables AI Gateway @@ -152,12 +155,15 @@ How to get/set each value: - Generate locally: `openssl rand -hex 32` - Store the exact value as a repository secret in GitHub Actions. - `APP_BASE_URL` (optional) - - Use your production app URL (for example `https://app.example.com`). + - Use your production app URL (for example `https://heykody.dev`) as the + fallback public origin for workflows and password-reset email. - Add it when password reset email should send; the sender is derived as `kody@`, so verify that sender/domain in Cloudflare Email Service. - It also lets deploy-time health/version checks use a fixed URL. - Production CI writes this into the generated Wrangler `vars` config before deploy, rather than syncing it as a Worker secret. + - Request-scoped MCP/app URLs use the inbound request origin so OAuth resource + metadata matches the host the client connected to. - Do not also upload `APP_BASE_URL` through `wrangler secret bulk` or pass it as a deploy-time `--var`, because Wrangler treats that as a conflicting binding name. diff --git a/packages/worker/src/app/app-base-url.node.test.ts b/packages/worker/src/app/app-base-url.node.test.ts new file mode 100644 index 0000000000..032e9c905d --- /dev/null +++ b/packages/worker/src/app/app-base-url.node.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest' +import { getAppBaseUrl } from './app-base-url.ts' + +test('getAppBaseUrl prefers the request origin when present', () => { + expect( + getAppBaseUrl({ + env: { APP_BASE_URL: 'https://heykody.dev' }, + requestUrl: 'https://kody-production.kody-a99.workers.dev/mcp', + }), + ).toBe('https://kody-production.kody-a99.workers.dev') + + expect( + getAppBaseUrl({ + env: { APP_BASE_URL: 'https://configured.example' }, + requestUrl: 'https://heykody.dev/mcp', + }), + ).toBe('https://heykody.dev') +}) + +test('getAppBaseUrl falls back to APP_BASE_URL then heykody.dev', () => { + expect( + getAppBaseUrl({ + env: { APP_BASE_URL: 'https://configured.example/path' }, + }), + ).toBe('https://configured.example') + + expect( + getAppBaseUrl({ + env: { APP_BASE_URL: '' }, + }), + ).toBe('https://heykody.dev') + + expect( + getAppBaseUrl({ + env: {}, + requestUrl: null, + }), + ).toBe('https://heykody.dev') +}) diff --git a/packages/worker/src/app/app-base-url.ts b/packages/worker/src/app/app-base-url.ts index d0add0dadc..5e2b49d0d2 100644 --- a/packages/worker/src/app/app-base-url.ts +++ b/packages/worker/src/app/app-base-url.ts @@ -1,9 +1,27 @@ +const DEFAULT_APP_BASE_URL = 'https://heykody.dev' + +/** + * Resolve the public app origin for request-scoped work. + * + * Prefer the request origin when a real request URL is available so MCP OAuth + * metadata and app links match the host the client actually connected to. + * Fall back to `APP_BASE_URL`, then the production default, for background work + * (workflows, email, etc.) that has no inbound request. + */ export function getAppBaseUrl(input: { env: { APP_BASE_URL?: string | null } - requestUrl: string | URL + requestUrl?: string | URL | null }) { + if (input.requestUrl != null && input.requestUrl !== '') { + try { + return new URL(input.requestUrl).origin + } catch { + // Fall through to configured / default origin. + } + } + const configuredBaseUrl = input.env.APP_BASE_URL?.trim() if (configuredBaseUrl) { try { @@ -13,5 +31,5 @@ export function getAppBaseUrl(input: { } } - return new URL(input.requestUrl).origin + return DEFAULT_APP_BASE_URL } diff --git a/packages/worker/src/email/package-subscriptions.ts b/packages/worker/src/email/package-subscriptions.ts index 6ba0492150..9edc266bbc 100644 --- a/packages/worker/src/email/package-subscriptions.ts +++ b/packages/worker/src/email/package-subscriptions.ts @@ -151,7 +151,6 @@ export async function dispatchInboundEmailSubscriptionEvents(input: { }) { const baseUrl = getAppBaseUrl({ env: input.env, - requestUrl: 'https://kody.invalid', }) const attachments = await listEmailAttachmentsForMessage({ db: input.env.APP_DB, diff --git a/packages/worker/src/execute-maintenance.ts b/packages/worker/src/execute-maintenance.ts index 96673ed4bd..8de3df0420 100644 --- a/packages/worker/src/execute-maintenance.ts +++ b/packages/worker/src/execute-maintenance.ts @@ -1,4 +1,5 @@ import { exports as workerExports } from 'cloudflare:workers' +import { getAppBaseUrl } from '#app/app-base-url.ts' import { createExecuteExecutor } from '#mcp/executor.ts' import { handleSecretMaintenanceRequest } from './maintenance-handler.ts' import { getErrorMessage } from '#mcp/capabilities/error-message.ts' @@ -22,7 +23,7 @@ export async function runExecuteSmokeCheck(env: Env) { ) } - const origin = env.APP_BASE_URL?.trim() || 'https://heykody.dev' + const origin = getAppBaseUrl({ env }) const executor = createExecuteExecutor({ env, exports: workerExports, diff --git a/packages/worker/src/mcp-auth.workers.test.ts b/packages/worker/src/mcp-auth.workers.test.ts index 11b0eb0f33..9a8436ca4f 100644 --- a/packages/worker/src/mcp-auth.workers.test.ts +++ b/packages/worker/src/mcp-auth.workers.test.ts @@ -115,6 +115,7 @@ function createContext() { test('protected resource metadata and auth challenge resolve origin consistently', async () => { const requestOrigin = 'https://example.com' + const workersDevOrigin = 'https://kody-production.kentcdodds.workers.dev' const appBaseUrl = 'https://heykody.dev' const requestOriginMetadataResponse = handleProtectedResourceMetadata( @@ -125,17 +126,17 @@ test('protected resource metadata and auth challenge resolve origin consistently buildProtectedResourceMetadata(requestOrigin), ) + // Request origin wins even when APP_BASE_URL is configured differently — + // MCP clients require resource metadata to match the URL they connected to. const appBaseUrlMetadataResponse = handleProtectedResourceMetadata( - new Request( - `https://kody-production.kentcdodds.workers.dev${protectedResourceMetadataPath}`, - ), + new Request(`${workersDevOrigin}${protectedResourceMetadataPath}`), { APP_BASE_URL: appBaseUrl, } as Env, ) expect(appBaseUrlMetadataResponse.status).toBe(200) expect(await appBaseUrlMetadataResponse.json()).toEqual( - buildProtectedResourceMetadata(appBaseUrl), + buildProtectedResourceMetadata(workersDevOrigin), ) const requestOriginUnauthorizedResponse = await handleMcpRequest({ @@ -151,9 +152,7 @@ test('protected resource metadata and auth challenge resolve origin consistently ) const appBaseUrlUnauthorizedResponse = await handleMcpRequest({ - request: new Request( - `https://kody-production.kentcdodds.workers.dev${mcpResourcePath}`, - ), + request: new Request(`${workersDevOrigin}${mcpResourcePath}`), env: createEnv(createHelpers(), { APP_BASE_URL: appBaseUrl, }), @@ -163,7 +162,7 @@ test('protected resource metadata and auth challenge resolve origin consistently expect(appBaseUrlUnauthorizedResponse.status).toBe(401) expectAuthenticateHeader( appBaseUrlUnauthorizedResponse.headers.get('WWW-Authenticate') ?? '', - appBaseUrl, + workersDevOrigin, ) }) diff --git a/packages/worker/src/mcp/generated-ui-app-base-url.node.test.ts b/packages/worker/src/mcp/generated-ui-app-base-url.node.test.ts index 4e7e5dfb83..4a24fc73f8 100644 --- a/packages/worker/src/mcp/generated-ui-app-base-url.node.test.ts +++ b/packages/worker/src/mcp/generated-ui-app-base-url.node.test.ts @@ -6,25 +6,26 @@ import { renderGeneratedUiRuntimeHtmlEntry } from '#mcp/apps/generated-ui-runtim const mcpResourcePath = '/mcp' /** - * Full MCP E2E cannot assert APP_BASE_URL that differs from the Streamable HTTP - * server URL: @modelcontextprotocol/sdk OAuth rejects protected-resource metadata - * when `resource` does not match the MCP endpoint origin (see `selectResourceURL` - * in client/auth.js). Production uses the same public origin for both. + * Full MCP E2E cannot assert a canonical origin that differs from the + * Streamable HTTP server URL: @modelcontextprotocol/sdk OAuth rejects + * protected-resource metadata when `resource` does not match the MCP endpoint + * origin (see `selectResourceURL` in client/auth.js). Request-origin resolution + * keeps metadata aligned with the host the client connected to. */ -test('canonical APP_BASE_URL drives runtime script href and MCP URL for widget domain', async () => { +test('request origin drives runtime script href and MCP URL for widget domain', async () => { const appBase = getAppBaseUrl({ - env: { APP_BASE_URL: 'https://app.example/custom-path' }, - requestUrl: 'http://127.0.0.1:9999/mcp', + env: { APP_BASE_URL: 'https://configured.example' }, + requestUrl: 'https://heykody.dev/mcp', }) - expect(appBase).toBe('https://app.example') + expect(appBase).toBe('https://heykody.dev') const html = renderGeneratedUiRuntimeHtmlEntry(appBase) - expect(html).toMatch(/https:\/\/app\.example\/mcp-apps\/[^"]+/) - expect(html).not.toContain('127.0.0.1:9999') + expect(html).toMatch(/https:\/\/heykody\.dev\/mcp-apps\/[^"]+/) + expect(html).not.toContain('configured.example') const mcpServerUrl = new URL(mcpResourcePath, appBase).toString() - expect(mcpServerUrl).toBe('https://app.example/mcp') + expect(mcpServerUrl).toBe('https://heykody.dev/mcp') expect(await computeClaudeWidgetDomain(mcpServerUrl)).toBe( - await computeClaudeWidgetDomain('https://app.example/mcp'), + await computeClaudeWidgetDomain('https://heykody.dev/mcp'), ) }) diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index c7f9744098..9d9dd97faf 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -1022,7 +1022,6 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint< env: this.env, baseUrl: getAppBaseUrl({ env: this.env, - requestUrl: 'https://kody.invalid/', }), token: { tokenId: packageWorkflowTokenId, @@ -1070,7 +1069,6 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint< createMcpCallerContext({ baseUrl: getAppBaseUrl({ env: this.env, - requestUrl: 'https://kody.invalid/', }), user: { userId: payload.userId, From 2d0b6cf17c91189b8eef28abec0befb5d6d304b5 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 7 Jul 2026 15:19:34 -0600 Subject: [PATCH 2/2] Fix OAuth authorize SSR when browsers send cookies. renderAppPage now configures COOKIE_SECRET before reading session cookies so /oauth/authorize no longer 500s for Cursor (and other MCP clients) that retain cookies from prior visits. Co-authored-by: Cursor --- packages/worker/src/app/auth-session.ts | 6 +++++ .../worker/src/app/ssr-render.node.test.ts | 26 +++++++++++++++++++ packages/worker/src/app/ssr-render.tsx | 4 +++ 3 files changed, 36 insertions(+) diff --git a/packages/worker/src/app/auth-session.ts b/packages/worker/src/app/auth-session.ts index ea6f5fae95..725c4b70e7 100644 --- a/packages/worker/src/app/auth-session.ts +++ b/packages/worker/src/app/auth-session.ts @@ -44,6 +44,12 @@ export function setAuthSessionSecret(secret: string) { }) } +/** Clears configured session cookie state so SSR entry points re-initialize. */ +export function resetAuthSessionSecretForTests() { + sessionCookie = null + sessionSecret = null +} + function getSessionCookie() { if (!sessionCookie) { throw new Error('Session cookie not configured. Call setAuthSessionSecret.') diff --git a/packages/worker/src/app/ssr-render.node.test.ts b/packages/worker/src/app/ssr-render.node.test.ts index d669c250ef..feb66efbad 100644 --- a/packages/worker/src/app/ssr-render.node.test.ts +++ b/packages/worker/src/app/ssr-render.node.test.ts @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest' import { type CommunityListingWithAggregates } from '#worker/community/types.ts' import { createAuthCookie, + resetAuthSessionSecretForTests, setAuthSessionSecret, type AuthSession, } from '#app/auth-session.ts' @@ -279,3 +280,28 @@ test('SSR HTML routes render page content and embedded loader data', async () => '/reset-password?token=reset-token', ) }) + +test('renderAppPage configures session secret before reading cookies', async () => { + resetDataCacheForTests() + resetAuthSessionSecretForTests() + const env = createTestEnv(createUserTestDb([])) + + const response = await renderAppPage({ + request: new Request('https://example.com/oauth/authorize', { + headers: { Cookie: 'kody_session=stale-or-unsigned; other=1' }, + }), + env, + loaderData: { + oauthAuthorize: { + ok: true, + client: { id: 'client-1', name: 'Cursor' }, + scopes: ['profile', 'email'], + }, + }, + }) + + expect(response.status).toBe(200) + const html = await readResponseText(response) + expect(html).toContain('Authorize access') + expect(html).not.toContain('OAuth authorization failed') +}) diff --git a/packages/worker/src/app/ssr-render.tsx b/packages/worker/src/app/ssr-render.tsx index 722ceec51b..775780dc95 100644 --- a/packages/worker/src/app/ssr-render.tsx +++ b/packages/worker/src/app/ssr-render.tsx @@ -7,6 +7,7 @@ import { buildStylesheetHref, getClientBuildId, } from '#app/client-build-id.ts' +import { setAuthSessionSecret } from '#app/auth-session.ts' import { getEnv } from '#app/env.ts' import { type AppLoaderData, getRequestUrl } from '#app/loader-data.ts' import { getRequestDataCacheLookup } from '#app/request-cache.ts' @@ -38,6 +39,9 @@ export async function renderAppPage(input: RenderAppPageInput) { status, extraSetCookies, } = input + // OAuth authorize (and any SSR entry) can run outside appHandler, so configure + // the session cookie before reading request cookies. + setAuthSessionSecret(getEnv(env).COOKIE_SECRET) const { session, setCookie } = await loadSessionInfo(request, env) const requestUrl = new URL(request.url) const url = getRequestUrl(request)