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
42 changes: 36 additions & 6 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,18 +761,48 @@ export async function discoverOAuthProtectedResourceMetadata(
}

/**
* Helper function to handle fetch with CORS retry logic
* Fetch with a retry heuristic for CORS errors caused by custom headers.
*
* In browsers, adding a custom header (e.g. `MCP-Protocol-Version`) triggers a CORS preflight.
* If the server doesn't allow that header, the browser throws a `TypeError` before any response
* is received. Retrying without custom headers often succeeds because the request becomes
* "simple" (no preflight). If the server sends no CORS headers at all, the retry also fails
* with `TypeError` and we return `undefined` so callers can fall through to an alternate URL.
*
* However, `fetch()` also throws `TypeError` for non-CORS failures (DNS resolution, connection
* refused, invalid URL). Swallowing those and returning `undefined` masks real errors and can
* cause callers to silently fall through to a different discovery URL. CORS is a browser-only
* concept, so in non-browser runtimes (Node.js, Workers) a `TypeError` from `fetch` is never a
* CORS error — there we propagate the error instead of swallowing it.
*
* In browsers, we cannot reliably distinguish CORS `TypeError` from network `TypeError` from the
* error object alone, so the swallow-and-fallthrough heuristic is preserved there.
*/
async function fetchWithCorsRetry(url: URL, headers?: Record<string, string>, fetchFn: FetchLike = fetch): Promise<Response | undefined> {
// CORS only exists in browsers. In Node.js/Workers/etc., a TypeError from fetch is always a
// real network or configuration error, never CORS.
const corsIsPossible = (globalThis as { document?: unknown }).document !== undefined;
try {
return await fetchFn(url, { headers });
} catch (error) {
if (error instanceof TypeError) {
// CORS errors come back as TypeError, retry without headers
// We're getting CORS errors on retry too, return undefined
return headers ? fetchWithCorsRetry(url, undefined, fetchFn) : undefined;
if (!(error instanceof TypeError) || !corsIsPossible) {
throw error;
}
throw error;
if (headers) {
// Could be a CORS preflight rejection caused by our custom header. Retry as a simple
// request: if that succeeds, we've sidestepped the preflight.
try {
return await fetchFn(url, {});
} catch (retryError) {
if (!(retryError instanceof TypeError)) {
throw retryError;
}
// Retry also got CORS-blocked (server sends no CORS headers at all).
// Return undefined so the caller tries the next discovery URL.
return undefined;
}
}
return undefined;
}
}

Expand Down
68 changes: 59 additions & 9 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,27 @@ vi.mock('pkce-challenge', () => ({
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;

/**
* fetchWithCorsRetry gates its CORS-swallowing heuristic on running in a browser (where `document`
* exists). CORS doesn't apply in Node.js, so there a fetch TypeError is always a real network error
* and is thrown instead of swallowed. Tests that specifically exercise the CORS retry logic call
* this helper to simulate a browser environment. Cleanup is done by `restoreBrowserLikeEnvironment`
* in an `afterEach` hook so a failed assertion can't leak the `document` stub into later tests.
*/
function withBrowserLikeEnvironment(): void {
(globalThis as { document?: unknown }).document = {};
}
function restoreBrowserLikeEnvironment(): void {
delete (globalThis as { document?: unknown }).document;
}

describe('OAuth Authorization', () => {
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
restoreBrowserLikeEnvironment();
});

describe('extractWWWAuthenticateParams', () => {
it('returns resource metadata url when present', async () => {
Expand Down Expand Up @@ -131,7 +148,8 @@ describe('OAuth Authorization', () => {
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
});

it('returns metadata when first fetch fails but second without MCP header succeeds', async () => {
it('returns metadata when first fetch fails but second without MCP header succeeds (browser CORS retry)', async () => {
withBrowserLikeEnvironment();
// Set up a counter to control behavior
let callCount = 0;

Expand Down Expand Up @@ -159,7 +177,8 @@ describe('OAuth Authorization', () => {
expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version');
});

it('throws an error when all fetch attempts fail', async () => {
it('throws an error when all fetch attempts fail (browser, retry throws non-TypeError)', async () => {
withBrowserLikeEnvironment();
// Set up a counter to control behavior
let callCount = 0;

Expand All @@ -177,6 +196,18 @@ describe('OAuth Authorization', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('throws TypeError immediately in non-browser environments without retrying', async () => {
// In Node.js/Workers, CORS doesn't exist — a TypeError from fetch is a real
// network/config error (DNS failure, connection refused, invalid URL) and
// should propagate rather than being silently swallowed.
mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND resource.example.com')));

await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(TypeError);

// Only one call — no CORS retry attempted
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('throws on 404 errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand Down Expand Up @@ -348,7 +379,8 @@ describe('OAuth Authorization', () => {
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
});

it('falls back when path-aware discovery encounters CORS error', async () => {
it('falls back when path-aware discovery encounters CORS error (browser)', async () => {
withBrowserLikeEnvironment();
// First call (path-aware) fails with TypeError (CORS)
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));

Expand Down Expand Up @@ -560,7 +592,8 @@ describe('OAuth Authorization', () => {
expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
});

it('falls back when path-aware discovery encounters CORS error', async () => {
it('falls back when path-aware discovery encounters CORS error (browser)', async () => {
withBrowserLikeEnvironment();
// First call (path-aware) fails with TypeError (CORS)
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));

Expand Down Expand Up @@ -591,7 +624,8 @@ describe('OAuth Authorization', () => {
});
});

it('returns metadata when first fetch fails but second without MCP header succeeds', async () => {
it('returns metadata when first fetch fails but second without MCP header succeeds (browser CORS retry)', async () => {
withBrowserLikeEnvironment();
// Set up a counter to control behavior
let callCount = 0;

Expand Down Expand Up @@ -619,7 +653,8 @@ describe('OAuth Authorization', () => {
expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version');
});

it('throws an error when all fetch attempts fail', async () => {
it('throws an error when all fetch attempts fail (browser, retry throws non-TypeError)', async () => {
withBrowserLikeEnvironment();
// Set up a counter to control behavior
let callCount = 0;

Expand All @@ -637,7 +672,8 @@ describe('OAuth Authorization', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('returns undefined when both CORS requests fail in fetchWithCorsRetry', async () => {
it('returns undefined when both CORS requests fail in fetchWithCorsRetry (browser)', async () => {
withBrowserLikeEnvironment();
// fetchWithCorsRetry tries with headers (fails with CORS), then retries without headers (also fails with CORS)
// simulating a 404 w/o headers set. We want this to return undefined, not throw TypeError
mockFetch.mockImplementation(() => {
Expand Down Expand Up @@ -827,7 +863,8 @@ describe('OAuth Authorization', () => {
await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500');
});

it('handles CORS errors with retry', async () => {
it('handles CORS errors with retry (browser)', async () => {
withBrowserLikeEnvironment();
// First call fails with CORS
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));

Expand Down Expand Up @@ -883,7 +920,8 @@ describe('OAuth Authorization', () => {
});
});

it('returns undefined when all URLs fail with CORS errors', async () => {
it('returns undefined when all URLs fail with CORS errors (browser)', async () => {
withBrowserLikeEnvironment();
// All fetch attempts fail with CORS errors (TypeError)
mockFetch.mockImplementation(() => Promise.reject(new TypeError('CORS error')));

Expand All @@ -894,6 +932,18 @@ describe('OAuth Authorization', () => {
// Verify that all discovery URLs were attempted
expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers)
});

it('throws TypeError in non-browser environments instead of silently falling through (network failure)', async () => {
// In Node.js, a TypeError from fetch is a real error (DNS/connection), not CORS.
// Swallowing it and returning undefined would cause the caller to silently fall
// through to the next discovery URL, masking the actual network failure.
mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND auth.example.com')));

await expect(discoverAuthorizationServerMetadata('https://auth.example.com/tenant1')).rejects.toThrow(TypeError);

// Only one call — no CORS retry attempted in non-browser environments
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});

describe('discoverOAuthServerInfo', () => {
Expand Down
Loading