1- import { StreamableHTTPClientTransport } from "./streamableHttp.js" ;
1+ import { StreamableHTTPClientTransport , StreamableHTTPReconnectionOptions } from "./streamableHttp.js" ;
22import { JSONRPCMessage } from "../types.js" ;
33
44
@@ -101,6 +101,77 @@ describe("StreamableHTTPClientTransport", () => {
101101 expect ( lastCall [ 1 ] . headers . get ( "mcp-session-id" ) ) . toBe ( "test-session-id" ) ;
102102 } ) ;
103103
104+ it ( "should terminate session with DELETE request" , async ( ) => {
105+ // First, simulate getting a session ID
106+ const message : JSONRPCMessage = {
107+ jsonrpc : "2.0" ,
108+ method : "initialize" ,
109+ params : {
110+ clientInfo : { name : "test-client" , version : "1.0" } ,
111+ protocolVersion : "2025-03-26"
112+ } ,
113+ id : "init-id"
114+ } ;
115+
116+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
117+ ok : true ,
118+ status : 200 ,
119+ headers : new Headers ( { "content-type" : "text/event-stream" , "mcp-session-id" : "test-session-id" } ) ,
120+ } ) ;
121+
122+ await transport . send ( message ) ;
123+ expect ( transport . sessionId ) . toBe ( "test-session-id" ) ;
124+
125+ // Now terminate the session
126+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
127+ ok : true ,
128+ status : 200 ,
129+ headers : new Headers ( )
130+ } ) ;
131+
132+ await transport . terminateSession ( ) ;
133+
134+ // Verify the DELETE request was sent with the session ID
135+ const calls = ( global . fetch as jest . Mock ) . mock . calls ;
136+ const lastCall = calls [ calls . length - 1 ] ;
137+ expect ( lastCall [ 1 ] . method ) . toBe ( "DELETE" ) ;
138+ expect ( lastCall [ 1 ] . headers . get ( "mcp-session-id" ) ) . toBe ( "test-session-id" ) ;
139+
140+ // The session ID should be cleared after successful termination
141+ expect ( transport . sessionId ) . toBeUndefined ( ) ;
142+ } ) ;
143+
144+ it ( "should handle 405 response when server doesn't support session termination" , async ( ) => {
145+ // First, simulate getting a session ID
146+ const message : JSONRPCMessage = {
147+ jsonrpc : "2.0" ,
148+ method : "initialize" ,
149+ params : {
150+ clientInfo : { name : "test-client" , version : "1.0" } ,
151+ protocolVersion : "2025-03-26"
152+ } ,
153+ id : "init-id"
154+ } ;
155+
156+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
157+ ok : true ,
158+ status : 200 ,
159+ headers : new Headers ( { "content-type" : "text/event-stream" , "mcp-session-id" : "test-session-id" } ) ,
160+ } ) ;
161+
162+ await transport . send ( message ) ;
163+
164+ // Now terminate the session, but server responds with 405
165+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
166+ ok : false ,
167+ status : 405 ,
168+ statusText : "Method Not Allowed" ,
169+ headers : new Headers ( )
170+ } ) ;
171+
172+ await expect ( transport . terminateSession ( ) ) . resolves . not . toThrow ( ) ;
173+ } ) ;
174+
104175 it ( "should handle 404 response when session expires" , async ( ) => {
105176 const message : JSONRPCMessage = {
106177 jsonrpc : "2.0" ,
@@ -164,7 +235,7 @@ describe("StreamableHTTPClientTransport", () => {
164235 // We expect the 405 error to be caught and handled gracefully
165236 // This should not throw an error that breaks the transport
166237 await transport . start ( ) ;
167- await expect ( transport [ "_startOrAuthStandaloneSSE " ] ( ) ) . resolves . not . toThrow ( "Failed to open SSE stream: Method Not Allowed" ) ;
238+ await expect ( transport [ "_startOrAuthSse " ] ( { } ) ) . resolves . not . toThrow ( "Failed to open SSE stream: Method Not Allowed" ) ;
168239 // Check that GET was attempted
169240 expect ( global . fetch ) . toHaveBeenCalledWith (
170241 expect . anything ( ) ,
@@ -208,7 +279,7 @@ describe("StreamableHTTPClientTransport", () => {
208279 transport . onmessage = messageSpy ;
209280
210281 await transport . start ( ) ;
211- await transport [ "_startOrAuthStandaloneSSE " ] ( ) ;
282+ await transport [ "_startOrAuthSse " ] ( { } ) ;
212283
213284 // Give time for the SSE event to be processed
214285 await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
@@ -275,45 +346,62 @@ describe("StreamableHTTPClientTransport", () => {
275346 } ) ) . toBe ( true ) ;
276347 } ) ;
277348
278- it ( "should include last-event-id header when resuming a broken connection" , async ( ) => {
279- // First make a successful connection that provides an event ID
280- const encoder = new TextEncoder ( ) ;
281- const stream = new ReadableStream ( {
282- start ( controller ) {
283- const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n" ;
284- controller . enqueue ( encoder . encode ( event ) ) ;
285- controller . close ( ) ;
349+ it ( "should support custom reconnection options" , ( ) => {
350+ // Create a transport with custom reconnection options
351+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) , {
352+ reconnectionOptions : {
353+ initialReconnectionDelay : 500 ,
354+ maxReconnectionDelay : 10000 ,
355+ reconnectionDelayGrowFactor : 2 ,
356+ maxRetries : 5 ,
286357 }
287358 } ) ;
288359
289- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
290- ok : true ,
291- status : 200 ,
292- headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
293- body : stream
294- } ) ;
360+ // Verify options were set correctly (checking implementation details)
361+ // Access private properties for testing
362+ const transportInstance = transport as unknown as {
363+ _reconnectionOptions : StreamableHTTPReconnectionOptions ;
364+ } ;
365+ expect ( transportInstance . _reconnectionOptions . initialReconnectionDelay ) . toBe ( 500 ) ;
366+ expect ( transportInstance . _reconnectionOptions . maxRetries ) . toBe ( 5 ) ;
367+ } ) ;
295368
296- await transport . start ( ) ;
297- await transport [ "_startOrAuthStandaloneSSE" ] ( ) ;
298- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
369+ it ( "should pass lastEventId when reconnecting" , async ( ) => {
370+ // Create a fresh transport
371+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) ) ;
299372
300- // Now simulate attempting to reconnect
301- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
373+ // Mock fetch to verify headers sent
374+ const fetchSpy = global . fetch as jest . Mock ;
375+ fetchSpy . mockReset ( ) ;
376+ fetchSpy . mockResolvedValue ( {
302377 ok : true ,
303378 status : 200 ,
304379 headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
305- body : null
380+ body : new ReadableStream ( )
306381 } ) ;
307382
308- await transport [ "_startOrAuthStandaloneSSE" ] ( ) ;
383+ // Call the reconnect method directly with a lastEventId
384+ await transport . start ( ) ;
385+ // Type assertion to access private method
386+ const transportWithPrivateMethods = transport as unknown as {
387+ _startOrAuthSse : ( options : { resumptionToken ?: string } ) => Promise < void >
388+ } ;
389+ await transportWithPrivateMethods . _startOrAuthSse ( { resumptionToken : "test-event-id" } ) ;
309390
310- // Check that Last-Event-ID was included
311- const calls = ( global . fetch as jest . Mock ) . mock . calls ;
312- const lastCall = calls [ calls . length - 1 ] ;
313- expect ( lastCall [ 1 ] . headers . get ( "last-event-id" ) ) . toBe ( "event-123" ) ;
391+ // Verify fetch was called with the lastEventId header
392+ expect ( fetchSpy ) . toHaveBeenCalled ( ) ;
393+ const fetchCall = fetchSpy . mock . calls [ 0 ] ;
394+ const headers = fetchCall [ 1 ] . headers ;
395+ expect ( headers . get ( "last-event-id" ) ) . toBe ( "test-event-id" ) ;
314396 } ) ;
315397
316398 it ( "should throw error when invalid content-type is received" , async ( ) => {
399+ // Clear any previous state from other tests
400+ jest . clearAllMocks ( ) ;
401+
402+ // Create a fresh transport instance
403+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) ) ;
404+
317405 const message : JSONRPCMessage = {
318406 jsonrpc : "2.0" ,
319407 method : "test" ,
@@ -323,7 +411,7 @@ describe("StreamableHTTPClientTransport", () => {
323411
324412 const stream = new ReadableStream ( {
325413 start ( controller ) {
326- controller . enqueue ( "invalid text response" ) ;
414+ controller . enqueue ( new TextEncoder ( ) . encode ( "invalid text response" ) ) ;
327415 controller . close ( ) ;
328416 }
329417 } ) ;
@@ -365,7 +453,7 @@ describe("StreamableHTTPClientTransport", () => {
365453
366454 await transport . start ( ) ;
367455
368- await transport [ "_startOrAuthStandaloneSSE " ] ( ) ;
456+ await transport [ "_startOrAuthSse " ] ( { } ) ;
369457 expect ( ( actualReqInit . headers as Headers ) . get ( "x-custom-header" ) ) . toBe ( "CustomValue" ) ;
370458
371459 requestInit . headers [ "X-Custom-Header" ] = "SecondCustomValue" ;
@@ -375,4 +463,38 @@ describe("StreamableHTTPClientTransport", () => {
375463
376464 expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 ) ;
377465 } ) ;
466+
467+
468+ it ( "should have exponential backoff with configurable maxRetries" , ( ) => {
469+ // This test verifies the maxRetries and backoff calculation directly
470+
471+ // Create transport with specific options for testing
472+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) , {
473+ reconnectionOptions : {
474+ initialReconnectionDelay : 100 ,
475+ maxReconnectionDelay : 5000 ,
476+ reconnectionDelayGrowFactor : 2 ,
477+ maxRetries : 3 ,
478+ }
479+ } ) ;
480+
481+ // Get access to the internal implementation
482+ const getDelay = transport [ "_getNextReconnectionDelay" ] . bind ( transport ) ;
483+
484+ // First retry - should use initial delay
485+ expect ( getDelay ( 0 ) ) . toBe ( 100 ) ;
486+
487+ // Second retry - should double (2^1 * 100 = 200)
488+ expect ( getDelay ( 1 ) ) . toBe ( 200 ) ;
489+
490+ // Third retry - should double again (2^2 * 100 = 400)
491+ expect ( getDelay ( 2 ) ) . toBe ( 400 ) ;
492+
493+ // Fourth retry - should double again (2^3 * 100 = 800)
494+ expect ( getDelay ( 3 ) ) . toBe ( 800 ) ;
495+
496+ // Tenth retry - should be capped at maxReconnectionDelay
497+ expect ( getDelay ( 10 ) ) . toBe ( 5000 ) ;
498+ } ) ;
499+
378500} ) ;
0 commit comments