@@ -303,6 +303,49 @@ describe('StreamableHTTPClientTransport', () => {
303303 expect ( lastCall [ 1 ] . headers . get ( 'mcp-session-id' ) ) . toBeNull ( ) ;
304304 } ) ;
305305
306+ it ( 'should not clear a newer session ID when a stale session-bound POST request returns 404' , async ( ) => {
307+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
308+ sessionId : 'stale-session-A'
309+ } ) ;
310+
311+ const message : JSONRPCMessage = {
312+ jsonrpc : '2.0' ,
313+ method : 'tools/list' ,
314+ params : { } ,
315+ id : 'test-id'
316+ } ;
317+
318+ let resolveFetch ! : ( value : unknown ) => void ;
319+ const deferredFetch = new Promise ( resolve => {
320+ resolveFetch = resolve ;
321+ } ) ;
322+
323+ ( globalThis . fetch as Mock ) . mockImplementationOnce ( ( ) => {
324+ // Simulate another in-flight request establishing a fresh session while this request is pending.
325+ ( transport as unknown as { _sessionId ?: string } ) . _sessionId = 'fresh-session-B' ;
326+ return deferredFetch ;
327+ } ) ;
328+
329+ const sendPromise = transport . send ( message ) ;
330+
331+ resolveFetch ( {
332+ ok : false ,
333+ status : 404 ,
334+ statusText : 'Not Found' ,
335+ text : ( ) => Promise . resolve ( 'Session not found' ) ,
336+ headers : new Headers ( )
337+ } ) ;
338+
339+ await expect ( sendPromise ) . rejects . toMatchObject ( {
340+ code : SdkErrorCode . ClientHttpNotImplemented ,
341+ data : expect . objectContaining ( {
342+ status : 404
343+ } )
344+ } ) ;
345+
346+ expect ( transport . sessionId ) . toBe ( 'fresh-session-B' ) ;
347+ } ) ;
348+
306349 it ( 'should handle non-streaming JSON response' , async ( ) => {
307350 const message : JSONRPCMessage = {
308351 jsonrpc : '2.0' ,
@@ -395,6 +438,46 @@ describe('StreamableHTTPClientTransport', () => {
395438 expect ( getCall [ 1 ] . headers . get ( 'mcp-session-id' ) ) . toBe ( 'stale-session-id' ) ;
396439 } ) ;
397440
441+ it ( 'should not clear a newer session ID when a stale session-bound GET request returns 404' , async ( ) => {
442+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
443+ sessionId : 'stale-session-A'
444+ } ) ;
445+ await transport . start ( ) ;
446+
447+ let resolveFetch ! : ( value : unknown ) => void ;
448+ const deferredFetch = new Promise ( resolve => {
449+ resolveFetch = resolve ;
450+ } ) ;
451+
452+ ( globalThis . fetch as Mock ) . mockImplementationOnce ( ( ) => {
453+ // Simulate another in-flight request establishing a fresh session while this request is pending.
454+ ( transport as unknown as { _sessionId ?: string } ) . _sessionId = 'fresh-session-B' ;
455+ return deferredFetch ;
456+ } ) ;
457+
458+ const startPromise = (
459+ transport as unknown as { _startOrAuthSse : ( opts : StartSSEOptions ) => Promise < void > }
460+ ) . _startOrAuthSse ( { } ) ;
461+
462+ resolveFetch ( {
463+ ok : false ,
464+ status : 404 ,
465+ statusText : 'Not Found' ,
466+ text : ( ) => Promise . resolve ( 'Session not found' ) ,
467+ headers : new Headers ( )
468+ } ) ;
469+
470+ await expect ( startPromise ) . rejects . toMatchObject ( {
471+ code : SdkErrorCode . ClientHttpFailedToOpenStream ,
472+ data : expect . objectContaining ( {
473+ status : 404 ,
474+ statusText : 'Not Found'
475+ } )
476+ } ) ;
477+
478+ expect ( transport . sessionId ) . toBe ( 'fresh-session-B' ) ;
479+ } ) ;
480+
398481 it ( 'should handle successful initial GET connection for SSE' , async ( ) => {
399482 // Set up readable stream for SSE events
400483 const encoder = new TextEncoder ( ) ;
@@ -1022,6 +1105,40 @@ describe('StreamableHTTPClientTransport', () => {
10221105 expect ( fetchMock . mock . calls [ 1 ] ! [ 1 ] ?. method ) . toBe ( 'GET' ) ;
10231106 } ) ;
10241107
1108+ it ( 'should stop retrying GET reconnection after a session-bound 404 clears the stale session' , async ( ) => {
1109+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
1110+ sessionId : 'stale-session-id' ,
1111+ reconnectionOptions : {
1112+ initialReconnectionDelay : 10 ,
1113+ maxRetries : 3 ,
1114+ maxReconnectionDelay : 1000 ,
1115+ reconnectionDelayGrowFactor : 1
1116+ }
1117+ } ) ;
1118+ await transport . start ( ) ;
1119+
1120+ const fetchMock = globalThis . fetch as Mock ;
1121+ fetchMock . mockResolvedValue ( {
1122+ ok : false ,
1123+ status : 404 ,
1124+ statusText : 'Not Found' ,
1125+ headers : new Headers ( ) ,
1126+ text : ( ) => Promise . resolve ( 'Session not found' )
1127+ } ) ;
1128+
1129+ (
1130+ transport as unknown as {
1131+ _scheduleReconnection : ( opts : StartSSEOptions , attemptCount ?: number ) => void ;
1132+ }
1133+ ) . _scheduleReconnection ( { } , 0 ) ;
1134+
1135+ await vi . advanceTimersByTimeAsync ( 20 ) ;
1136+ await vi . advanceTimersByTimeAsync ( 100 ) ;
1137+
1138+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
1139+ expect ( transport . sessionId ) . toBeUndefined ( ) ;
1140+ } ) ;
1141+
10251142 it ( 'should NOT reconnect a POST-initiated stream that fails' , async ( ) => {
10261143 // ARRANGE
10271144 transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
0 commit comments