1
- import { StreamableHTTPClientTransport } from "./streamableHttp.js" ;
1
+ import { StreamableHTTPClientTransport , StreamableHTTPReconnectionOptions } from "./streamableHttp.js" ;
2
2
import { JSONRPCMessage } from "../types.js" ;
3
3
4
4
@@ -164,7 +164,7 @@ describe("StreamableHTTPClientTransport", () => {
164
164
// We expect the 405 error to be caught and handled gracefully
165
165
// This should not throw an error that breaks the transport
166
166
await transport . start ( ) ;
167
- await expect ( transport [ "_startOrAuthStandaloneSSE" ] ( ) ) . resolves . not . toThrow ( "Failed to open SSE stream: Method Not Allowed" ) ;
167
+ await expect ( transport [ "_startOrAuthStandaloneSSE" ] ( { } ) ) . resolves . not . toThrow ( "Failed to open SSE stream: Method Not Allowed" ) ;
168
168
// Check that GET was attempted
169
169
expect ( global . fetch ) . toHaveBeenCalledWith (
170
170
expect . anything ( ) ,
@@ -208,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => {
208
208
transport . onmessage = messageSpy ;
209
209
210
210
await transport . start ( ) ;
211
- await transport [ "_startOrAuthStandaloneSSE" ] ( ) ;
211
+ await transport [ "_startOrAuthStandaloneSSE" ] ( { } ) ;
212
212
213
213
// Give time for the SSE event to be processed
214
214
await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
@@ -275,45 +275,62 @@ describe("StreamableHTTPClientTransport", () => {
275
275
} ) ) . toBe ( true ) ;
276
276
} ) ;
277
277
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 ( ) ;
278
+ it ( "should support custom reconnection options" , ( ) => {
279
+ // Create a transport with custom reconnection options
280
+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) , {
281
+ reconnectionOptions : {
282
+ initialReconnectionDelay : 500 ,
283
+ maxReconnectionDelay : 10000 ,
284
+ reconnectionDelayGrowFactor : 2 ,
285
+ maxRetries : 5 ,
286
286
}
287
287
} ) ;
288
288
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
- } ) ;
289
+ // Verify options were set correctly (checking implementation details)
290
+ // Access private properties for testing
291
+ const transportInstance = transport as unknown as {
292
+ _reconnectionOptions : StreamableHTTPReconnectionOptions ;
293
+ } ;
294
+ expect ( transportInstance . _reconnectionOptions . initialReconnectionDelay ) . toBe ( 500 ) ;
295
+ expect ( transportInstance . _reconnectionOptions . maxRetries ) . toBe ( 5 ) ;
296
+ } ) ;
295
297
296
- await transport . start ( ) ;
297
- await transport [ "_startOrAuthStandaloneSSE" ] ( ) ;
298
- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
298
+ it ( "should pass lastEventId when reconnecting" , async ( ) => {
299
+ // Create a fresh transport
300
+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) ) ;
299
301
300
- // Now simulate attempting to reconnect
301
- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
302
+ // Mock fetch to verify headers sent
303
+ const fetchSpy = global . fetch as jest . Mock ;
304
+ fetchSpy . mockReset ( ) ;
305
+ fetchSpy . mockResolvedValue ( {
302
306
ok : true ,
303
307
status : 200 ,
304
308
headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
305
- body : null
309
+ body : new ReadableStream ( )
306
310
} ) ;
307
311
308
- await transport [ "_startOrAuthStandaloneSSE" ] ( ) ;
312
+ // Call the reconnect method directly with a lastEventId
313
+ await transport . start ( ) ;
314
+ // Type assertion to access private method
315
+ const transportWithPrivateMethods = transport as unknown as {
316
+ _startOrAuthStandaloneSSE : ( options : { lastEventId ?: string } ) => Promise < void >
317
+ } ;
318
+ await transportWithPrivateMethods . _startOrAuthStandaloneSSE ( { lastEventId : "test-event-id" } ) ;
309
319
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" ) ;
320
+ // Verify fetch was called with the lastEventId header
321
+ expect ( fetchSpy ) . toHaveBeenCalled ( ) ;
322
+ const fetchCall = fetchSpy . mock . calls [ 0 ] ;
323
+ const headers = fetchCall [ 1 ] . headers ;
324
+ expect ( headers . get ( "last-event-id" ) ) . toBe ( "test-event-id" ) ;
314
325
} ) ;
315
326
316
327
it ( "should throw error when invalid content-type is received" , async ( ) => {
328
+ // Clear any previous state from other tests
329
+ jest . clearAllMocks ( ) ;
330
+
331
+ // Create a fresh transport instance
332
+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) ) ;
333
+
317
334
const message : JSONRPCMessage = {
318
335
jsonrpc : "2.0" ,
319
336
method : "test" ,
@@ -323,7 +340,7 @@ describe("StreamableHTTPClientTransport", () => {
323
340
324
341
const stream = new ReadableStream ( {
325
342
start ( controller ) {
326
- controller . enqueue ( "invalid text response" ) ;
343
+ controller . enqueue ( new TextEncoder ( ) . encode ( "invalid text response" ) ) ;
327
344
controller . close ( ) ;
328
345
}
329
346
} ) ;
@@ -365,7 +382,7 @@ describe("StreamableHTTPClientTransport", () => {
365
382
366
383
await transport . start ( ) ;
367
384
368
- await transport [ "_startOrAuthStandaloneSSE" ] ( ) ;
385
+ await transport [ "_startOrAuthStandaloneSSE" ] ( { } ) ;
369
386
expect ( ( actualReqInit . headers as Headers ) . get ( "x-custom-header" ) ) . toBe ( "CustomValue" ) ;
370
387
371
388
requestInit . headers [ "X-Custom-Header" ] = "SecondCustomValue" ;
@@ -375,4 +392,38 @@ describe("StreamableHTTPClientTransport", () => {
375
392
376
393
expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 ) ;
377
394
} ) ;
395
+
396
+
397
+ it ( "should have exponential backoff with configurable maxRetries" , ( ) => {
398
+ // This test verifies the maxRetries and backoff calculation directly
399
+
400
+ // Create transport with specific options for testing
401
+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) , {
402
+ reconnectionOptions : {
403
+ initialReconnectionDelay : 100 ,
404
+ maxReconnectionDelay : 5000 ,
405
+ reconnectionDelayGrowFactor : 2 ,
406
+ maxRetries : 3 ,
407
+ }
408
+ } ) ;
409
+
410
+ // Get access to the internal implementation
411
+ const getDelay = transport [ "_getNextReconnectionDelay" ] . bind ( transport ) ;
412
+
413
+ // First retry - should use initial delay
414
+ expect ( getDelay ( 0 ) ) . toBe ( 100 ) ;
415
+
416
+ // Second retry - should double (2^1 * 100 = 200)
417
+ expect ( getDelay ( 1 ) ) . toBe ( 200 ) ;
418
+
419
+ // Third retry - should double again (2^2 * 100 = 400)
420
+ expect ( getDelay ( 2 ) ) . toBe ( 400 ) ;
421
+
422
+ // Fourth retry - should double again (2^3 * 100 = 800)
423
+ expect ( getDelay ( 3 ) ) . toBe ( 800 ) ;
424
+
425
+ // Tenth retry - should be capped at maxReconnectionDelay
426
+ expect ( getDelay ( 10 ) ) . toBe ( 5000 ) ;
427
+ } ) ;
428
+
378
429
} ) ;
0 commit comments