1
1
import { Transport } from "../shared/transport.js" ;
2
- import { isJSONRPCNotification , JSONRPCMessage , JSONRPCMessageSchema } from "../types.js" ;
2
+ import { isJSONRPCNotification , isJSONRPCRequest , isJSONRPCResponse , JSONRPCMessage , JSONRPCMessageSchema } from "../types.js" ;
3
3
import { auth , AuthResult , OAuthClientProvider , UnauthorizedError } from "./auth.js" ;
4
4
import { EventSourceParserStream } from "eventsource-parser/stream" ;
5
5
@@ -23,11 +23,26 @@ export class StreamableHTTPError extends Error {
23
23
/**
24
24
* Options for starting or authenticating an SSE connection
25
25
*/
26
- export interface StartSSEOptions {
26
+ interface StartSSEOptions {
27
27
/**
28
- * The ID of the last received event, used for resuming a disconnected stream
28
+ * The resumption token used to continue long-running requests that were interrupted.
29
+ *
30
+ * This allows clients to reconnect and continue from where they left off.
31
+ */
32
+ resumptionToken ?: string ;
33
+
34
+ /**
35
+ * A callback that is invoked when the resumption token changes.
36
+ *
37
+ * This allows clients to persist the latest token for potential reconnection.
29
38
*/
30
- lastEventId ?: string ;
39
+ onresumptiontoken ?: ( token : string ) => void ;
40
+
41
+ /**
42
+ * Override Message ID to associate with the replay message
43
+ * so that response can be associate with the new resumed request.
44
+ */
45
+ replayMessageId ?: string | number ;
31
46
}
32
47
33
48
/**
@@ -88,6 +103,12 @@ export type StreamableHTTPClientTransportOptions = {
88
103
* Options to configure the reconnection behavior.
89
104
*/
90
105
reconnectionOptions ?: StreamableHTTPReconnectionOptions ;
106
+
107
+ /**
108
+ * Session ID for the connection. This is used to identify the session on the server.
109
+ * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID.
110
+ */
111
+ sessionId ?: string ;
91
112
} ;
92
113
93
114
/**
@@ -114,6 +135,7 @@ export class StreamableHTTPClientTransport implements Transport {
114
135
this . _url = url ;
115
136
this . _requestInit = opts ?. requestInit ;
116
137
this . _authProvider = opts ?. authProvider ;
138
+ this . _sessionId = opts ?. sessionId ;
117
139
this . _reconnectionOptions = opts ?. reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS ;
118
140
}
119
141
@@ -134,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport {
134
156
throw new UnauthorizedError ( ) ;
135
157
}
136
158
137
- return await this . _startOrAuthStandaloneSSE ( { lastEventId : undefined } ) ;
159
+ return await this . _startOrAuthSse ( { resumptionToken : undefined } ) ;
138
160
}
139
161
140
162
private async _commonHeaders ( ) : Promise < Headers > {
@@ -156,17 +178,17 @@ export class StreamableHTTPClientTransport implements Transport {
156
178
}
157
179
158
180
159
- private async _startOrAuthStandaloneSSE ( options : StartSSEOptions ) : Promise < void > {
160
- const { lastEventId } = options ;
181
+ private async _startOrAuthSse ( options : StartSSEOptions ) : Promise < void > {
182
+ const { resumptionToken } = options ;
161
183
try {
162
184
// Try to open an initial SSE stream with GET to listen for server messages
163
185
// This is optional according to the spec - server may not support it
164
186
const headers = await this . _commonHeaders ( ) ;
165
187
headers . set ( "Accept" , "text/event-stream" ) ;
166
188
167
189
// Include Last-Event-ID header for resumable streams if provided
168
- if ( lastEventId ) {
169
- headers . set ( "last-event-id" , lastEventId ) ;
190
+ if ( resumptionToken ) {
191
+ headers . set ( "last-event-id" , resumptionToken ) ;
170
192
}
171
193
172
194
const response = await fetch ( this . _url , {
@@ -193,7 +215,7 @@ export class StreamableHTTPClientTransport implements Transport {
193
215
) ;
194
216
}
195
217
196
- this . _handleSseStream ( response . body ) ;
218
+ this . _handleSseStream ( response . body , options ) ;
197
219
} catch ( error ) {
198
220
this . onerror ?.( error as Error ) ;
199
221
throw error ;
@@ -224,7 +246,7 @@ export class StreamableHTTPClientTransport implements Transport {
224
246
* @param lastEventId The ID of the last received event for resumability
225
247
* @param attemptCount Current reconnection attempt count for this specific stream
226
248
*/
227
- private _scheduleReconnection ( lastEventId : string , attemptCount = 0 ) : void {
249
+ private _scheduleReconnection ( options : StartSSEOptions , attemptCount = 0 ) : void {
228
250
// Use provided options or default options
229
251
const maxRetries = this . _reconnectionOptions . maxRetries ;
230
252
@@ -240,18 +262,19 @@ export class StreamableHTTPClientTransport implements Transport {
240
262
// Schedule the reconnection
241
263
setTimeout ( ( ) => {
242
264
// Use the last event ID to resume where we left off
243
- this . _startOrAuthStandaloneSSE ( { lastEventId } ) . catch ( error => {
265
+ this . _startOrAuthSse ( options ) . catch ( error => {
244
266
this . onerror ?.( new Error ( `Failed to reconnect SSE stream: ${ error instanceof Error ? error . message : String ( error ) } ` ) ) ;
245
267
// Schedule another attempt if this one failed, incrementing the attempt counter
246
- this . _scheduleReconnection ( lastEventId , attemptCount + 1 ) ;
268
+ this . _scheduleReconnection ( options , attemptCount + 1 ) ;
247
269
} ) ;
248
270
} , delay ) ;
249
271
}
250
272
251
- private _handleSseStream ( stream : ReadableStream < Uint8Array > | null ) : void {
273
+ private _handleSseStream ( stream : ReadableStream < Uint8Array > | null , options : StartSSEOptions ) : void {
252
274
if ( ! stream ) {
253
275
return ;
254
276
}
277
+ const { onresumptiontoken, replayMessageId } = options ;
255
278
256
279
let lastEventId : string | undefined ;
257
280
const processStream = async ( ) => {
@@ -274,11 +297,15 @@ export class StreamableHTTPClientTransport implements Transport {
274
297
// Update last event ID if provided
275
298
if ( event . id ) {
276
299
lastEventId = event . id ;
300
+ onresumptiontoken ?.( event . id ) ;
277
301
}
278
302
279
303
if ( ! event . event || event . event === "message" ) {
280
304
try {
281
305
const message = JSONRPCMessageSchema . parse ( JSON . parse ( event . data ) ) ;
306
+ if ( replayMessageId !== undefined && isJSONRPCResponse ( message ) ) {
307
+ message . id = replayMessageId ;
308
+ }
282
309
this . onmessage ?.( message ) ;
283
310
} catch ( error ) {
284
311
this . onerror ?.( error as Error ) ;
@@ -294,7 +321,11 @@ export class StreamableHTTPClientTransport implements Transport {
294
321
// Use the exponential backoff reconnection strategy
295
322
if ( lastEventId !== undefined ) {
296
323
try {
297
- this . _scheduleReconnection ( lastEventId , 0 ) ;
324
+ this . _scheduleReconnection ( {
325
+ resumptionToken : lastEventId ,
326
+ onresumptiontoken,
327
+ replayMessageId
328
+ } , 0 ) ;
298
329
}
299
330
catch ( error ) {
300
331
this . onerror ?.( new Error ( `Failed to reconnect: ${ error instanceof Error ? error . message : String ( error ) } ` ) ) ;
@@ -338,8 +369,16 @@ export class StreamableHTTPClientTransport implements Transport {
338
369
this . onclose ?.( ) ;
339
370
}
340
371
341
- async send ( message : JSONRPCMessage | JSONRPCMessage [ ] ) : Promise < void > {
372
+ async send ( message : JSONRPCMessage | JSONRPCMessage [ ] , options ?: { resumptionToken ?: string , onresumptiontoken ?: ( token : string ) => void } ) : Promise < void > {
342
373
try {
374
+ const { resumptionToken, onresumptiontoken } = options || { } ;
375
+
376
+ if ( resumptionToken ) {
377
+ // If we have at last event ID, we need to reconnect the SSE stream
378
+ this . _startOrAuthSse ( { resumptionToken, replayMessageId : isJSONRPCRequest ( message ) ? message . id : undefined } ) . catch ( err => this . onerror ?.( err ) ) ;
379
+ return ;
380
+ }
381
+
343
382
const headers = await this . _commonHeaders ( ) ;
344
383
headers . set ( "content-type" , "application/json" ) ;
345
384
headers . set ( "accept" , "application/json, text/event-stream" ) ;
@@ -383,7 +422,7 @@ export class StreamableHTTPClientTransport implements Transport {
383
422
// if it's supported by the server
384
423
if ( isJSONRPCNotification ( message ) && message . method === "notifications/initialized" ) {
385
424
// Start without a lastEventId since this is a fresh connection
386
- this . _startOrAuthStandaloneSSE ( { lastEventId : undefined } ) . catch ( err => this . onerror ?.( err ) ) ;
425
+ this . _startOrAuthSse ( { resumptionToken : undefined } ) . catch ( err => this . onerror ?.( err ) ) ;
387
426
}
388
427
return ;
389
428
}
@@ -398,7 +437,10 @@ export class StreamableHTTPClientTransport implements Transport {
398
437
399
438
if ( hasRequests ) {
400
439
if ( contentType ?. includes ( "text/event-stream" ) ) {
401
- this . _handleSseStream ( response . body ) ;
440
+ // Handle SSE stream responses for requests
441
+ // We use the same handler as standalone streams, which now supports
442
+ // reconnection with the last event ID
443
+ this . _handleSseStream ( response . body , { onresumptiontoken } ) ;
402
444
} else if ( contentType ?. includes ( "application/json" ) ) {
403
445
// For non-streaming servers, we might get direct JSON responses
404
446
const data = await response . json ( ) ;
@@ -421,4 +463,8 @@ export class StreamableHTTPClientTransport implements Transport {
421
463
throw error ;
422
464
}
423
465
}
466
+
467
+ get sessionId ( ) : string | undefined {
468
+ return this . _sessionId ;
469
+ }
424
470
}
0 commit comments