1
+ import { StreamableHTTPClientTransport } from "./streamableHttp.js" ;
2
+ import { JSONRPCMessage } from "../types.js" ;
3
+
4
+
5
+ describe ( "StreamableHTTPClientTransport" , ( ) => {
6
+ let transport : StreamableHTTPClientTransport ;
7
+
8
+ beforeEach ( ( ) => {
9
+ transport = new StreamableHTTPClientTransport ( new URL ( "http://localhost:1234/mcp" ) ) ;
10
+ jest . spyOn ( global , "fetch" ) ;
11
+ } ) ;
12
+
13
+ afterEach ( async ( ) => {
14
+ await transport . close ( ) . catch ( ( ) => { } ) ;
15
+ jest . clearAllMocks ( ) ;
16
+ } ) ;
17
+
18
+ it ( "should send JSON-RPC messages via POST" , async ( ) => {
19
+ const message : JSONRPCMessage = {
20
+ jsonrpc : "2.0" ,
21
+ method : "test" ,
22
+ params : { } ,
23
+ id : "test-id"
24
+ } ;
25
+
26
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
27
+ ok : true ,
28
+ status : 202 ,
29
+ headers : new Headers ( ) ,
30
+ } ) ;
31
+
32
+ await transport . send ( message ) ;
33
+
34
+ expect ( global . fetch ) . toHaveBeenCalledWith (
35
+ expect . anything ( ) ,
36
+ expect . objectContaining ( {
37
+ method : "POST" ,
38
+ headers : expect . any ( Headers ) ,
39
+ body : JSON . stringify ( message )
40
+ } )
41
+ ) ;
42
+ } ) ;
43
+
44
+ it ( "should send batch messages" , async ( ) => {
45
+ const messages : JSONRPCMessage [ ] = [
46
+ { jsonrpc : "2.0" , method : "test1" , params : { } , id : "id1" } ,
47
+ { jsonrpc : "2.0" , method : "test2" , params : { } , id : "id2" }
48
+ ] ;
49
+
50
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
51
+ ok : true ,
52
+ status : 200 ,
53
+ headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
54
+ body : null
55
+ } ) ;
56
+
57
+ await transport . send ( messages ) ;
58
+
59
+ expect ( global . fetch ) . toHaveBeenCalledWith (
60
+ expect . anything ( ) ,
61
+ expect . objectContaining ( {
62
+ method : "POST" ,
63
+ headers : expect . any ( Headers ) ,
64
+ body : JSON . stringify ( messages )
65
+ } )
66
+ ) ;
67
+ } ) ;
68
+
69
+ it ( "should store session ID received during initialization" , async ( ) => {
70
+ const message : JSONRPCMessage = {
71
+ jsonrpc : "2.0" ,
72
+ method : "initialize" ,
73
+ params : {
74
+ clientInfo : { name : "test-client" , version : "1.0" } ,
75
+ protocolVersion : "2025-03-26"
76
+ } ,
77
+ id : "init-id"
78
+ } ;
79
+
80
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
81
+ ok : true ,
82
+ status : 200 ,
83
+ headers : new Headers ( { "mcp-session-id" : "test-session-id" } ) ,
84
+ } ) ;
85
+
86
+ await transport . send ( message ) ;
87
+
88
+ // Send a second message that should include the session ID
89
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
90
+ ok : true ,
91
+ status : 202 ,
92
+ headers : new Headers ( )
93
+ } ) ;
94
+
95
+ await transport . send ( { jsonrpc : "2.0" , method : "test" , params : { } } as JSONRPCMessage ) ;
96
+
97
+ // Check that second request included session ID header
98
+ const calls = ( global . fetch as jest . Mock ) . mock . calls ;
99
+ const lastCall = calls [ calls . length - 1 ] ;
100
+ expect ( lastCall [ 1 ] . headers ) . toBeDefined ( ) ;
101
+ expect ( lastCall [ 1 ] . headers . get ( "mcp-session-id" ) ) . toBe ( "test-session-id" ) ;
102
+ } ) ;
103
+
104
+ it ( "should handle 404 response when session expires" , async ( ) => {
105
+ const message : JSONRPCMessage = {
106
+ jsonrpc : "2.0" ,
107
+ method : "test" ,
108
+ params : { } ,
109
+ id : "test-id"
110
+ } ;
111
+
112
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
113
+ ok : false ,
114
+ status : 404 ,
115
+ statusText : "Not Found" ,
116
+ text : ( ) => Promise . resolve ( "Session not found" ) ,
117
+ headers : new Headers ( )
118
+ } ) ;
119
+
120
+ const errorSpy = jest . fn ( ) ;
121
+ transport . onerror = errorSpy ;
122
+
123
+ await expect ( transport . send ( message ) ) . rejects . toThrow ( "Error POSTing to endpoint (HTTP 404)" ) ;
124
+ expect ( errorSpy ) . toHaveBeenCalled ( ) ;
125
+ } ) ;
126
+
127
+ it ( "should handle non-streaming JSON response" , async ( ) => {
128
+ const message : JSONRPCMessage = {
129
+ jsonrpc : "2.0" ,
130
+ method : "test" ,
131
+ params : { } ,
132
+ id : "test-id"
133
+ } ;
134
+
135
+ const responseMessage : JSONRPCMessage = {
136
+ jsonrpc : "2.0" ,
137
+ result : { success : true } ,
138
+ id : "test-id"
139
+ } ;
140
+
141
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
142
+ ok : true ,
143
+ status : 200 ,
144
+ headers : new Headers ( { "content-type" : "application/json" } ) ,
145
+ json : ( ) => Promise . resolve ( responseMessage )
146
+ } ) ;
147
+
148
+ const messageSpy = jest . fn ( ) ;
149
+ transport . onmessage = messageSpy ;
150
+
151
+ await transport . send ( message ) ;
152
+
153
+ expect ( messageSpy ) . toHaveBeenCalledWith ( responseMessage ) ;
154
+ } ) ;
155
+
156
+ it ( "should attempt initial GET connection and handle 405 gracefully" , async ( ) => {
157
+ // Mock the server not supporting GET for SSE (returning 405)
158
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
159
+ ok : false ,
160
+ status : 405 ,
161
+ statusText : "Method Not Allowed"
162
+ } ) ;
163
+
164
+ // We expect the 405 error to be caught and handled gracefully
165
+ // This should not throw an error that breaks the transport
166
+ await transport . start ( ) ;
167
+ await expect ( transport . openSseStream ( ) ) . rejects . toThrow ( 'Failed to open SSE stream: Method Not Allowed' ) ;
168
+
169
+ // Check that GET was attempted
170
+ expect ( global . fetch ) . toHaveBeenCalledWith (
171
+ expect . anything ( ) ,
172
+ expect . objectContaining ( {
173
+ method : "GET" ,
174
+ headers : expect . any ( Headers )
175
+ } )
176
+ ) ;
177
+
178
+ // Verify transport still works after 405
179
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
180
+ ok : true ,
181
+ status : 202 ,
182
+ headers : new Headers ( )
183
+ } ) ;
184
+
185
+ await transport . send ( { jsonrpc : "2.0" , method : "test" , params : { } } as JSONRPCMessage ) ;
186
+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 ) ;
187
+ } ) ;
188
+
189
+ it ( "should handle successful initial GET connection for SSE" , async ( ) => {
190
+ // Set up readable stream for SSE events
191
+ const encoder = new TextEncoder ( ) ;
192
+ const stream = new ReadableStream ( {
193
+ start ( controller ) {
194
+ // Send a server notification via SSE
195
+ const event = 'event: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n' ;
196
+ controller . enqueue ( encoder . encode ( event ) ) ;
197
+ }
198
+ } ) ;
199
+
200
+ // Mock successful GET connection
201
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
202
+ ok : true ,
203
+ status : 200 ,
204
+ headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
205
+ body : stream
206
+ } ) ;
207
+
208
+ const messageSpy = jest . fn ( ) ;
209
+ transport . onmessage = messageSpy ;
210
+
211
+ await transport . start ( ) ;
212
+ await transport . openSseStream ( ) ;
213
+
214
+ // Give time for the SSE event to be processed
215
+ await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
216
+
217
+ expect ( messageSpy ) . toHaveBeenCalledWith (
218
+ expect . objectContaining ( {
219
+ jsonrpc : "2.0" ,
220
+ method : "serverNotification" ,
221
+ params : { }
222
+ } )
223
+ ) ;
224
+ } ) ;
225
+
226
+ it ( "should handle multiple concurrent SSE streams" , async ( ) => {
227
+ // Mock two POST requests that return SSE streams
228
+ const makeStream = ( id : string ) => {
229
+ const encoder = new TextEncoder ( ) ;
230
+ return new ReadableStream ( {
231
+ start ( controller ) {
232
+ const event = `event: message\ndata: {"jsonrpc": "2.0", "result": {"id": "${ id } "}, "id": "${ id } "}\n\n` ;
233
+ controller . enqueue ( encoder . encode ( event ) ) ;
234
+ }
235
+ } ) ;
236
+ } ;
237
+
238
+ ( global . fetch as jest . Mock )
239
+ . mockResolvedValueOnce ( {
240
+ ok : true ,
241
+ status : 200 ,
242
+ headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
243
+ body : makeStream ( "request1" )
244
+ } )
245
+ . mockResolvedValueOnce ( {
246
+ ok : true ,
247
+ status : 200 ,
248
+ headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
249
+ body : makeStream ( "request2" )
250
+ } ) ;
251
+
252
+ const messageSpy = jest . fn ( ) ;
253
+ transport . onmessage = messageSpy ;
254
+
255
+ // Send two concurrent requests
256
+ await Promise . all ( [
257
+ transport . send ( { jsonrpc : "2.0" , method : "test1" , params : { } , id : "request1" } ) ,
258
+ transport . send ( { jsonrpc : "2.0" , method : "test2" , params : { } , id : "request2" } )
259
+ ] ) ;
260
+
261
+ // Give time for SSE processing
262
+ await new Promise ( resolve => setTimeout ( resolve , 100 ) ) ;
263
+
264
+ // Both streams should have delivered their messages
265
+ expect ( messageSpy ) . toHaveBeenCalledTimes ( 2 ) ;
266
+
267
+ // Verify received messages without assuming specific order
268
+ expect ( messageSpy . mock . calls . some ( call => {
269
+ const msg = call [ 0 ] ;
270
+ return msg . id === "request1" && msg . result ?. id === "request1" ;
271
+ } ) ) . toBe ( true ) ;
272
+
273
+ expect ( messageSpy . mock . calls . some ( call => {
274
+ const msg = call [ 0 ] ;
275
+ return msg . id === "request2" && msg . result ?. id === "request2" ;
276
+ } ) ) . toBe ( true ) ;
277
+ } ) ;
278
+
279
+ it ( "should include last-event-id header when resuming a broken connection" , async ( ) => {
280
+ // First make a successful connection that provides an event ID
281
+ const encoder = new TextEncoder ( ) ;
282
+ const stream = new ReadableStream ( {
283
+ start ( controller ) {
284
+ const event = 'id: event-123\nevent: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n' ;
285
+ controller . enqueue ( encoder . encode ( event ) ) ;
286
+ controller . close ( ) ;
287
+ }
288
+ } ) ;
289
+
290
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
291
+ ok : true ,
292
+ status : 200 ,
293
+ headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
294
+ body : stream
295
+ } ) ;
296
+
297
+ await transport . start ( ) ;
298
+ await transport . openSseStream ( ) ;
299
+ await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
300
+
301
+ // Now simulate attempting to reconnect
302
+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
303
+ ok : true ,
304
+ status : 200 ,
305
+ headers : new Headers ( { "content-type" : "text/event-stream" } ) ,
306
+ body : null
307
+ } ) ;
308
+
309
+ await transport . openSseStream ( ) ;
310
+
311
+ // Check that Last-Event-ID was included
312
+ const calls = ( global . fetch as jest . Mock ) . mock . calls ;
313
+ const lastCall = calls [ calls . length - 1 ] ;
314
+ expect ( lastCall [ 1 ] . headers . get ( "last-event-id" ) ) . toBe ( "event-123" ) ;
315
+ } ) ;
316
+ } ) ;
0 commit comments