@@ -593,6 +593,98 @@ describe('StreamableHTTPClientTransport', () => {
593593 expect ( mockAuthProvider . redirectToAuthorization . mock . calls ) . toHaveLength ( 1 ) ;
594594 } ) ;
595595
596+ it ( 'attempts upscoping on 403 with WWW-Authenticate header' , async ( ) => {
597+ const message : JSONRPCMessage = {
598+ jsonrpc : '2.0' ,
599+ method : 'test' ,
600+ params : { } ,
601+ id : 'test-id'
602+ } ;
603+
604+ const fetchMock = global . fetch as Mock ;
605+ fetchMock
606+ // First call: returns 403 with insufficient_scope
607+ . mockResolvedValueOnce ( {
608+ ok : false ,
609+ status : 403 ,
610+ statusText : 'Forbidden' ,
611+ headers : new Headers ( {
612+ 'WWW-Authenticate' :
613+ 'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"'
614+ } ) ,
615+ text : ( ) => Promise . resolve ( 'Insufficient scope' )
616+ } )
617+ // Second call: successful after upscoping
618+ . mockResolvedValueOnce ( {
619+ ok : true ,
620+ status : 202 ,
621+ headers : new Headers ( )
622+ } ) ;
623+
624+ // Spy on the imported auth function and mock successful authorization
625+ const authModule = await import ( './auth.js' ) ;
626+ const authSpy = vi . spyOn ( authModule , 'auth' ) ;
627+ authSpy . mockResolvedValue ( 'AUTHORIZED' ) ;
628+
629+ await transport . send ( message ) ;
630+
631+ // Verify fetch was called twice
632+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
633+
634+ // Verify auth was called with the new scope
635+ expect ( authSpy ) . toHaveBeenCalledWith (
636+ mockAuthProvider ,
637+ expect . objectContaining ( {
638+ scope : 'new_scope' ,
639+ resourceMetadataUrl : new URL ( 'http://example.com/resource' )
640+ } )
641+ ) ;
642+
643+ authSpy . mockRestore ( ) ;
644+ } ) ;
645+
646+ it ( 'prevents infinite upscoping on repeated 403' , async ( ) => {
647+ const message : JSONRPCMessage = {
648+ jsonrpc : '2.0' ,
649+ method : 'test' ,
650+ params : { } ,
651+ id : 'test-id'
652+ } ;
653+
654+ // Mock fetch calls to always return 403 with insufficient_scope
655+ const fetchMock = global . fetch as Mock ;
656+ fetchMock . mockResolvedValue ( {
657+ ok : false ,
658+ status : 403 ,
659+ statusText : 'Forbidden' ,
660+ headers : new Headers ( {
661+ 'WWW-Authenticate' : 'Bearer error="insufficient_scope", scope="new_scope"'
662+ } ) ,
663+ text : ( ) => Promise . resolve ( 'Insufficient scope' )
664+ } ) ;
665+
666+ // Spy on the imported auth function and mock successful authorization
667+ const authModule = await import ( './auth.js' ) ;
668+ const authSpy = vi . spyOn ( authModule , 'auth' ) ;
669+ authSpy . mockResolvedValue ( 'AUTHORIZED' ) ;
670+
671+ // First send: should trigger upscoping
672+ await expect ( transport . send ( message ) ) . rejects . toThrow ( 'Server returned 403 after trying upscoping' ) ;
673+
674+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ; // Initial call + one retry after auth
675+ expect ( authSpy ) . toHaveBeenCalledTimes ( 1 ) ; // Auth called once
676+
677+ // Second send: should fail immediately without re-calling auth
678+ fetchMock . mockClear ( ) ;
679+ authSpy . mockClear ( ) ;
680+ await expect ( transport . send ( message ) ) . rejects . toThrow ( 'Server returned 403 after trying upscoping' ) ;
681+
682+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ; // Only one fetch call
683+ expect ( authSpy ) . not . toHaveBeenCalled ( ) ; // Auth not called again
684+
685+ authSpy . mockRestore ( ) ;
686+ } ) ;
687+
596688 describe ( 'Reconnection Logic' , ( ) => {
597689 let transport : StreamableHTTPClientTransport ;
598690
0 commit comments