Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-oauth-5xx-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Continue OAuth metadata discovery on 5xx responses instead of throwing, matching the existing behavior for 4xx. This fixes MCP servers behind reverse proxies that return 502/503 for path-aware metadata URLs.
12 changes: 3 additions & 9 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,7 @@ async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn:
* Determines if fallback to root discovery should be attempted
*/
function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean {
return !response || (response.status >= 400 && response.status < 500 && pathname !== '/');
return !response || (!response.ok && pathname !== '/');
}

/**
Expand All @@ -845,7 +845,7 @@ async function discoverMetadataWithFallback(

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

// If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery
// If path-aware discovery fails (any non-OK status) and we're not already at root, try fallback to root discovery
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
Expand Down Expand Up @@ -1013,13 +1013,7 @@ export async function discoverAuthorizationServerMetadata(

if (!response.ok) {
await response.text?.().catch(() => {});
// Continue looking for any 4xx response code.
if (response.status >= 400 && response.status < 500) {
continue; // Try next URL
}
throw new Error(
`HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`
);
continue; // Try next URL for any non-OK response (4xx, 5xx)
}

// Parse and validate based on type
Expand Down
77 changes: 69 additions & 8 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,17 +299,23 @@ describe('OAuth Authorization', () => {
expect(calls.length).toBe(2);
});

it('throws error on 500 status and does not fallback', async () => {
// First call (path-aware) returns 500
it('falls back to root on 500 status for path URL', async () => {
// First call (path-aware) returns 500 (reverse proxy)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
});

await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow();
// Root fallback also returns 500
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
});

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

const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1); // Should not attempt fallback
expect(calls.length).toBe(2); // Should attempt root fallback
});

it('does not fallback when the original URL is already at root path', async () => {
Expand Down Expand Up @@ -660,12 +666,42 @@ describe('OAuth Authorization', () => {
expect(metadata).toBeUndefined();
});

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

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

it('falls back to root URL on 5xx for path-aware discovery', async () => {
// Path-aware URL returns 502 (reverse proxy has no route for well-known path)
mockFetch.mockResolvedValueOnce(new Response(null, { status: 502 }));

// Root fallback URL succeeds
mockFetch.mockResolvedValueOnce(Response.json(validMetadata, { status: 200 }));

const metadata = await discoverOAuthMetadata('https://auth.example.com/tenant1', {
authorizationServerUrl: 'https://auth.example.com/tenant1'
});

expect(metadata).toEqual(validMetadata);
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('throws when root fallback also returns 5xx for path-aware discovery', async () => {
// Path-aware URL returns 502
mockFetch.mockResolvedValueOnce(new Response(null, { status: 502 }));

// Root fallback also returns 503
mockFetch.mockResolvedValueOnce(new Response(null, { status: 503 }));

await expect(
discoverOAuthMetadata('https://auth.example.com/tenant1', {
authorizationServerUrl: 'https://auth.example.com/tenant1'
})
).rejects.toThrow('HTTP 503');
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('validates metadata schema', async () => {
mockFetch.mockResolvedValueOnce(
Response.json(
Expand Down Expand Up @@ -818,13 +854,38 @@ describe('OAuth Authorization', () => {
expect(metadata).toEqual(validOpenIdMetadata);
});

it('throws on non-4xx errors', async () => {
it('continues on 5xx errors and tries next URL', async () => {
// First URL (OAuth) returns 502 (e.g. reverse proxy with no route)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
status: 502,
text: async () => ''
});

// Second URL (OIDC) succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validOpenIdMetadata
});

const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com');

expect(metadata).toEqual(validOpenIdMetadata);
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('returns undefined when all URLs fail with 5xx', async () => {
// All URLs return 5xx
mockFetch.mockResolvedValue({
ok: false,
status: 502,
text: async () => ''
});

await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500');
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1');

expect(metadata).toBeUndefined();
});

it('handles CORS errors with retry', async () => {
Expand Down
Loading