@@ -2727,6 +2727,91 @@ describe('StreamableHTTPClientTransport', () => {
27272727 // Before the fix this was null: the empty second stream dropped the token.
27282728 expect ( thirdCallHeaders . get ( 'last-event-id' ) ) . toBe ( 'event-1' ) ;
27292729 } ) ;
2730+
2731+ it ( 'threads the stream callbacks through a resumed send(), settling the caller on exhaustion' , async ( ) => {
2732+ // A send() with a resumptionToken resumes via GET. New event IDs on
2733+ // the resumed stream must reach the caller's onresumptiontoken, and
2734+ // when reconnection attempts are exhausted the caller's
2735+ // onRequestStreamEnd must fire so the pending request settles
2736+ // instead of hanging until its timeout.
2737+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
2738+ reconnectionOptions : {
2739+ initialReconnectionDelay : 10 ,
2740+ maxReconnectionDelay : 1000 ,
2741+ reconnectionDelayGrowFactor : 1 ,
2742+ maxRetries : 1
2743+ }
2744+ } ) ;
2745+ const errorSpy = vi . fn ( ) ;
2746+ transport . onerror = errorSpy ;
2747+ const tokenSpy = vi . fn ( ) ;
2748+ const streamEndSpy = vi . fn ( ) ;
2749+
2750+ const fetchMock = globalThis . fetch as Mock ;
2751+ // Resumed stream delivers a fresh priming event then idle-closes;
2752+ // every stream after that closes empty until exhaustion.
2753+ const bodies : string [ ] [ ] = [ [ 'id: event-next\ndata: \n\n' ] ] ;
2754+ fetchMock . mockImplementation ( async ( ) => sseResponse ( bodies . shift ( ) ?? [ ] ) ) ;
2755+
2756+ const requestMessage : JSONRPCRequest = {
2757+ jsonrpc : '2.0' ,
2758+ method : 'long_running_tool' ,
2759+ id : 'request-1' ,
2760+ params : { }
2761+ } ;
2762+
2763+ await transport . start ( ) ;
2764+ await transport . send ( requestMessage , {
2765+ resumptionToken : 'event-0' ,
2766+ onresumptiontoken : tokenSpy ,
2767+ onRequestStreamEnd : streamEndSpy
2768+ } ) ;
2769+ await vi . advanceTimersByTimeAsync ( 200 ) ;
2770+
2771+ // The resume goes out as a GET with the caller's token.
2772+ expect ( fetchMock . mock . calls [ 0 ] ! [ 1 ] ?. method ) . toBe ( 'GET' ) ;
2773+ expect ( ( fetchMock . mock . calls [ 0 ] ! [ 1 ] ?. headers as Headers ) . get ( 'last-event-id' ) ) . toBe ( 'event-0' ) ;
2774+ // The fresh priming event reached the caller's persistence hook.
2775+ expect ( tokenSpy ) . toHaveBeenCalledWith ( 'event-next' ) ;
2776+ // Exhaustion settled the caller instead of leaving it hanging.
2777+ expect ( streamEndSpy ) . toHaveBeenCalledTimes ( 1 ) ;
2778+ expect ( errorSpy ) . toHaveBeenCalledWith (
2779+ expect . objectContaining ( {
2780+ message : 'Maximum reconnection attempts (1) exceeded.'
2781+ } )
2782+ ) ;
2783+ } ) ;
2784+
2785+ it ( 'does not double-fire exhaustion callbacks when a user onerror handler throws on graceful close' , async ( ) => {
2786+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
2787+ reconnectionOptions : {
2788+ initialReconnectionDelay : 10 ,
2789+ maxReconnectionDelay : 1000 ,
2790+ reconnectionDelayGrowFactor : 1 ,
2791+ maxRetries : 0 // exhaustion trips on the first graceful close
2792+ }
2793+ } ) ;
2794+ const errorSpy = vi . fn ( ) . mockImplementationOnce ( ( ) => {
2795+ throw new Error ( 'user handler exploded' ) ;
2796+ } ) ;
2797+ transport . onerror = errorSpy ;
2798+ const streamEndSpy = vi . fn ( ) ;
2799+
2800+ const fetchMock = globalThis . fetch as Mock ;
2801+ fetchMock . mockImplementation ( async ( ) => sseResponse ( [ ] ) ) ;
2802+
2803+ await transport . start ( ) ;
2804+ await transport [ '_startOrAuthSse' ] ( { onRequestStreamEnd : streamEndSpy } ) ;
2805+ await vi . advanceTimersByTimeAsync ( 100 ) ;
2806+
2807+ // The throwing handler is contained by the graceful branch's guard:
2808+ // the caller still settles exactly once, no reconnect is scheduled,
2809+ // and no misleading 'SSE stream disconnected' error is emitted.
2810+ expect ( streamEndSpy ) . toHaveBeenCalledTimes ( 1 ) ;
2811+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
2812+ const messages = errorSpy . mock . calls . map ( args => ( args [ 0 ] as Error ) . message ) ;
2813+ expect ( messages . some ( m => m . includes ( 'SSE stream disconnected' ) ) ) . toBe ( false ) ;
2814+ } ) ;
27302815 } ) ;
27312816
27322817 describe ( 'prevent infinite recursion when server returns 401 after successful auth' , ( ) => {
0 commit comments