From 5a607170e510eaa778bed5254392e3ad3d098ef4 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Wed, 3 Jun 2026 13:40:08 +0100 Subject: [PATCH] fix: mcp auth --- apps/leaf/src/lib/env.ts | 6 ++--- apps/leaf/src/main.ts | 2 +- scripts/dev.ts | 4 ++- server/src/utils/auth.ts | 57 ++++++++++++++++++++++++---------------- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/apps/leaf/src/lib/env.ts b/apps/leaf/src/lib/env.ts index 6d6e878f4..361d453fe 100644 --- a/apps/leaf/src/lib/env.ts +++ b/apps/leaf/src/lib/env.ts @@ -9,6 +9,7 @@ const envSchema = z .object({ AUTUMN_MCP_URL: z.string().min(1).default("http://localhost:3099/mcp"), BETTER_AUTH_SECRET: optionalString, + BETTER_AUTH_URL: optionalString, CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4-6"), CHAT_NAME: z.string().min(1).default("Autumn"), CHAT_STATE_DATABASE_URL: optionalString, @@ -18,7 +19,6 @@ const envSchema = z ENCRYPTION_PASSWORD: z.string().min(1), FIRECRAWL_API_KEY: z.string().min(1), MCP_OAUTH_ENVIRONMENT: z.enum(["live", "sandbox"]).default("sandbox"), - MCP_SERVER_URL: optionalString, PORT: z.coerce.number().int().positive().default(3099), SLACK_CLIENT_ID: z.string().min(1), SLACK_CLIENT_SECRET: z.string().min(1), @@ -32,8 +32,8 @@ const envSchema = z return { ...values, - MCP_SERVER_URL: - values.MCP_SERVER_URL ?? + BETTER_AUTH_URL: + values.BETTER_AUTH_URL ?? (process.env.NODE_ENV === "production" ? "https://api.useautumn.com" : "http://localhost:8080"), diff --git a/apps/leaf/src/main.ts b/apps/leaf/src/main.ts index 169a40834..452cfacaa 100644 --- a/apps/leaf/src/main.ts +++ b/apps/leaf/src/main.ts @@ -21,7 +21,7 @@ app.get("/health", (c) => c.json({ ok: true })); registerMcpRoutes(app, { "oauth-enabled": true, "oauth-environment": env.MCP_OAUTH_ENVIRONMENT, - "server-url": env.MCP_SERVER_URL, + "server-url": env.BETTER_AUTH_URL, logger: createConsoleLogger("info"), }); diff --git a/scripts/dev.ts b/scripts/dev.ts index 0c428a003..c42b966cb 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -289,7 +289,9 @@ async function startDev() { CHAT_PORT: CHAT_PORT.toString(), MCP_DEBUG_PENDING_ACTIONS: process.env.MCP_DEBUG_PENDING_ACTIONS ?? "1", MCP_SERVER_URL: - process.env.MCP_SERVER_URL ?? `http://localhost:${SERVER_PORT}`, + process.env.MCP_SERVER_URL ?? `http://localhost:${CHAT_PORT}`, + CHAT_SERVER_URL: + process.env.CHAT_SERVER_URL ?? `http://localhost:${CHAT_PORT}`, MCP_RESOURCE_URLS: process.env.MCP_RESOURCE_URLS ?? `http://localhost:${CHAT_PORT}/mcp`, AUTUMN_MCP_URL: diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index b878e08fc..2a8f7a97f 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -65,13 +65,8 @@ const emulateGoogleUrl = // OAuth flow leaves and returns via a third-party host (emulate.dev), so the // state cookie must be SameSite=None+Secure to survive the round trip. const isHttpsBaseUrl = process.env.BETTER_AUTH_URL?.startsWith("https://"); -const hostedMcpResourceUrls = - process.env.MCP_UPSTREAM_URL && process.env.BETTER_AUTH_URL - ? [ - new URL("/mcp", process.env.BETTER_AUTH_URL).href, - new URL("/internal/mcp", process.env.BETTER_AUTH_URL).href, - ] - : []; +const isProductionAuth = process.env.NODE_ENV === "production"; + const parseMcpResourceUrl = (rawUrl: string) => { const resourceUrl = rawUrl.trim(); if (!resourceUrl) return null; @@ -83,15 +78,36 @@ const parseMcpResourceUrl = (rawUrl: string) => { return null; } }; -const mcpResourceUrls = - process.env.MCP_RESOURCE_URLS?.split(",") - .map(parseMcpResourceUrl) - .filter((url): url is string => Boolean(url)) ?? []; -const internalMcpResourceUrls = mcpResourceUrls.map((resourceUrl) => { - const url = new URL(resourceUrl); - url.pathname = "/internal/mcp"; - return url.href; -}); + +// Public hosts that serve OAuth-protected MCP endpoints. leaf serves both the +// MCP server (MCP_SERVER_URL) and the chat/slackbot (CHAT_SERVER_URL); the +// autumn server can also proxy /mcp under its own origin (BETTER_AUTH_URL). +// The OAuth `resource` indicator is host-based, so every public host + path +// must be a registered audience. MCP_RESOURCE_URLS is an explicit override. +const mcpServerUrl = + process.env.MCP_SERVER_URL ?? + (isProductionAuth ? "https://mcp.useautumn.com" : "http://localhost:3099"); +const chatServerUrl = + process.env.CHAT_SERVER_URL ?? + (isProductionAuth ? "https://chat.useautumn.com" : "http://localhost:3099"); + +const mcpResourcePaths = ["/mcp", "/internal/mcp"]; +const mcpResourceBases = [ + process.env.BETTER_AUTH_URL, + mcpServerUrl, + chatServerUrl, +].filter((base): base is string => Boolean(base)); + +const mcpResourceUrls = [ + ...new Set([ + ...mcpResourceBases.flatMap((base) => + mcpResourcePaths.map((path) => new URL(path, base).href), + ), + ...(process.env.MCP_RESOURCE_URLS?.split(",") + .map(parseMcpResourceUrl) + .filter((url): url is string => Boolean(url)) ?? []), + ]), +]; /** * Passkey (WebAuthn) is bound to the FRONTEND origin where the browser calls @@ -255,12 +271,9 @@ const options = { // Resource-based scopes with R/W actions (plus legacy CRUDL + // meta scopes — see shared/utils/scopeDefinitions.ts). scopes: [...ALL_SCOPES], - validAudiences: [ - process.env.BETTER_AUTH_URL, - ...hostedMcpResourceUrls, - ...mcpResourceUrls, - ...internalMcpResourceUrls, - ].filter(Boolean) as string[], + validAudiences: [process.env.BETTER_AUTH_URL, ...mcpResourceUrls].filter( + Boolean, + ) as string[], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, customAccessTokenClaims: ({ referenceId }) => ({