@@ -16,6 +16,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
1616 private static readonly MediaTypeWithQualityHeaderValue s_applicationJsonMediaType = new ( "application/json" ) ;
1717 private static readonly MediaTypeWithQualityHeaderValue s_textEventStreamMediaType = new ( "text/event-stream" ) ;
1818
19+ private static readonly TimeSpan s_defaultReconnectionDelay = TimeSpan . FromSeconds ( 1 ) ;
20+
1921 private readonly McpHttpClient _httpClient ;
2022 private readonly HttpClientTransportOptions _options ;
2123 private readonly CancellationTokenSource _connectionCts ;
@@ -106,7 +108,17 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
106108 else if ( response . Content . Headers . ContentType ? . MediaType == "text/event-stream" )
107109 {
108110 using var responseBodyStream = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
109- rpcResponseOrError = await ProcessSseResponseAsync ( responseBodyStream , rpcRequest , cancellationToken ) . ConfigureAwait ( false ) ;
111+ var sseState = await ProcessSseResponseAsync ( responseBodyStream , rpcRequest , cancellationToken ) . ConfigureAwait ( false ) ;
112+ rpcResponseOrError = sseState . Response ;
113+
114+ // Resumability: If POST SSE stream ended without a response but we have a Last-Event-ID (from priming),
115+ // attempt to resume by sending a GET request with Last-Event-ID header. The server will replay
116+ // events from the event store, allowing us to receive the pending response.
117+ if ( rpcResponseOrError is null && rpcRequest is not null && sseState . LastEventId is not null )
118+ {
119+ var resumeResult = await SendGetSseRequestWithRetriesAsync ( rpcRequest , sseState , cancellationToken ) . ConfigureAwait ( false ) ;
120+ rpcResponseOrError = resumeResult . Response ;
121+ }
110122 }
111123
112124 if ( rpcRequest is null )
@@ -188,54 +200,135 @@ public override async ValueTask DisposeAsync()
188200
189201 private async Task ReceiveUnsolicitedMessagesAsync ( )
190202 {
191- // Send a GET request to handle any unsolicited messages not sent over a POST response.
192- using var request = new HttpRequestMessage ( HttpMethod . Get , _options . Endpoint ) ;
193- request . Headers . Accept . Add ( s_textEventStreamMediaType ) ;
194- CopyAdditionalHeaders ( request . Headers , _options . AdditionalHeaders , SessionId , _negotiatedProtocolVersion ) ;
203+ var state = new SseStreamState ( ) ;
195204
196- // Server support for the GET request is optional. If it fails, we don't care. It just means we won't receive unsolicited messages.
197- HttpResponseMessage response ;
198- try
199- {
200- response = await _httpClient . SendAsync ( request , message : null , _connectionCts . Token ) . ConfigureAwait ( false ) ;
201- }
202- catch ( HttpRequestException )
205+ // Continuously receive unsolicited messages until cancelled
206+ while ( ! _connectionCts . Token . IsCancellationRequested )
203207 {
204- return ;
208+ var result = await SendGetSseRequestWithRetriesAsync (
209+ relatedRpcRequest : null ,
210+ state ,
211+ _connectionCts . Token ) . ConfigureAwait ( false ) ;
212+
213+ // Update state for next reconnection attempt
214+ state . UpdateFrom ( result ) ;
215+
216+ // If we exhausted retries without receiving any events, stop trying
217+ if ( result . LastEventId is null )
218+ {
219+ return ;
220+ }
205221 }
222+ }
223+
224+ /// <summary>
225+ /// Sends a GET request for SSE with retry logic and resumability support.
226+ /// </summary>
227+ private async Task < SseStreamState > SendGetSseRequestWithRetriesAsync (
228+ JsonRpcRequest ? relatedRpcRequest ,
229+ SseStreamState state ,
230+ CancellationToken cancellationToken )
231+ {
232+ int attempt = 0 ;
233+
234+ // Delay before first attempt if we're reconnecting (have a Last-Event-ID)
235+ bool shouldDelay = state . LastEventId is not null ;
206236
207- using ( response )
237+ while ( attempt < _options . MaxReconnectionAttempts )
208238 {
209- if ( ! response . IsSuccessStatusCode )
239+ cancellationToken . ThrowIfCancellationRequested ( ) ;
240+
241+ if ( shouldDelay )
210242 {
211- return ;
243+ var delay = state . RetryInterval ?? s_defaultReconnectionDelay ;
244+ await Task . Delay ( delay , cancellationToken ) . ConfigureAwait ( false ) ;
212245 }
246+ shouldDelay = true ;
247+
248+ using var request = new HttpRequestMessage ( HttpMethod . Get , _options . Endpoint ) ;
249+ request . Headers . Accept . Add ( s_textEventStreamMediaType ) ;
250+ CopyAdditionalHeaders ( request . Headers , _options . AdditionalHeaders , SessionId , _negotiatedProtocolVersion , state . LastEventId ) ;
251+
252+ HttpResponseMessage response ;
253+ try
254+ {
255+ response = await _httpClient . SendAsync ( request , message : null , cancellationToken ) . ConfigureAwait ( false ) ;
256+ }
257+ catch ( HttpRequestException )
258+ {
259+ attempt ++ ;
260+ continue ;
261+ }
262+
263+ using ( response )
264+ {
265+ if ( ! response . IsSuccessStatusCode )
266+ {
267+ attempt ++ ;
268+ continue ;
269+ }
270+
271+ using var responseStream = await response . Content . ReadAsStreamAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
272+ var result = await ProcessSseResponseAsync ( responseStream , relatedRpcRequest , cancellationToken ) . ConfigureAwait ( false ) ;
273+
274+ state . UpdateFrom ( result ) ;
275+
276+ if ( result . Response is not null )
277+ {
278+ return state ;
279+ }
213280
214- using var responseStream = await response . Content . ReadAsStreamAsync ( _connectionCts . Token ) . ConfigureAwait ( false ) ;
215- await ProcessSseResponseAsync ( responseStream , relatedRpcRequest : null , _connectionCts . Token ) . ConfigureAwait ( false ) ;
281+ // Stream closed without the response
282+ if ( state . LastEventId is null )
283+ {
284+ // No event ID means server may not support resumability - don't retry indefinitely
285+ attempt ++ ;
286+ }
287+ else
288+ {
289+ // We have an event ID, so reconnection should work - reset attempts
290+ attempt = 0 ;
291+ }
292+ }
216293 }
294+
295+ return state ;
217296 }
218297
219- private async Task < JsonRpcMessageWithId ? > ProcessSseResponseAsync ( Stream responseStream , JsonRpcRequest ? relatedRpcRequest , CancellationToken cancellationToken )
298+ private async Task < SseStreamState > ProcessSseResponseAsync (
299+ Stream responseStream ,
300+ JsonRpcRequest ? relatedRpcRequest ,
301+ CancellationToken cancellationToken )
220302 {
303+ var state = new SseStreamState ( ) ;
304+
221305 await foreach ( SseItem < string > sseEvent in SseParser . Create ( responseStream ) . EnumerateAsync ( cancellationToken ) . ConfigureAwait ( false ) )
222306 {
223- if ( sseEvent . EventType != "message" )
307+ // Track event ID and retry interval for resumability
308+ if ( ! string . IsNullOrEmpty ( sseEvent . EventId ) )
309+ {
310+ state . LastEventId = sseEvent . EventId ;
311+ }
312+ if ( sseEvent . ReconnectionInterval . HasValue )
313+ {
314+ state . RetryInterval = sseEvent . ReconnectionInterval . Value ;
315+ }
316+
317+ // Skip events with empty data (priming events, keep-alives)
318+ if ( string . IsNullOrEmpty ( sseEvent . Data ) || sseEvent . EventType != "message" )
224319 {
225320 continue ;
226321 }
227322
228323 var rpcResponseOrError = await ProcessMessageAsync ( sseEvent . Data , relatedRpcRequest , cancellationToken ) . ConfigureAwait ( false ) ;
229-
230- // The server SHOULD end the HTTP response body here anyway, but we won't leave it to chance. This transport makes
231- // a GET request for any notifications that might need to be sent after the completion of each POST.
232324 if ( rpcResponseOrError is not null )
233325 {
234- return rpcResponseOrError ;
326+ state . Response = rpcResponseOrError ;
327+ return state ;
235328 }
236329 }
237330
238- return null ;
331+ return state ;
239332 }
240333
241334 private async Task < JsonRpcMessageWithId ? > ProcessMessageAsync ( string data , JsonRpcRequest ? relatedRpcRequest , CancellationToken cancellationToken )
@@ -292,7 +385,8 @@ internal static void CopyAdditionalHeaders(
292385 HttpRequestHeaders headers ,
293386 IDictionary < string , string > ? additionalHeaders ,
294387 string ? sessionId ,
295- string ? protocolVersion )
388+ string ? protocolVersion ,
389+ string ? lastEventId = null )
296390 {
297391 if ( sessionId is not null )
298392 {
@@ -304,6 +398,11 @@ internal static void CopyAdditionalHeaders(
304398 headers . Add ( "MCP-Protocol-Version" , protocolVersion ) ;
305399 }
306400
401+ if ( lastEventId is not null )
402+ {
403+ headers . Add ( "Last-Event-ID" , lastEventId ) ;
404+ }
405+
307406 if ( additionalHeaders is null )
308407 {
309408 return ;
@@ -317,4 +416,21 @@ internal static void CopyAdditionalHeaders(
317416 }
318417 }
319418 }
419+
420+ /// <summary>
421+ /// Tracks state across SSE stream connections.
422+ /// </summary>
423+ private struct SseStreamState
424+ {
425+ public JsonRpcMessageWithId ? Response ;
426+ public string ? LastEventId ;
427+ public TimeSpan ? RetryInterval ;
428+
429+ public void UpdateFrom ( SseStreamState other )
430+ {
431+ Response ??= other . Response ;
432+ LastEventId ??= other . LastEventId ;
433+ RetryInterval ??= other . RetryInterval ;
434+ }
435+ }
320436}
0 commit comments