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/oauth-well-known-fallback.md
Original file line number Diff line number Diff line change
@@ -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).
17 changes: 13 additions & 4 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
});
Expand Down
69 changes: 66 additions & 3 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,11 +626,11 @@ describe('OAuth Authorization', () => {
});
});

it('falls back to root discovery when path-aware discovery returns 404', async () => {
// First call (path-aware) returns 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: 404
status: statusCode
});

// Second call (root fallback) succeeds
Expand Down Expand Up @@ -661,6 +661,48 @@ describe('OAuth Authorization', () => {
});
});

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://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'
]);
});

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
});

// Root well-known on the authorization-server origin succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validMetadata
});

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 () => {
// First call (path-aware) returns 404
mockFetch.mockResolvedValueOnce({
Expand Down Expand Up @@ -1020,6 +1062,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
Expand Down
Loading