Skip to content

Commit 13a0d34

Browse files
Don't swallow fetch TypeError as CORS in non-browser environments (#1595)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
1 parent f9fda80 commit 13a0d34

File tree

8 files changed

+161
-20
lines changed

8 files changed

+161
-20
lines changed

.changeset/tame-camels-greet.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Don't swallow fetch `TypeError` as CORS in non-browser environments. Network errors
6+
(DNS resolution failure, connection refused, invalid URL) in Node.js and Cloudflare
7+
Workers now propagate from OAuth discovery instead of being silently misattributed
8+
to CORS and returning `undefined`. This surfaces the real error to callers rather
9+
than masking it as "metadata not found."

packages/client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
"import": "./dist/shimsWorkerd.mjs"
3131
},
3232
"browser": {
33-
"types": "./dist/shimsWorkerd.d.mts",
34-
"import": "./dist/shimsWorkerd.mjs"
33+
"types": "./dist/shimsBrowser.d.mts",
34+
"import": "./dist/shimsBrowser.mjs"
3535
},
3636
"node": {
3737
"types": "./dist/shimsNode.d.mts",

packages/client/src/client/auth.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CORS_IS_POSSIBLE } from '@modelcontextprotocol/client/_shims';
12
import type {
23
AuthorizationServerMetadata,
34
FetchLike,
@@ -512,7 +513,12 @@ async function authInternal(
512513
{ resourceMetadataUrl: effectiveResourceMetadataUrl },
513514
fetchFn
514515
);
515-
} catch {
516+
} catch (error) {
517+
// Network failures (DNS, connection refused) surface as TypeError — propagate
518+
// those rather than masking a transient reachability problem.
519+
if (error instanceof TypeError) {
520+
throw error;
521+
}
516522
// RFC 9728 not available — selectResourceURL will handle undefined
517523
}
518524
}
@@ -826,18 +832,45 @@ export async function discoverOAuthProtectedResourceMetadata(
826832
}
827833

828834
/**
829-
* Helper function to handle fetch with CORS retry logic
835+
* Fetch with a retry heuristic for CORS errors caused by custom headers.
836+
*
837+
* In browsers, adding a custom header (e.g. `MCP-Protocol-Version`) triggers a CORS preflight.
838+
* If the server doesn't allow that header, the browser throws a `TypeError` before any response
839+
* is received. Retrying without custom headers often succeeds because the request becomes
840+
* "simple" (no preflight). If the server sends no CORS headers at all, the retry also fails
841+
* with `TypeError` and we return `undefined` so callers can fall through to an alternate URL.
842+
*
843+
* However, `fetch()` also throws `TypeError` for non-CORS failures (DNS resolution, connection
844+
* refused, invalid URL). Swallowing those and returning `undefined` masks real errors and can
845+
* cause callers to silently fall through to a different discovery URL. CORS is a browser-only
846+
* concept, so in non-browser runtimes (Node.js, Workers) a `TypeError` from `fetch` is never a
847+
* CORS error — there we propagate the error instead of swallowing it.
848+
*
849+
* In browsers, we cannot reliably distinguish CORS `TypeError` from network `TypeError` from the
850+
* error object alone, so the swallow-and-fallthrough heuristic is preserved there.
830851
*/
831852
async function fetchWithCorsRetry(url: URL, headers?: Record<string, string>, fetchFn: FetchLike = fetch): Promise<Response | undefined> {
832853
try {
833854
return await fetchFn(url, { headers });
834855
} catch (error) {
835-
if (error instanceof TypeError) {
836-
// CORS errors come back as TypeError, retry without headers
837-
// We're getting CORS errors on retry too, return undefined
838-
return headers ? fetchWithCorsRetry(url, undefined, fetchFn) : undefined;
856+
if (!(error instanceof TypeError) || !CORS_IS_POSSIBLE) {
857+
throw error;
839858
}
840-
throw error;
859+
if (headers) {
860+
// Could be a CORS preflight rejection caused by our custom header. Retry as a simple
861+
// request: if that succeeds, we've sidestepped the preflight.
862+
try {
863+
return await fetchFn(url, {});
864+
} catch (retryError) {
865+
if (!(retryError instanceof TypeError)) {
866+
throw retryError;
867+
}
868+
// Retry also got CORS-blocked (server sends no CORS headers at all).
869+
// Return undefined so the caller tries the next discovery URL.
870+
return undefined;
871+
}
872+
}
873+
return undefined;
841874
}
842875
}
843876

@@ -1146,7 +1179,13 @@ export async function discoverOAuthServerInfo(
11461179
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
11471180
authorizationServerUrl = resourceMetadata.authorization_servers[0];
11481181
}
1149-
} catch {
1182+
} catch (error) {
1183+
// Network failures (DNS, connection refused) surface as TypeError from fetch. Those are
1184+
// transient reachability problems, not "server doesn't support PRM" — propagate so the
1185+
// caller sees the real error instead of silently falling back to a different auth server.
1186+
if (error instanceof TypeError) {
1187+
throw error;
1188+
}
11501189
// RFC 9728 not supported -- fall back to treating the server URL as the authorization server
11511190
}
11521191

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Browser runtime shims for client package
3+
*
4+
* This file is selected via package.json export conditions when running in a browser.
5+
*/
6+
export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core';
7+
8+
/**
9+
* Whether `fetch()` may throw `TypeError` due to CORS. Only true in browser contexts
10+
* (including Web Workers / Service Workers). In Node.js and Cloudflare Workers, a
11+
* `TypeError` from `fetch` is always a real network/configuration error.
12+
*/
13+
export const CORS_IS_POSSIBLE = true;

packages/client/src/shimsNode.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@
44
* This file is selected via package.json export conditions when running in Node.js.
55
*/
66
export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core';
7+
8+
/**
9+
* Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept —
10+
* in Node.js, a `TypeError` from `fetch` is always a real network/configuration error
11+
* (DNS resolution, connection refused, invalid URL), never a CORS error.
12+
*/
13+
export const CORS_IS_POSSIBLE = false;

packages/client/src/shimsWorkerd.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@
44
* This file is selected via package.json export conditions when running in workerd.
55
*/
66
export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core';
7+
8+
/**
9+
* Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept —
10+
* in Cloudflare Workers, a `TypeError` from `fetch` is always a real network/configuration
11+
* error, never a CORS error.
12+
*/
13+
export const CORS_IS_POSSIBLE = false;

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

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,34 @@ vi.mock('pkce-challenge', () => ({
3333
const mockFetch = vi.fn();
3434
globalThis.fetch = mockFetch;
3535

36+
/**
37+
* fetchWithCorsRetry gates its CORS-swallowing heuristic on the `CORS_IS_POSSIBLE` shim constant.
38+
* Tests run under the Node shim (`false`), so a fetch TypeError is treated as a real network error
39+
* and thrown instead of swallowed. Tests that specifically exercise the browser CORS retry path
40+
* call `withBrowserLikeEnvironment()` to flip the mocked constant to `true`. The `afterEach` hook
41+
* resets it so a failed assertion can't leak the override into later tests.
42+
*/
43+
let mockedCorsIsPossible = false;
44+
vi.mock('@modelcontextprotocol/client/_shims', async importOriginal => {
45+
const actual = await importOriginal<typeof import('@modelcontextprotocol/client/_shims')>();
46+
return {
47+
...actual,
48+
get CORS_IS_POSSIBLE() {
49+
return mockedCorsIsPossible;
50+
}
51+
};
52+
});
53+
function withBrowserLikeEnvironment(): void {
54+
mockedCorsIsPossible = true;
55+
}
56+
3657
describe('OAuth Authorization', () => {
3758
beforeEach(() => {
3859
mockFetch.mockReset();
3960
});
61+
afterEach(() => {
62+
mockedCorsIsPossible = false;
63+
});
4064

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

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

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

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

@@ -177,6 +203,18 @@ describe('OAuth Authorization', () => {
177203
expect(mockFetch).toHaveBeenCalledTimes(2);
178204
});
179205

206+
it('throws TypeError immediately in non-browser environments without retrying', async () => {
207+
// In Node.js/Workers, CORS doesn't exist — a TypeError from fetch is a real
208+
// network/config error (DNS failure, connection refused, invalid URL) and
209+
// should propagate rather than being silently swallowed.
210+
mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND resource.example.com')));
211+
212+
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(TypeError);
213+
214+
// Only one call — no CORS retry attempted
215+
expect(mockFetch).toHaveBeenCalledTimes(1);
216+
});
217+
180218
it('throws on 404 errors', async () => {
181219
mockFetch.mockResolvedValueOnce({
182220
ok: false,
@@ -348,7 +386,8 @@ describe('OAuth Authorization', () => {
348386
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
349387
});
350388

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

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

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

@@ -591,7 +631,8 @@ describe('OAuth Authorization', () => {
591631
});
592632
});
593633

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

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

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

@@ -637,7 +679,8 @@ describe('OAuth Authorization', () => {
637679
expect(mockFetch).toHaveBeenCalledTimes(2);
638680
});
639681

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

830-
it('handles CORS errors with retry', async () => {
873+
it('handles CORS errors with retry (browser)', async () => {
874+
withBrowserLikeEnvironment();
831875
// First call fails with CORS
832876
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));
833877

@@ -883,7 +927,8 @@ describe('OAuth Authorization', () => {
883927
});
884928
});
885929

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

@@ -894,6 +939,18 @@ describe('OAuth Authorization', () => {
894939
// Verify that all discovery URLs were attempted
895940
expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers)
896941
});
942+
943+
it('throws TypeError in non-browser environments instead of silently falling through (network failure)', async () => {
944+
// In Node.js, a TypeError from fetch is a real error (DNS/connection), not CORS.
945+
// Swallowing it and returning undefined would cause the caller to silently fall
946+
// through to the next discovery URL, masking the actual network failure.
947+
mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND auth.example.com')));
948+
949+
await expect(discoverAuthorizationServerMetadata('https://auth.example.com/tenant1')).rejects.toThrow(TypeError);
950+
951+
// Only one call — no CORS retry attempted in non-browser environments
952+
expect(mockFetch).toHaveBeenCalledTimes(1);
953+
});
897954
});
898955

899956
describe('discoverOAuthServerInfo', () => {
@@ -1006,6 +1063,15 @@ describe('OAuth Authorization', () => {
10061063
// Verify the override URL was used instead of the default well-known path
10071064
expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString());
10081065
});
1066+
1067+
it('propagates network failures instead of silently falling back (non-browser)', async () => {
1068+
// PRM discovery hits a DNS/connection failure. That's a transient reachability problem,
1069+
// not "server doesn't support RFC 9728" — the caller should see the real error rather
1070+
// than silently falling back to treating the MCP server URL as the auth server.
1071+
mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND resource.example.com')));
1072+
1073+
await expect(discoverOAuthServerInfo('https://resource.example.com')).rejects.toThrow(TypeError);
1074+
});
10091075
});
10101076

10111077
describe('auth with provider authorization server URL caching', () => {

packages/client/tsdown.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown';
33
export default defineConfig({
44
// 1. Entry Points
55
// Directly matches package.json include/exclude globs
6-
entry: ['src/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts'],
6+
entry: ['src/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts'],
77

88
// 2. Output Configuration
99
format: ['esm'],

0 commit comments

Comments
 (0)