Skip to content

Commit d99f3ee

Browse files
matantsachclaudefelixweinbergerpcarleton
authored
fix: continue OAuth metadata discovery on 5xx responses (#1632)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
1 parent 045c62a commit d99f3ee

File tree

3 files changed

+115
-12
lines changed

3 files changed

+115
-12
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Continue OAuth metadata discovery on 502 (Bad Gateway) responses, matching the existing behavior for 4xx. This fixes MCP servers behind reverse proxies that return 502 for path-aware metadata URLs. Other 5xx errors still throw to avoid retrying against overloaded servers.

packages/client/src/client/auth.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,9 @@ async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn:
10351035
* Determines if fallback to root discovery should be attempted
10361036
*/
10371037
function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean {
1038-
return !response || (response.status >= 400 && response.status < 500 && pathname !== '/');
1038+
if (!response) return true; // CORS error — always try fallback
1039+
if (pathname === '/') return false; // Already at root
1040+
return (response.status >= 400 && response.status < 500) || response.status === 502;
10391041
}
10401042

10411043
/**
@@ -1062,7 +1064,7 @@ async function discoverMetadataWithFallback(
10621064

10631065
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
10641066

1065-
// If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery
1067+
// If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery
10661068
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
10671069
const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
10681070
response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
@@ -1230,9 +1232,8 @@ export async function discoverAuthorizationServerMetadata(
12301232

12311233
if (!response.ok) {
12321234
await response.text?.().catch(() => {});
1233-
// Continue looking for any 4xx response code.
1234-
if (response.status >= 400 && response.status < 500) {
1235-
continue; // Try next URL
1235+
if ((response.status >= 400 && response.status < 500) || response.status === 502) {
1236+
continue; // Try next URL for 4xx or 502 (Bad Gateway)
12361237
}
12371238
throw new Error(
12381239
`HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`

packages/client/test/client/auth.test.ts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,19 +338,38 @@ describe('OAuth Authorization', () => {
338338
expect(calls.length).toBe(2);
339339
});
340340

341-
it('throws error on 500 status and does not fallback', async () => {
342-
// First call (path-aware) returns 500
341+
it('throws on 500 status without fallback', async () => {
342+
// First call (path-aware) returns 500 (overloaded server)
343343
mockFetch.mockResolvedValueOnce({
344344
ok: false,
345345
status: 500
346346
});
347347

348-
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow();
348+
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow('HTTP 500');
349349

350350
const calls = mockFetch.mock.calls;
351351
expect(calls.length).toBe(1); // Should not attempt fallback
352352
});
353353

354+
it('falls back to root on 502 status for path URL', async () => {
355+
// First call (path-aware) returns 502 (reverse proxy routing error)
356+
mockFetch.mockResolvedValueOnce({
357+
ok: false,
358+
status: 502
359+
});
360+
361+
// Root fallback also returns 502
362+
mockFetch.mockResolvedValueOnce({
363+
ok: false,
364+
status: 502
365+
});
366+
367+
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow('HTTP 502');
368+
369+
const calls = mockFetch.mock.calls;
370+
expect(calls.length).toBe(2); // Should attempt root fallback for 502
371+
});
372+
354373
it('does not fallback when the original URL is already at root path', async () => {
355374
// First call (path-aware for root) returns 404
356375
mockFetch.mockResolvedValueOnce({
@@ -704,12 +723,54 @@ describe('OAuth Authorization', () => {
704723
expect(metadata).toBeUndefined();
705724
});
706725

707-
it('throws on non-404 errors', async () => {
726+
it('throws on non-404 errors for root URL', async () => {
708727
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));
709728

710729
await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500');
711730
});
712731

732+
it('falls back to root URL on 502 for path-aware discovery', async () => {
733+
// Path-aware URL returns 502 (reverse proxy has no route for well-known path)
734+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 502 }));
735+
736+
// Root fallback URL succeeds
737+
mockFetch.mockResolvedValueOnce(Response.json(validMetadata, { status: 200 }));
738+
739+
const metadata = await discoverOAuthMetadata('https://auth.example.com/tenant1', {
740+
authorizationServerUrl: 'https://auth.example.com/tenant1'
741+
});
742+
743+
expect(metadata).toEqual(validMetadata);
744+
expect(mockFetch).toHaveBeenCalledTimes(2);
745+
});
746+
747+
it('does not fall back on non-502 5xx for path-aware discovery', async () => {
748+
// Path-aware URL returns 500 (overloaded server — should not retry)
749+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));
750+
751+
await expect(
752+
discoverOAuthMetadata('https://auth.example.com/tenant1', {
753+
authorizationServerUrl: 'https://auth.example.com/tenant1'
754+
})
755+
).rejects.toThrow('HTTP 500');
756+
expect(mockFetch).toHaveBeenCalledTimes(1);
757+
});
758+
759+
it('throws when root fallback also returns error for path-aware discovery', async () => {
760+
// Path-aware URL returns 502 (gateway error — triggers fallback)
761+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 502 }));
762+
763+
// Root fallback also returns 503
764+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 503 }));
765+
766+
await expect(
767+
discoverOAuthMetadata('https://auth.example.com/tenant1', {
768+
authorizationServerUrl: 'https://auth.example.com/tenant1'
769+
})
770+
).rejects.toThrow('HTTP 503');
771+
expect(mockFetch).toHaveBeenCalledTimes(2);
772+
});
773+
713774
it('validates metadata schema', async () => {
714775
mockFetch.mockResolvedValueOnce(
715776
Response.json(
@@ -862,13 +923,49 @@ describe('OAuth Authorization', () => {
862923
expect(metadata).toEqual(validOpenIdMetadata);
863924
});
864925

865-
it('throws on non-4xx errors', async () => {
926+
it('continues on 502 and tries next URL', async () => {
927+
// First URL (OAuth) returns 502 (reverse proxy with no route)
866928
mockFetch.mockResolvedValueOnce({
867929
ok: false,
868-
status: 500
930+
status: 502,
931+
text: async () => ''
932+
});
933+
934+
// Second URL (OIDC) succeeds
935+
mockFetch.mockResolvedValueOnce({
936+
ok: true,
937+
status: 200,
938+
json: async () => validOpenIdMetadata
939+
});
940+
941+
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com');
942+
943+
expect(metadata).toEqual(validOpenIdMetadata);
944+
expect(mockFetch).toHaveBeenCalledTimes(2);
945+
});
946+
947+
it('throws on non-502 5xx errors', async () => {
948+
mockFetch.mockResolvedValueOnce({
949+
ok: false,
950+
status: 500,
951+
text: async () => ''
869952
});
870953

871-
await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500');
954+
await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500');
955+
expect(mockFetch).toHaveBeenCalledTimes(1);
956+
});
957+
958+
it('returns undefined when all URLs fail with 502', async () => {
959+
// All URLs return 502
960+
mockFetch.mockResolvedValue({
961+
ok: false,
962+
status: 502,
963+
text: async () => ''
964+
});
965+
966+
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1');
967+
968+
expect(metadata).toBeUndefined();
872969
});
873970

874971
it('handles CORS errors with retry (browser)', async () => {

0 commit comments

Comments
 (0)