From d3b5ff08fb77e89ac73b823359eb8ebc376ef797 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Thu, 10 Sep 2026 23:14:48 +0000 Subject: [PATCH 1/4] fix(client): use authorization-server origin for well-known fallback Path-inserted well-known discovery already retries on 4xx, but the root fallback URL was built from the resource/issuer URL. When the authorization server lives on a different host, that fetched the wrong metadata document. Build the fallback from metadataServerUrl (the AS) and pass the resource URL only as the path prefix in discoverOAuthMetadata. Fixes #2783 Fixes #2784 Co-authored-by: Tiago Vilas Boas --- packages/client/src/client/auth.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 4c37339297..20251863d6 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1707,7 +1707,11 @@ async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: } /** - * Determines if fallback to root discovery should be attempted + * Determines if fallback to root discovery should be attempted. + * + * Path-inserted well-known URLs often 401/403 behind CDNs or bot protection + * instead of 404, so any 4xx triggers fallback. 502 Bad Gateway is also + * treated as a routing miss; other 5xx responses are not retried. */ function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { if (!response) return true; // CORS error — always try fallback @@ -1739,9 +1743,12 @@ async function discoverMetadataWithFallback( let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); - // If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery + // If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery. + // The root well-known URL must stay on the metadata-server / authorization-server host + // (opts.metadataServerUrl), not the resource URL used only for the path prefix. if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { - const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); + const fallbackBase = opts?.metadataServerUrl ?? issuer; + const rootUrl = new URL(`/.well-known/${wellKnownType}`, fallbackBase); response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); } @@ -1778,7 +1785,9 @@ export async function discoverOAuthMetadata( } protocolVersion ??= LATEST_PROTOCOL_VERSION; - const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, { + // Path comes from `issuer` (often the MCP resource URL); host comes from the + // authorization server so root fallback stays on the AS origin (#2784). + const response = await discoverMetadataWithFallback(issuer, 'oauth-authorization-server', fetchFn, { protocolVersion, metadataServerUrl: authorizationServerUrl }); From 9abb491cc6c2d3f91659791a5c966949e891a403 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Thu, 10 Sep 2026 23:14:53 +0000 Subject: [PATCH 2/4] test(client): cover 4xx well-known fallback and AS-origin fallback URL Add mocked-fetch cases for 401/403/404 path-aware fallback, 200 short-circuit when resource and authorization-server hosts differ, and continuation on 4xx during authorization-server metadata discovery. Co-authored-by: Tiago Vilas Boas --- packages/client/test/client/auth.test.ts | 106 ++++++++++++++++++----- 1 file changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 3ac9c7ddff..6d6b78a3d0 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -626,39 +626,84 @@ describe('OAuth Authorization', () => { }); }); - it('falls back to root discovery when path-aware discovery returns 404', async () => { - // First call (path-aware) returns 404 - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404 - }); + it.each([401, 403, 404])( + 'falls back to root discovery when path-aware discovery returns %d', + async statusCode => { + // First call (path-aware) returns 4xx (CDNs often use 401/403 instead of 404) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: statusCode + }); - // Second call (root fallback) succeeds + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]!; + expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); + expect(firstOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]!; + expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(secondOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + } + ); + + it('does not fall back when path-aware authorization-server discovery returns 200', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validMetadata }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + const metadata = await discoverOAuthMetadata('https://resource.example.com/apis/mcp', { + authorizationServerUrl: 'https://id.auth.example.com' + }); expect(metadata).toEqual(validMetadata); + expect(mockFetch.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://id.auth.example.com/.well-known/oauth-authorization-server/apis/mcp' + ]); + }); - const calls = mockFetch.mock.calls; - expect(calls.length).toBe(2); + it('builds the authorization-server fallback URL on the AS host, not the resource host', async () => { + // Path-aware AS metadata on the issuer host is blocked (e.g. CDN 403) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403 + }); - // First call should be path-aware - const [firstUrl, firstOptions] = calls[0]!; - expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); - expect(firstOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + // Root well-known on the authorization-server origin succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata }); - // Second call should be root fallback - const [secondUrl, secondOptions] = calls[1]!; - expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); - expect(secondOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + const metadata = await discoverOAuthMetadata('https://resource.example.com/apis/mcp', { + authorizationServerUrl: 'https://id.auth.example.com' }); + expect(metadata).toEqual(validMetadata); + + const urls = mockFetch.mock.calls.map(([url]) => String(url)); + expect(urls).toEqual([ + 'https://id.auth.example.com/.well-known/oauth-authorization-server/apis/mcp', + 'https://id.auth.example.com/.well-known/oauth-authorization-server' + ]); }); it('returns undefined when both path-aware and root discovery return 404', async () => { @@ -1020,6 +1065,27 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validOpenIdMetadata); }); + it.each([401, 403, 404])('continues from path-inserted AS metadata to the next URL on %d', async statusCode => { + const tenantOidcMetadata = { ...validOpenIdMetadata, issuer: 'https://auth.example.com/tenant1' }; + mockFetch.mockResolvedValueOnce({ + ok: false, + status: statusCode + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tenantOidcMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + + expect(metadata).toEqual(tenantOidcMetadata); + expect(mockFetch.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1' + ]); + }); + it('preserves authorization_response_iss_parameter_supported through OIDC discovery parse', async () => { // OAuth well-known 404s; OIDC well-known returns metadata advertising RFC 9207 support. // Regression-guard: OpenIdProviderDiscoveryMetadataSchema is a plain z.object(), so the From 04f34dbf9705140d3cf34ab7d48490eed1a1ffcd Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Thu, 10 Sep 2026 23:16:22 +0000 Subject: [PATCH 3/4] style(client): format OAuth well-known fallback tests Co-authored-by: Tiago Vilas Boas --- packages/client/test/client/auth.test.ts | 61 +++++++++++------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 6d6b78a3d0..ae0229a697 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -626,43 +626,40 @@ describe('OAuth Authorization', () => { }); }); - it.each([401, 403, 404])( - 'falls back to root discovery when path-aware discovery returns %d', - async statusCode => { - // First call (path-aware) returns 4xx (CDNs often use 401/403 instead of 404) - mockFetch.mockResolvedValueOnce({ - ok: false, - status: statusCode - }); + it.each([401, 403, 404])('falls back to root discovery when path-aware discovery returns %d', async statusCode => { + // First call (path-aware) returns 4xx (CDNs often use 401/403 instead of 404) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: statusCode + }); - // Second call (root fallback) succeeds - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validMetadata - }); + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); - expect(metadata).toEqual(validMetadata); + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + expect(metadata).toEqual(validMetadata); - const calls = mockFetch.mock.calls; - expect(calls.length).toBe(2); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); - // First call should be path-aware - const [firstUrl, firstOptions] = calls[0]!; - expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); - expect(firstOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION - }); + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]!; + expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); + expect(firstOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); - // Second call should be root fallback - const [secondUrl, secondOptions] = calls[1]!; - expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); - expect(secondOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION - }); - } - ); + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]!; + expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(secondOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); it('does not fall back when path-aware authorization-server discovery returns 200', async () => { mockFetch.mockResolvedValueOnce({ From 72454b8b6facf7fae35324abfa440f11c397efd1 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Thu, 10 Sep 2026 23:16:22 +0000 Subject: [PATCH 4/4] chore: add changeset for OAuth well-known discovery fallback Co-authored-by: Tiago Vilas Boas --- .changeset/oauth-well-known-fallback.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/oauth-well-known-fallback.md diff --git a/.changeset/oauth-well-known-fallback.md b/.changeset/oauth-well-known-fallback.md new file mode 100644 index 0000000000..6bc582c8a4 --- /dev/null +++ b/.changeset/oauth-well-known-fallback.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +OAuth well-known metadata discovery now falls back from a path-inserted URL to the root well-known document on any 4xx (not only 404), and builds that fallback on the authorization-server origin instead of the resource host (#2783, #2784).