From c9a2516ef211f512600e29f10f8990c2aa553ebd Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:36:47 -0700 Subject: [PATCH] Point plain /mcp challenges at a v1-owned metadata path --- apps/cloud/src/mcp/auth.ts | 19 ++++++++++++++----- apps/cloud/src/mcp/mount.ts | 16 +++++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 9d34df7c57..49bd6d9ce7 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -50,7 +50,12 @@ const jwks = createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)); const MCP_PATH = "/mcp"; export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; -export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; +// The plain `/mcp` challenge names this alias of the bare document instead of +// the RFC 9728 path-derived one. Executor v2 will own `executor.sh/mcp`'s root +// discovery documents; a v1 client whose token expired follows this pointer +// back to v1's authorization server instead of discovering v2's. The leading +// underscore keeps it out of the org slug grammar and the `org_` id namespace. +export const V1_PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/_v1/mcp"; export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; const TOOLKIT_SEGMENT = "/toolkits/"; @@ -94,15 +99,19 @@ export const resourceUrlFor = ( ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}` : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`; -/** The protected-resource-metadata URL for an org selector, or the bare one. */ +/** The protected-resource-metadata URL a challenge names: the org-scoped or + * toolkit document, or the v1 alias of the bare one for plain `/mcp`. */ export const protectedResourceMetadataUrlFor = ( organizationSelector: string | null, toolkitSlug: string | null = null, ): string => { const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : ""; - return organizationSelector - ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}` - : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`; + if (organizationSelector) { + return `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}`; + } + return toolkitSlug + ? `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}${toolkitSuffix}` + : `${RESOURCE_ORIGIN}${V1_PROTECTED_RESOURCE_METADATA_PATH}`; }; type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts index 96bb435305..89304292ba 100644 --- a/apps/cloud/src/mcp/mount.ts +++ b/apps/cloud/src/mcp/mount.ts @@ -14,7 +14,11 @@ import { isValidOrgSlug } from "@executor-js/api"; -import { MCP_ORGANIZATION_HEADER, PROTECTED_RESOURCE_METADATA_PATH } from "./auth"; +import { + MCP_ORGANIZATION_HEADER, + PROTECTED_RESOURCE_METADATA_PATH, + V1_PROTECTED_RESOURCE_METADATA_PATH, +} from "./auth"; const MCP_PATH = "/mcp"; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; @@ -71,8 +75,9 @@ const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefin * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler` * 404s unknown paths rather than returning `null`, so this gate decides whether * to even invoke the envelope handler (null -> fall through to Start routing). - * Recognizes the bare `/mcp` + the two discovery docs AND their org-scoped - * variants (`/org_xxx/mcp`, `/.well-known/oauth-protected-resource/org_xxx/mcp`); + * Recognizes the bare `/mcp` + the two discovery docs (and the v1 alias of the + * protected-resource doc) AND their org-scoped variants (`/org_xxx/mcp`, + * `/.well-known/oauth-protected-resource/org_xxx/mcp`); * only `org_…`-shaped segments are claimed. `prepareMcpOrgScope` then rewrites an * org-scoped path to the bare path the shared envelope actually routes. */ @@ -80,6 +85,11 @@ export const classifyMcpPath = (pathname: string): McpRoute => { if (pathname === AUTHORIZATION_SERVER_METADATA_PATH) { return { kind: "oauth-authorization-server", organizationId: null }; } + // The v1 alias of the bare document, which plain `/mcp` challenges name. Its + // `_v1` segment is never an org selector, so it is matched before them. + if (pathname === V1_PROTECTED_RESOURCE_METADATA_PATH) { + return { kind: "oauth-protected-resource", organizationId: null }; + } const segments = pathname.split("/").filter((segment) => segment.length > 0); // Protected-resource metadata: `${prefix}/mcp` or `${prefix}//mcp`. The