@@ -33,10 +33,34 @@ vi.mock('pkce-challenge', () => ({
3333const mockFetch = vi . fn ( ) ;
3434globalThis . 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+
3657describe ( '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' , ( ) => {
0 commit comments