From 1d845abf924eaa9bace6d2905f7a5893d5529ba1 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Thu, 28 May 2026 19:58:31 +0200 Subject: [PATCH] fix(oauth): resolve server by path segment, not subdomain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Desktop got past discovery + DCR but /oauth/authorize rejected the resource parameter with invalid_resource. Root cause: resolveServerByResource() extracted the slug from the URL's first hostname label (subdomain routing), but production runs path routing — mcp.buildmymcpserver.com/. The function saw resource "https://mcp.buildmymcpserver.com/text-generation", tried to look up slug="mcp", missed, returned null → 400. Path lookup is now tried first (matches the production topology and the resource URL we publish via /.well-known/oauth-protected-resource), port lookup second (local dev), subdomain lookup last with an explicit "mcp" guard so the legacy path doesn't shadow the new one. --- apps/api/src/routes/oauth.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts index 79a0cea..00add85 100644 --- a/apps/api/src/routes/oauth.ts +++ b/apps/api/src/routes/oauth.ts @@ -40,16 +40,42 @@ function pkceVerify(verifier: string, challenge: string, method: string): boolea async function resolveServerByResource(resource: string) { const url = new URL(resource); - const port = url.port ? Number(url.port) : null; - if (port !== null) { - const [s] = await db.select().from(mcpServers).where(eq(mcpServers.hostPort, port)).limit(1); + + // Path routing (the prod topology on mcp.buildmymcpserver.com): the slug + // is the first path segment. Has to be checked BEFORE the subdomain + // heuristic below, otherwise we extract "mcp" from "mcp.example.com/" + // and look up the wrong (or no) server. Claude Desktop's RFC 8707 resource + // parameter matches the `resource` field we publish in /.well-known/ + // oauth-protected-resource, which is exactly the path-routed public URL. + const firstSegment = url.pathname.split('/').filter(Boolean)[0]; + if (firstSegment) { + const [s] = await db + .select() + .from(mcpServers) + .where(eq(mcpServers.slug, firstSegment)) + .limit(1); if (s) return s; } + + // Port-based lookup — used in local dev where the runner is reached + // directly at http://:. + const port = url.port ? Number(url.port) : null; + if (port !== null) { + const [s] = await db + .select() + .from(mcpServers) + .where(eq(mcpServers.hostPort, port)) + .limit(1); + if (s) return s; + } + + // Subdomain routing — legacy / future .mcp.example.com setup. const slug = url.hostname.split('.')[0]; - if (slug) { + if (slug && slug !== 'mcp') { const [s] = await db.select().from(mcpServers).where(eq(mcpServers.slug, slug)).limit(1); if (s) return s; } + return null; }