@@ -4,6 +4,7 @@ import type { Mock, Mocked } from 'vitest';
44
55import type { OAuthClientProvider } from '../../src/client/auth' ;
66import { UnauthorizedError } from '../../src/client/auth' ;
7+ import { Client } from '../../src/client/client' ;
78import type { ReconnectionScheduler , StartSSEOptions , StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp' ;
89import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp' ;
910
@@ -1464,6 +1465,57 @@ describe('StreamableHTTPClientTransport', () => {
14641465 expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
14651466 } ) ;
14661467
1468+ it ( 'per-request requestSignal abort while a reconnect is scheduled: the pending reconnect never fires (#2615)' , async ( ) => {
1469+ // ARRANGE — a POST stream that is primed (SSE event id) and then
1470+ // closes gracefully WITHOUT delivering the response, so the
1471+ // transport schedules a GET+Last-Event-ID reconnect. The abort
1472+ // lands in the window between "reconnect scheduled" and "reconnect
1473+ // fires" — the shape a request timeout produces.
1474+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
1475+ reconnectionOptions : {
1476+ initialReconnectionDelay : 10 ,
1477+ maxRetries : 5 ,
1478+ maxReconnectionDelay : 1000 ,
1479+ reconnectionDelayGrowFactor : 1
1480+ }
1481+ } ) ;
1482+ const errorSpy = vi . fn ( ) ;
1483+ transport . onerror = errorSpy ;
1484+
1485+ const primedClosingStream = new ReadableStream < Uint8Array > ( {
1486+ start ( controller ) {
1487+ controller . enqueue ( new TextEncoder ( ) . encode ( 'id: ev-1\ndata: \n\n' ) ) ;
1488+ controller . close ( ) ;
1489+ }
1490+ } ) ;
1491+ const fetchMock = globalThis . fetch as Mock ;
1492+ fetchMock . mockResolvedValueOnce ( {
1493+ ok : true ,
1494+ status : 200 ,
1495+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
1496+ body : primedClosingStream
1497+ } ) ;
1498+
1499+ const requestAbort = new AbortController ( ) ;
1500+ await transport . start ( ) ;
1501+ await transport . send (
1502+ { jsonrpc : '2.0' , method : 'long_running_tool' , id : 'request-1' , params : { } } ,
1503+ { requestSignal : requestAbort . signal }
1504+ ) ;
1505+ // Let the stream close and the reconnect get scheduled (delay 10ms).
1506+ await vi . advanceTimersByTimeAsync ( 5 ) ;
1507+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
1508+
1509+ // ACT — the request settles (timeout/cancel) before the reconnect fires.
1510+ requestAbort . abort ( ) ;
1511+ await vi . advanceTimersByTimeAsync ( 100 ) ;
1512+
1513+ // ASSERT — the scheduled reconnect saw the aborted requestSignal
1514+ // and bailed: no GET resume, no onerror.
1515+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
1516+ expect ( errorSpy ) . not . toHaveBeenCalled ( ) ;
1517+ } ) ;
1518+
14671519 it ( 'onRequestStreamEnd fires when the per-request POST stream ends gracefully without reconnecting' , async ( ) => {
14681520 // ARRANGE — a POST stream with NO priming event id (so the
14691521 // graceful-close path does NOT schedule a reconnect): the
@@ -2737,3 +2789,160 @@ describe('StreamableHTTPClientTransport', () => {
27372789 } ) ;
27382790 } ) ;
27392791} ) ;
2792+
2793+ /**
2794+ * End-to-end regression for #2615: on a legacy (2025-11-25) session, the
2795+ * transport's request-scoped SSE reconnect chain (GET + Last-Event-ID
2796+ * resumption) must stop once the originating request settles via timeout.
2797+ * Before the fix, the chain kept resuming forever (every successful resume
2798+ * resets the retry counter), and a late resumed GET carrying the original
2799+ * JSON-RPC response surfaced as "Received a response for an unknown message
2800+ * ID".
2801+ */
2802+ describe ( 'legacy era (2025-11-25): request timeout stops the SSE reconnect chain (#2615)' , ( ) => {
2803+ beforeEach ( ( ) => {
2804+ vi . useFakeTimers ( ) ;
2805+ vi . spyOn ( globalThis , 'fetch' ) ;
2806+ } ) ;
2807+
2808+ afterEach ( ( ) => {
2809+ vi . useRealTimers ( ) ;
2810+ vi . clearAllMocks ( ) ;
2811+ } ) ;
2812+
2813+ const encoder = new TextEncoder ( ) ;
2814+ const sseResponse = ( chunks : string [ ] ) => ( {
2815+ ok : true ,
2816+ status : 200 ,
2817+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
2818+ body : new ReadableStream < Uint8Array > ( {
2819+ start ( controller ) {
2820+ for ( const chunk of chunks ) {
2821+ controller . enqueue ( encoder . encode ( chunk ) ) ;
2822+ }
2823+ controller . close ( ) ;
2824+ }
2825+ } )
2826+ } ) ;
2827+ const jsonResponse = ( message : JSONRPCMessage ) => ( {
2828+ ok : true ,
2829+ status : 200 ,
2830+ headers : new Headers ( { 'content-type' : 'application/json' } ) ,
2831+ json : async ( ) => message ,
2832+ text : async ( ) => JSON . stringify ( message )
2833+ } ) ;
2834+ const accepted = ( ) => ( { ok : true , status : 202 , headers : new Headers ( ) , text : async ( ) => '' } ) ;
2835+ const methodNotAllowed = ( ) => ( {
2836+ ok : false ,
2837+ status : 405 ,
2838+ statusText : 'Method Not Allowed' ,
2839+ headers : new Headers ( ) ,
2840+ text : async ( ) => ''
2841+ } ) ;
2842+
2843+ it ( 'stops resuming once the request times out; the late response never surfaces as an unknown message ID' , async ( ) => {
2844+ let pingId : string | number | undefined ;
2845+ let eventSeq = 0 ;
2846+ let settled = false ;
2847+ let resumesAfterSettle = 0 ;
2848+ const cancelledPosts : JSONRPCMessage [ ] = [ ] ;
2849+
2850+ const fetchMock = globalThis . fetch as Mock ;
2851+ fetchMock . mockImplementation ( async ( _url , init : RequestInit ) => {
2852+ if ( init . method === 'GET' ) {
2853+ const lastEventId = ( init . headers as Headers ) . get ( 'last-event-id' ) ;
2854+ // Standalone notification stream: not offered by this server.
2855+ if ( lastEventId === null ) {
2856+ return methodNotAllowed ( ) ;
2857+ }
2858+ // Request-scoped resume. Once the request has settled, hand
2859+ // back the late original response — before the fix this is
2860+ // the resumed GET that surfaced "unknown message ID".
2861+ if ( settled ) {
2862+ resumesAfterSettle ++ ;
2863+ return sseResponse ( [ `id: evt-${ ++ eventSeq } \ndata: {"jsonrpc":"2.0","id":${ JSON . stringify ( pingId ) } ,"result":{}}\n\n` ] ) ;
2864+ }
2865+ // Keep the chain alive: a priming event id, then a graceful
2866+ // close without the response (the server expects the client
2867+ // to resume via GET + Last-Event-ID).
2868+ return sseResponse ( [ `id: evt-${ ++ eventSeq } \ndata: \n\n` ] ) ;
2869+ }
2870+ const message = JSON . parse ( init . body as string ) as JSONRPCMessage ;
2871+ if ( 'method' in message ) {
2872+ if ( message . method === 'initialize' && 'id' in message ) {
2873+ return jsonResponse ( {
2874+ jsonrpc : '2.0' ,
2875+ id : message . id ,
2876+ result : {
2877+ protocolVersion : '2025-11-25' ,
2878+ capabilities : { } ,
2879+ serverInfo : { name : 'legacy-server' , version : '1.0.0' }
2880+ }
2881+ } ) ;
2882+ }
2883+ if ( message . method === 'notifications/cancelled' ) {
2884+ cancelledPosts . push ( message ) ;
2885+ return accepted ( ) ;
2886+ }
2887+ if ( message . method === 'ping' && 'id' in message ) {
2888+ pingId = message . id ;
2889+ // SSE response: retry hint + priming event id, then a
2890+ // graceful close without the response.
2891+ return sseResponse ( [ `retry: 10\nid: evt-${ ++ eventSeq } \ndata: \n\n` ] ) ;
2892+ }
2893+ }
2894+ return accepted ( ) ;
2895+ } ) ;
2896+
2897+ const transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
2898+ reconnectionOptions : {
2899+ initialReconnectionDelay : 10 ,
2900+ maxRetries : 2 ,
2901+ maxReconnectionDelay : 1000 ,
2902+ reconnectionDelayGrowFactor : 1
2903+ }
2904+ } ) ;
2905+ const client = new Client ( { name : 'test-client' , version : '1.0.0' } ) ;
2906+ const errors : Error [ ] = [ ] ;
2907+ client . onerror = error => errors . push ( error ) ;
2908+
2909+ await client . connect ( transport ) ;
2910+
2911+ const resumeGetCount = ( ) =>
2912+ fetchMock . mock . calls . filter ( call => call [ 1 ] ?. method === 'GET' && ( call [ 1 ] . headers as Headers ) . get ( 'last-event-id' ) !== null )
2913+ . length ;
2914+
2915+ let settledError : unknown ;
2916+ const pending = client . ping ( { timeout : 100 } ) . catch ( error => {
2917+ settled = true ;
2918+ settledError = error ;
2919+ } ) ;
2920+
2921+ // Let the reconnect chain run a few resume cycles before the timeout.
2922+ await vi . advanceTimersByTimeAsync ( 50 ) ;
2923+ expect ( resumeGetCount ( ) ) . toBeGreaterThan ( 0 ) ;
2924+ expect ( settled ) . toBe ( false ) ;
2925+
2926+ // Cross the request timeout.
2927+ await vi . advanceTimersByTimeAsync ( 100 ) ;
2928+ await pending ;
2929+ expect ( settled ) . toBe ( true ) ;
2930+ expect ( String ( settledError ) ) . toContain ( 'Request timed out' ) ;
2931+
2932+ // The legacy wire cancel signal is unchanged: exactly one
2933+ // notifications/cancelled POST.
2934+ expect ( cancelledPosts ) . toHaveLength ( 1 ) ;
2935+
2936+ // Give an orphaned chain ample time to keep resuming (before the fix
2937+ // it reconnected forever — each successful resume resets the retry
2938+ // counter, so maxRetries never binds).
2939+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
2940+
2941+ // THE KEY ASSERTIONS: no resumed GET after the request settled, and
2942+ // the late response never surfaced as an unknown message ID.
2943+ expect ( resumesAfterSettle ) . toBe ( 0 ) ;
2944+ expect ( errors . map ( e => e . message ) ) . not . toContainEqual ( expect . stringContaining ( 'unknown message ID' ) ) ;
2945+
2946+ await client . close ( ) ;
2947+ } ) ;
2948+ } ) ;
0 commit comments