From 89de0df0712caab67f079c31fea83b3d1d502217 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 26 Feb 2026 14:44:24 -0500 Subject: [PATCH 1/6] Don't swallow fetch TypeError as CORS in non-browser environments fetchWithCorsRetry previously caught all TypeErrors from fetch() and either retried without headers or silently returned undefined. The intent was to work around CORS preflight failures triggered by the custom MCP-Protocol-Version header in browsers. However, fetch() also throws TypeError for network failures (DNS resolution, connection refused, invalid URL). Swallowing those causes OAuth discovery to silently fall through to the next URL, masking the real error and giving users a misleading 'metadata not found' instead of the actual network failure. CORS is a browser-only concept. In Node.js, Workers, and other non-browser runtimes, a TypeError from fetch is never a CORS error. This change gates the CORS retry/swallow heuristic on running in a browser (detected via globalThis.document). In non-browser environments, TypeErrors now propagate immediately so callers see the underlying network failure. In browsers, the existing behavior is preserved: we cannot reliably distinguish CORS TypeError from network TypeError from the error object alone, so the swallow-and-fallthrough heuristic still applies there. Tests that exercise CORS retry logic now stub globalThis.document to simulate a browser environment. --- packages/client/src/client/auth.ts | 42 +++++++++++-- packages/client/test/client/auth.test.ts | 79 +++++++++++++++++++++--- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index cfba29d85..f7fa6ab58 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -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, fetchFn: FetchLike = fetch): Promise { + // 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; } } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 742dbc143..709425dc1 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -33,6 +33,25 @@ 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 use this + * helper to simulate a browser environment. + */ +function withBrowserLikeEnvironment(): { restore: () => void } { + const g = globalThis as { document?: unknown }; + const had = 'document' in g; + const prev = g.document; + g.document = {}; + return { + restore: () => { + if (had) g.document = prev; + else delete g.document; + } + }; +} + describe('OAuth Authorization', () => { beforeEach(() => { mockFetch.mockReset(); @@ -131,7 +150,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 () => { + const env = withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -157,9 +177,11 @@ describe('OAuth Authorization', () => { // Verify first call had MCP header expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); + env.restore(); }); - 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 () => { + const env = withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -175,6 +197,19 @@ describe('OAuth Authorization', () => { // Verify both calls were made expect(mockFetch).toHaveBeenCalledTimes(2); + env.restore(); + }); + + 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 () => { @@ -348,7 +383,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 () => { + const env = withBrowserLikeEnvironment(); // First call (path-aware) fails with TypeError (CORS) mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -377,6 +413,7 @@ describe('OAuth Authorization', () => { expect(lastOptions.headers).toEqual({ 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION }); + env.restore(); }); it('does not fallback when resourceMetadataUrl is provided', async () => { @@ -560,7 +597,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 () => { + const env = withBrowserLikeEnvironment(); // First call (path-aware) fails with TypeError (CORS) mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -589,9 +627,11 @@ describe('OAuth Authorization', () => { expect(lastOptions.headers).toEqual({ 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION }); + env.restore(); }); - 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 () => { + const env = withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -617,9 +657,11 @@ describe('OAuth Authorization', () => { // Verify first call had MCP header expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); + env.restore(); }); - 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 () => { + const env = withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -635,9 +677,11 @@ describe('OAuth Authorization', () => { // Verify both calls were made expect(mockFetch).toHaveBeenCalledTimes(2); + env.restore(); }); - it('returns undefined when both CORS requests fail in fetchWithCorsRetry', async () => { + it('returns undefined when both CORS requests fail in fetchWithCorsRetry (browser)', async () => { + const env = 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(() => { @@ -648,6 +692,7 @@ describe('OAuth Authorization', () => { // This should return undefined (the desired behavior after the fix) const metadata = await discoverOAuthMetadata('https://auth.example.com/path'); expect(metadata).toBeUndefined(); + env.restore(); }); it('returns undefined when discovery endpoint returns 404', async () => { @@ -827,7 +872,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 () => { + const env = withBrowserLikeEnvironment(); // First call fails with CORS mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -849,6 +895,7 @@ describe('OAuth Authorization', () => { // Second call should not have headers (CORS retry) expect(calls[1]![1]?.headers).toBeUndefined(); + env.restore(); }); it('supports custom fetch function', async () => { @@ -883,7 +930,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 () => { + const env = withBrowserLikeEnvironment(); // All fetch attempts fail with CORS errors (TypeError) mockFetch.mockImplementation(() => Promise.reject(new TypeError('CORS error'))); @@ -893,6 +941,19 @@ describe('OAuth Authorization', () => { // Verify that all discovery URLs were attempted expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers) + env.restore(); + }); + + 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); }); }); From b8a2e610d4e231924f889f08ef1d7549bf3e6072 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 26 Feb 2026 15:09:17 -0500 Subject: [PATCH 2/6] Use afterEach to restore document stub in CORS tests Ensures the browser-environment stub is cleaned up even if a CORS test throws an assertion error before reaching the explicit restore() call, preventing the stubbed document from leaking into subsequent tests. --- packages/client/test/client/auth.test.ts | 51 ++++++++++-------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 709425dc1..7bcc1aaf4 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -36,26 +36,24 @@ 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 use this - * helper to simulate a browser environment. + * 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(): { restore: () => void } { - const g = globalThis as { document?: unknown }; - const had = 'document' in g; - const prev = g.document; - g.document = {}; - return { - restore: () => { - if (had) g.document = prev; - else delete g.document; - } - }; +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 () => { @@ -151,7 +149,7 @@ describe('OAuth Authorization', () => { }); it('returns metadata when first fetch fails but second without MCP header succeeds (browser CORS retry)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -177,11 +175,10 @@ describe('OAuth Authorization', () => { // Verify first call had MCP header expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); - env.restore(); }); it('throws an error when all fetch attempts fail (browser, retry throws non-TypeError)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -197,7 +194,6 @@ describe('OAuth Authorization', () => { // Verify both calls were made expect(mockFetch).toHaveBeenCalledTimes(2); - env.restore(); }); it('throws TypeError immediately in non-browser environments without retrying', async () => { @@ -384,7 +380,7 @@ describe('OAuth Authorization', () => { }); it('falls back when path-aware discovery encounters CORS error (browser)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // First call (path-aware) fails with TypeError (CORS) mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -413,7 +409,6 @@ describe('OAuth Authorization', () => { expect(lastOptions.headers).toEqual({ 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION }); - env.restore(); }); it('does not fallback when resourceMetadataUrl is provided', async () => { @@ -598,7 +593,7 @@ describe('OAuth Authorization', () => { }); it('falls back when path-aware discovery encounters CORS error (browser)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // First call (path-aware) fails with TypeError (CORS) mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -627,11 +622,10 @@ describe('OAuth Authorization', () => { expect(lastOptions.headers).toEqual({ 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION }); - env.restore(); }); it('returns metadata when first fetch fails but second without MCP header succeeds (browser CORS retry)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -657,11 +651,10 @@ describe('OAuth Authorization', () => { // Verify first call had MCP header expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); - env.restore(); }); it('throws an error when all fetch attempts fail (browser, retry throws non-TypeError)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // Set up a counter to control behavior let callCount = 0; @@ -677,11 +670,10 @@ describe('OAuth Authorization', () => { // Verify both calls were made expect(mockFetch).toHaveBeenCalledTimes(2); - env.restore(); }); it('returns undefined when both CORS requests fail in fetchWithCorsRetry (browser)', async () => { - const env = withBrowserLikeEnvironment(); + 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(() => { @@ -692,7 +684,6 @@ describe('OAuth Authorization', () => { // This should return undefined (the desired behavior after the fix) const metadata = await discoverOAuthMetadata('https://auth.example.com/path'); expect(metadata).toBeUndefined(); - env.restore(); }); it('returns undefined when discovery endpoint returns 404', async () => { @@ -873,7 +864,7 @@ describe('OAuth Authorization', () => { }); it('handles CORS errors with retry (browser)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // First call fails with CORS mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -895,7 +886,6 @@ describe('OAuth Authorization', () => { // Second call should not have headers (CORS retry) expect(calls[1]![1]?.headers).toBeUndefined(); - env.restore(); }); it('supports custom fetch function', async () => { @@ -931,7 +921,7 @@ describe('OAuth Authorization', () => { }); it('returns undefined when all URLs fail with CORS errors (browser)', async () => { - const env = withBrowserLikeEnvironment(); + withBrowserLikeEnvironment(); // All fetch attempts fail with CORS errors (TypeError) mockFetch.mockImplementation(() => Promise.reject(new TypeError('CORS error'))); @@ -941,7 +931,6 @@ describe('OAuth Authorization', () => { // Verify that all discovery URLs were attempted expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers) - env.restore(); }); it('throws TypeError in non-browser environments instead of silently falling through (network failure)', async () => { From d194f1de6874c3de44326f316a2ec73d66a63b89 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 23 Mar 2026 14:49:42 +0000 Subject: [PATCH 3/6] Use shim pattern for CORS detection instead of globalThis.document check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, replace the runtime feature-sniff (globalThis.document) with the package's existing _shims export-conditions pattern. Adds CORS_IS_POSSIBLE constant to the client shims: - shimsNode.ts -> false (Node has no CORS) - shimsWorkerd.ts -> false (Cloudflare Workers has no CORS) - shimsBrowser.ts -> true (new file; browser condition now resolves here) This also fixes the Web Worker / Service Worker gap noted in the PR description: bundlers resolve those to the 'browser' condition, so CORS_IS_POSSIBLE is correctly true there — unlike the document check which would have returned false in workers. Tests now mock the shim module rather than stubbing globalThis.document. --- packages/client/package.json | 4 ++-- packages/client/src/client/auth.ts | 6 ++---- packages/client/src/shimsBrowser.ts | 13 ++++++++++++ packages/client/src/shimsNode.ts | 7 ++++++ packages/client/src/shimsWorkerd.ts | 7 ++++++ packages/client/test/client/auth.test.ts | 27 +++++++++++++++--------- packages/client/tsdown.config.ts | 2 +- 7 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 packages/client/src/shimsBrowser.ts diff --git a/packages/client/package.json b/packages/client/package.json index a8cd73c3b..e205903b8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -30,8 +30,8 @@ "import": "./dist/shimsWorkerd.mjs" }, "browser": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" + "types": "./dist/shimsBrowser.d.mts", + "import": "./dist/shimsBrowser.mjs" }, "node": { "types": "./dist/shimsNode.d.mts", diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index bf2f5a3cf..e176d1c95 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,3 +1,4 @@ +import { CORS_IS_POSSIBLE } from '@modelcontextprotocol/client/_shims'; import type { AuthorizationServerMetadata, FetchLike, @@ -844,13 +845,10 @@ export async function discoverOAuthProtectedResourceMetadata( * error object alone, so the swallow-and-fallthrough heuristic is preserved there. */ async function fetchWithCorsRetry(url: URL, headers?: Record, fetchFn: FetchLike = fetch): Promise { - // 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) || !corsIsPossible) { + if (!(error instanceof TypeError) || !CORS_IS_POSSIBLE) { throw error; } if (headers) { diff --git a/packages/client/src/shimsBrowser.ts b/packages/client/src/shimsBrowser.ts new file mode 100644 index 000000000..bfaa67a2e --- /dev/null +++ b/packages/client/src/shimsBrowser.ts @@ -0,0 +1,13 @@ +/** + * Browser runtime shims for client package + * + * This file is selected via package.json export conditions when running in a browser. + */ +export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core'; + +/** + * Whether `fetch()` may throw `TypeError` due to CORS. Only true in browser contexts + * (including Web Workers / Service Workers). In Node.js and Cloudflare Workers, a + * `TypeError` from `fetch` is always a real network/configuration error. + */ +export const CORS_IS_POSSIBLE = true; diff --git a/packages/client/src/shimsNode.ts b/packages/client/src/shimsNode.ts index 1ad16bedc..00b80abe0 100644 --- a/packages/client/src/shimsNode.ts +++ b/packages/client/src/shimsNode.ts @@ -4,3 +4,10 @@ * This file is selected via package.json export conditions when running in Node.js. */ export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core'; + +/** + * Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept — + * in Node.js, a `TypeError` from `fetch` is always a real network/configuration error + * (DNS resolution, connection refused, invalid URL), never a CORS error. + */ +export const CORS_IS_POSSIBLE = false; diff --git a/packages/client/src/shimsWorkerd.ts b/packages/client/src/shimsWorkerd.ts index 14380d41b..f8374597e 100644 --- a/packages/client/src/shimsWorkerd.ts +++ b/packages/client/src/shimsWorkerd.ts @@ -4,3 +4,10 @@ * This file is selected via package.json export conditions when running in workerd. */ export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core'; + +/** + * Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept — + * in Cloudflare Workers, a `TypeError` from `fetch` is always a real network/configuration + * error, never a CORS error. + */ +export const CORS_IS_POSSIBLE = false; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 21e516e2f..96b89f370 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -34,17 +34,24 @@ 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. + * fetchWithCorsRetry gates its CORS-swallowing heuristic on the `CORS_IS_POSSIBLE` shim constant. + * Tests run under the Node shim (`false`), so a fetch TypeError is treated as a real network error + * and thrown instead of swallowed. Tests that specifically exercise the browser CORS retry path + * call `withBrowserLikeEnvironment()` to flip the mocked constant to `true`. The `afterEach` hook + * resets it so a failed assertion can't leak the override into later tests. */ +let mockedCorsIsPossible = false; +vi.mock('@modelcontextprotocol/client/_shims', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + get CORS_IS_POSSIBLE() { + return mockedCorsIsPossible; + } + }; +}); function withBrowserLikeEnvironment(): void { - (globalThis as { document?: unknown }).document = {}; -} -function restoreBrowserLikeEnvironment(): void { - delete (globalThis as { document?: unknown }).document; + mockedCorsIsPossible = true; } describe('OAuth Authorization', () => { @@ -52,7 +59,7 @@ describe('OAuth Authorization', () => { mockFetch.mockReset(); }); afterEach(() => { - restoreBrowserLikeEnvironment(); + mockedCorsIsPossible = false; }); describe('extractWWWAuthenticateParams', () => { diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 5ab07eecd..c6b89247a 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ // 1. Entry Points // Directly matches package.json include/exclude globs - entry: ['src/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts'], + entry: ['src/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts'], // 2. Output Configuration format: ['esm'], From db6213587b960e8718999a31f7db4a1eee28a8f1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 23 Mar 2026 17:48:02 +0000 Subject: [PATCH 4/6] Add changeset --- .changeset/tame-camels-greet.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/tame-camels-greet.md diff --git a/.changeset/tame-camels-greet.md b/.changeset/tame-camels-greet.md new file mode 100644 index 000000000..5f9c1d1c5 --- /dev/null +++ b/.changeset/tame-camels-greet.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Don't swallow fetch `TypeError` as CORS in non-browser environments. Network errors +(DNS resolution failure, connection refused, invalid URL) in Node.js and Cloudflare +Workers now propagate from OAuth discovery instead of being silently misattributed +to CORS and returning `undefined`. This surfaces the real error to callers rather +than masking it as "metadata not found." From 8fa92735604757d3ca1858209ae8f5ef39e062f9 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 24 Mar 2026 17:19:48 -0400 Subject: [PATCH 5/6] Propagate network TypeError through discoverOAuthServerInfo PRM catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare catch in discoverOAuthServerInfo swallowed all errors from PRM discovery, including the TypeErrors that fetchWithCorsRetry now correctly propagates. A DNS/connection failure during PRM discovery would still silently fall back to treating the MCP server URL as the auth server — masking the real network error one layer up from where the earlier commits fixed it. Re-throw TypeErrors (transient network failures); keep catching regular Errors (404s, HTTP errors) as 'RFC 9728 not supported' and fall back as before. --- packages/client/src/client/auth.ts | 8 +++++++- packages/client/test/client/auth.test.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index e176d1c95..b879b2987 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1174,7 +1174,13 @@ export async function discoverOAuthServerInfo( if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - } catch { + } catch (error) { + // Network failures (DNS, connection refused) surface as TypeError from fetch. Those are + // transient reachability problems, not "server doesn't support PRM" — propagate so the + // caller sees the real error instead of silently falling back to a different auth server. + if (error instanceof TypeError) { + throw error; + } // RFC 9728 not supported -- fall back to treating the server URL as the authorization server } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 96b89f370..8178df906 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1063,6 +1063,15 @@ describe('OAuth Authorization', () => { // Verify the override URL was used instead of the default well-known path expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); }); + + it('propagates network failures instead of silently falling back (non-browser)', async () => { + // PRM discovery hits a DNS/connection failure. That's a transient reachability problem, + // not "server doesn't support RFC 9728" — the caller should see the real error rather + // than silently falling back to treating the MCP server URL as the auth server. + mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND resource.example.com'))); + + await expect(discoverOAuthServerInfo('https://resource.example.com')).rejects.toThrow(TypeError); + }); }); describe('auth with provider authorization server URL caching', () => { From 88d9eeadb5c5d3d4c52540f60d8705941f7ef8d8 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 24 Mar 2026 17:31:43 -0400 Subject: [PATCH 6/6] Apply same TypeError propagation to authInternal cached-state path The cached-state path in authInternal has a parallel bare catch around discoverOAuthProtectedResourceMetadata (line ~516) with the same error-swallowing behavior. Apply the same re-throw-TypeError pattern for consistency with the discoverOAuthServerInfo fix. --- packages/client/src/client/auth.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index b879b2987..bedfd8743 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -513,7 +513,12 @@ async function authInternal( { resourceMetadataUrl: effectiveResourceMetadataUrl }, fetchFn ); - } catch { + } catch (error) { + // Network failures (DNS, connection refused) surface as TypeError — propagate + // those rather than masking a transient reachability problem. + if (error instanceof TypeError) { + throw error; + } // RFC 9728 not available — selectResourceURL will handle undefined } }