From d537f2859a10e2ac2bcb89e69ec393005ba6f5c5 Mon Sep 17 00:00:00 2001 From: ch0rch Date: Sun, 2 Aug 2026 17:33:24 -0300 Subject: [PATCH] fix(connectors): use RFC 8414 path-aware OAuth metadata discovery for custom MCP servers MCP servers hosted under a path (e.g. https://mcp.facebook.com/ads) publish their OAuth authorization server metadata at the path-aware well-known URL (/.well-known/oauth-authorization-server/ads). Discovery previously only queried the root document and, on 404, fell back to /authorize, /token and /register, dropping the server path entirely. For the Meta Ads MCP this produced a registration call against https://mcp.facebook.com/register (404) and an authorize redirect to https://mcp.facebook.com/authorize, which Meta's router rejects with "MCP server not found for path: /authorize". Try the RFC 8414 path-inserted metadata URL first when the MCP server URL has a path component, then fall back to the root document, keeping the existing origin-based defaults as the last resort. --- .../__tests__/oauth-metadata.test.ts | 76 +++++++++++++++++++ apps/web/src/lib/connectors/oauth-metadata.ts | 68 +++++++++++------ 2 files changed, 121 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts b/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts index d68f99e5d..ecfeea672 100644 --- a/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts +++ b/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts @@ -93,6 +93,31 @@ describe('oauth-metadata', () => { tokenEndpoint: 'https://example.com/token', registrationEndpoint: 'https://example.com/register', }) + // RFC 8414: server URLs with a path component are discovered through the + // path-aware well-known document first. + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/oauth-authorization-server/mcp', { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }) + + vi.unstubAllGlobals() + }) + + it('queries the root well-known document directly when the server URL has no path', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + }), + }) + vi.stubGlobal('fetch', mockFetch) + + const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata') + await discoverOAuthMetadata('https://example.com/') + expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/oauth-authorization-server', { method: 'GET', headers: { Accept: 'application/json' }, @@ -102,6 +127,57 @@ describe('oauth-metadata', () => { vi.unstubAllGlobals() }) + it('falls back to the root well-known document when the path-aware document is missing', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + }), + }) + vi.stubGlobal('fetch', mockFetch) + + const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata') + const result = await discoverOAuthMetadata('https://example.com/mcp') + expect(result.authorizationEndpoint).toBe('https://example.com/auth') + expect(mockFetch).toHaveBeenNthCalledWith(1, 'https://example.com/.well-known/oauth-authorization-server/mcp', expect.any(Object)) + expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://example.com/.well-known/oauth-authorization-server', expect.any(Object)) + + vi.unstubAllGlobals() + }) + + it('uses path-aware metadata for servers hosted under a path (Meta Ads MCP shape)', async () => { + const mockFetch = vi.fn(async (url: string) => { + if (url === 'https://mcp.facebook.com/.well-known/oauth-authorization-server/ads') { + return { + ok: true, + json: async () => ({ + issuer: 'https://www.facebook.com', + authorization_endpoint: 'https://www.facebook.com/v26.0/dialog/oauth', + token_endpoint: 'https://graph.facebook.com/v26.0/oauth/access_token', + registration_endpoint: 'https://mcp.facebook.com/.well-known/register/ads', + }), + } + } + return { ok: false, status: 404 } + }) + vi.stubGlobal('fetch', mockFetch) + + const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata') + const result = await discoverOAuthMetadata('https://mcp.facebook.com/ads') + expect(result).toEqual({ + issuer: 'https://www.facebook.com', + authorizationEndpoint: 'https://www.facebook.com/v26.0/dialog/oauth', + tokenEndpoint: 'https://graph.facebook.com/v26.0/oauth/access_token', + registrationEndpoint: 'https://mcp.facebook.com/.well-known/register/ads', + }) + + vi.unstubAllGlobals() + }) + it('throws when metadata response is missing required fields', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/apps/web/src/lib/connectors/oauth-metadata.ts b/apps/web/src/lib/connectors/oauth-metadata.ts index 8ac92d1a8..e1334cd67 100644 --- a/apps/web/src/lib/connectors/oauth-metadata.ts +++ b/apps/web/src/lib/connectors/oauth-metadata.ts @@ -30,37 +30,59 @@ export async function sanitizeOAuthMetadata(metadata: OAuthServerMetadata): Prom } } +function buildWellKnownMetadataUrls(serverUrl: URL): string[] { + const authorizationBase = `${serverUrl.protocol}//${serverUrl.host}` + const rootMetadataUrl = `${authorizationBase}/.well-known/oauth-authorization-server` + const serverPath = serverUrl.pathname.replace(/\/+$/, '') + + // RFC 8414 ยง3.1: when the issuer has a path component, the well-known suffix + // is inserted between the host and the path (e.g. https://mcp.facebook.com/ads + // publishes metadata at /.well-known/oauth-authorization-server/ads). + // The root document is kept as a fallback for servers that only publish there. + if (serverPath) { + return [`${rootMetadataUrl}${serverPath}`, rootMetadataUrl] + } + + return [rootMetadataUrl] +} + export async function discoverOAuthMetadata(mcpServerUrl: string): Promise { const serverUrl = new URL(mcpServerUrl) const authorizationBase = `${serverUrl.protocol}//${serverUrl.host}` - const metadataUrl = `${authorizationBase}/.well-known/oauth-authorization-server` + const metadataUrls = buildWellKnownMetadataUrls(serverUrl) - const metadataResponse = await fetch(metadataUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - }, - cache: 'no-store', - }).catch(() => null) + for (const [index, metadataUrl] of metadataUrls.entries()) { + const isLastCandidate = index === metadataUrls.length - 1 - if (metadataResponse && metadataResponse.ok) { - const data = (await metadataResponse.json().catch(() => null)) as Record | null - const authorizationEndpoint = getString(data?.authorization_endpoint) - const tokenEndpoint = getString(data?.token_endpoint) - if (!authorizationEndpoint || !tokenEndpoint) { - throw new Error('oauth_discovery_failed:invalid_metadata') - } + const metadataResponse = await fetch(metadataUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + cache: 'no-store', + }).catch(() => null) + + if (metadataResponse && metadataResponse.ok) { + const data = (await metadataResponse.json().catch(() => null)) as Record | null + const authorizationEndpoint = getString(data?.authorization_endpoint) + const tokenEndpoint = getString(data?.token_endpoint) + if (!authorizationEndpoint || !tokenEndpoint) { + throw new Error('oauth_discovery_failed:invalid_metadata') + } - return { - issuer: getString(data?.issuer), - authorizationEndpoint, - tokenEndpoint, - registrationEndpoint: getString(data?.registration_endpoint), + return { + issuer: getString(data?.issuer), + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint: getString(data?.registration_endpoint), + } } - } - if (metadataResponse && metadataResponse.status !== 404) { - throw new Error(`oauth_discovery_failed:${metadataResponse.status}`) + // Non-404 errors on intermediate candidates fall through to the next + // candidate; only the final (root) document keeps strict error semantics. + if (isLastCandidate && metadataResponse && metadataResponse.status !== 404) { + throw new Error(`oauth_discovery_failed:${metadataResponse.status}`) + } } return {