1
- import { StreamableHTTPClientTransport } from "./streamableHttp.js" ;
1
+ import { StreamableHTTPClientTransport , StreamableHTTPReconnectionOptions } from "./streamableHttp.js" ;
2
2
import { JSONRPCMessage } from "../types.js" ;
3
3
4
4
@@ -101,6 +101,77 @@ describe("StreamableHTTPClientTransport", () => {
101
101
expect ( lastCall [ 1 ] . headers . get ( "mcp-session-id" ) ) . toBe ( "test-session-id" ) ;
102
102
} ) ;
103
103
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
+
104
175
it ( "should handle 404 response when session expires" , async ( ) => {
105
176
const message : JSONRPCMessage = {
106
177
jsonrpc : "2.0" ,
@@ -164,7 +235,7 @@ describe("StreamableHTTPClientTransport", () => {
164
235
// We expect the 405 error to be caught and handled gracefully
165
236
// This should not throw an error that breaks the transport
166
237
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" ) ;
168
239
// Check that GET was attempted
169
240
expect ( global . fetch ) . toHaveBeenCalledWith (
170
241
expect . anything ( ) ,
@@ -208,7 +279,7 @@ describe("StreamableHTTPClientTransport", () => {
208
279
transport . onmessage = messageSpy ;
209
280
210
281
await transport . start ( ) ;
211
- await transport [ "_startOrAuthStandaloneSSE " ] ( ) ;
282
+ await transport [ "_startOrAuthSse " ] ( { } ) ;
212
283
213
284
// Give time for the SSE event to be processed
214
285
await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
@@ -275,45 +346,62 @@ describe("StreamableHTTPClientTransport", () => {
275
346
} ) ) . toBe ( true ) ;
276
347
} ) ;
277
348
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 ,
286
357
}
287
358
} ) ;
288
359
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
+ } ) ;
295
368
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" ) ) ;
299
372
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 ( {
302
377
ok : true ,
303
378
status : 200 ,
304
379
headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
305
- body : null
380
+ body : new ReadableStream ( )
306
381
} ) ;
307
382
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" } ) ;
309
390
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" ) ;
314
396
} ) ;
315
397
316
398
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
+
317
405
const message : JSONRPCMessage = {
318
406
jsonrpc : "2.0" ,
319
407
method : "test" ,
@@ -323,7 +411,7 @@ describe("StreamableHTTPClientTransport", () => {
323
411
324
412
const stream = new ReadableStream ( {
325
413
start ( controller ) {
326
- controller . enqueue ( "invalid text response" ) ;
414
+ controller . enqueue ( new TextEncoder ( ) . encode ( "invalid text response" ) ) ;
327
415
controller . close ( ) ;
328
416
}
329
417
} ) ;
@@ -365,7 +453,7 @@ describe("StreamableHTTPClientTransport", () => {
365
453
366
454
await transport . start ( ) ;
367
455
368
- await transport [ "_startOrAuthStandaloneSSE " ] ( ) ;
456
+ await transport [ "_startOrAuthSse " ] ( { } ) ;
369
457
expect ( ( actualReqInit . headers as Headers ) . get ( "x-custom-header" ) ) . toBe ( "CustomValue" ) ;
370
458
371
459
requestInit . headers [ "X-Custom-Header" ] = "SecondCustomValue" ;
@@ -375,4 +463,38 @@ describe("StreamableHTTPClientTransport", () => {
375
463
376
464
expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 ) ;
377
465
} ) ;
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
+
378
500
} ) ;
0 commit comments