Streamable HTTP resumability + redelivery + SSE polling via server-side disconnect (#1077)

Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Mackinnon Buck 2026-01-14 22:54:25 -08:00 • committed by GitHub
commit b8311756b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2675 additions and 246 deletions

View file

@ -108,4 +108,31 @@ public sealed class HttpClientTransportOptions
/// Gets sor sets the authorization provider to use for authentication.
/// </summary>
public ClientOAuthOptions? OAuth { get; set; }
/// <summary>
/// Gets or sets the maximum number of consecutive reconnection attempts when an SSE stream is disconnected.
/// </summary>
/// <value>
/// The maximum number of reconnection attempts. The default is 2.
/// </value>
/// <remarks>
/// When an SSE stream is disconnected (e.g., due to a network issue), the client will attempt to
/// reconnect using the Last-Event-ID header to resume from where it left off. This property controls
/// how many reconnection attempts are made before giving up.
/// </remarks>
public int MaxReconnectionAttempts { get; set; } = 2;
/// <summary>
/// Gets or sets the default interval at which the client attempts reconnection after an SSE stream is disconnected.
/// </summary>
/// <remarks>
/// <p>
/// The default value is 1 second.
/// </p>
/// <p>
/// If the server sends a message specifying a different reconnection interval, that new value will be used for all
/// subsequent reconnection attempts for that stream.
/// </p>
/// </remarks>
public TimeSpan DefaultReconnectionInterval { get; set; } = TimeSpan.FromSeconds(1);
}

View file

@ -19,7 +19,7 @@ internal sealed class StdioClientSessionTransport(
{
try
{
await base.SendMessageAsync(message, cancellationToken);
await base.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
catch (IOException)
{
@ -58,7 +58,7 @@ internal sealed class StdioClientSessionTransport(
}
// And handle cleanup in the base type.
await base.CleanupAsync(error, cancellationToken);
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
}
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)

View file

@ -5,6 +5,7 @@ using System.Net.ServerSentEvents;
using System.Text.Json;
using ModelContextProtocol.Protocol;
using System.Threading.Channels;
using System.Net;
namespace ModelContextProtocol.Client;
@ -105,8 +106,18 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}
else if (response.Content.Headers.ContentType?.MediaType == "text/event-stream")
{
using var responseBodyStream = await response.Content.ReadAsStreamAsync(cancellationToken);
rpcResponseOrError = await ProcessSseResponseAsync(responseBodyStream, rpcRequest, cancellationToken).ConfigureAwait(false);
var sseState = new SseStreamState();
using var responseBodyStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var sseResponse = await ProcessSseResponseAsync(responseBodyStream, rpcRequest, sseState, cancellationToken).ConfigureAwait(false);
rpcResponseOrError = sseResponse.Response;
// Resumability: If POST SSE stream ended without a response but we have a Last-Event-ID (from priming),
// attempt to resume by sending a GET request with Last-Event-ID header. The server will replay
// events from the event store, allowing us to receive the pending response.
if (rpcResponseOrError is null && rpcRequest is not null && sseState.LastEventId is not null)
{
rpcResponseOrError = await SendGetSseRequestWithRetriesAsync(rpcRequest, sseState, cancellationToken).ConfigureAwait(false);
}
}
if (rpcRequest is null)
@ -155,7 +166,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
// Send DELETE request to terminate the session. Only send if we have a session ID, per MCP spec.
if (_options.OwnsSession && !string.IsNullOrEmpty(SessionId))
{
await SendDeleteRequest();
await SendDeleteRequest().ConfigureAwait(false);
}
if (_getReceiveTask != null)
@ -188,56 +199,147 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
private async Task ReceiveUnsolicitedMessagesAsync()
{
// Send a GET request to handle any unsolicited messages not sent over a POST response.
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
request.Headers.Accept.Add(s_textEventStreamMediaType);
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
var state = new SseStreamState();
// Server support for the GET request is optional. If it fails, we don't care. It just means we won't receive unsolicited messages.
HttpResponseMessage response;
try
// Continuously receive unsolicited messages until canceled
while (!_connectionCts.Token.IsCancellationRequested)
{
response = await _httpClient.SendAsync(request, message: null, _connectionCts.Token).ConfigureAwait(false);
}
catch (HttpRequestException)
{
return;
}
await SendGetSseRequestWithRetriesAsync(
relatedRpcRequest: null,
state,
_connectionCts.Token).ConfigureAwait(false);
using (response)
{
if (!response.IsSuccessStatusCode)
// If we exhausted retries without receiving any events, stop trying
if (state.LastEventId is null)
{
return;
}
using var responseStream = await response.Content.ReadAsStreamAsync(_connectionCts.Token).ConfigureAwait(false);
await ProcessSseResponseAsync(responseStream, relatedRpcRequest: null, _connectionCts.Token).ConfigureAwait(false);
}
}
private async Task<JsonRpcMessageWithId?> ProcessSseResponseAsync(Stream responseStream, JsonRpcRequest? relatedRpcRequest, CancellationToken cancellationToken)
/// <summary>
/// Sends a GET request for SSE with retry logic and resumability support.
/// </summary>
private async Task<JsonRpcMessageWithId?> SendGetSseRequestWithRetriesAsync(
JsonRpcRequest? relatedRpcRequest,
SseStreamState state,
CancellationToken cancellationToken)
{
await foreach (SseItem<string> sseEvent in SseParser.Create(responseStream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
int attempt = 0;
// Delay before first attempt if we're reconnecting (have a Last-Event-ID)
bool shouldDelay = state.LastEventId is not null;
while (attempt < _options.MaxReconnectionAttempts)
{
if (sseEvent.EventType != "message")
cancellationToken.ThrowIfCancellationRequested();
if (shouldDelay)
{
var delay = state.RetryInterval ?? _options.DefaultReconnectionInterval;
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
shouldDelay = true;
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
request.Headers.Accept.Add(s_textEventStreamMediaType);
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion, state.LastEventId);
HttpResponseMessage response;
try
{
response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException)
{
attempt++;
continue;
}
var rpcResponseOrError = await ProcessMessageAsync(sseEvent.Data, relatedRpcRequest, cancellationToken).ConfigureAwait(false);
// The server SHOULD end the HTTP response body here anyway, but we won't leave it to chance. This transport makes
// a GET request for any notifications that might need to be sent after the completion of each POST.
if (rpcResponseOrError is not null)
using (response)
{
return rpcResponseOrError;
if (response.StatusCode >= HttpStatusCode.InternalServerError)
{
// Server error; retry.
attempt++;
continue;
}
if (!response.IsSuccessStatusCode)
{
// If the server could be reached but returned a non-success status code,
// retrying likely won't change that.
return null;
}
using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var sseResponse = await ProcessSseResponseAsync(responseStream, relatedRpcRequest, state, cancellationToken).ConfigureAwait(false);
if (sseResponse.Response is { } rpcResponseOrError)
{
return rpcResponseOrError;
}
// If we reach here, then the stream closed without the response.
if (sseResponse.IsNetworkError || state.LastEventId is null)
{
// No event ID means server may not support resumability; don't retry indefinitely.
attempt++;
}
else
{
// We have an event ID, so we continue polling to receive more events.
// The server should eventually send a response or return an error.
attempt = 0;
}
}
}
return null;
}
private async Task<SseResponse> ProcessSseResponseAsync(
Stream responseStream,
JsonRpcRequest? relatedRpcRequest,
SseStreamState state,
CancellationToken cancellationToken)
{
try
{
await foreach (SseItem<string> sseEvent in SseParser.Create(responseStream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
{
// Track event ID and retry interval for resumability
if (!string.IsNullOrEmpty(sseEvent.EventId))
{
state.LastEventId = sseEvent.EventId;
}
if (sseEvent.ReconnectionInterval.HasValue)
{
state.RetryInterval = sseEvent.ReconnectionInterval.Value;
}
// Skip events with empty data
if (string.IsNullOrEmpty(sseEvent.Data))
{
continue;
}
var rpcResponseOrError = await ProcessMessageAsync(sseEvent.Data, relatedRpcRequest, cancellationToken).ConfigureAwait(false);
if (rpcResponseOrError is not null)
{
return new() { Response = rpcResponseOrError };
}
}
}
catch (Exception ex) when (ex is IOException or HttpRequestException)
{
return new() { IsNetworkError = true };
}
return default;
}
private async Task<JsonRpcMessageWithId?> ProcessMessageAsync(string data, JsonRpcRequest? relatedRpcRequest, CancellationToken cancellationToken)
{
LogTransportReceivedMessageSensitive(Name, data);
@ -292,7 +394,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
HttpRequestHeaders headers,
IDictionary<string, string>? additionalHeaders,
string? sessionId,
string? protocolVersion)
string? protocolVersion,
string? lastEventId = null)
{
if (sessionId is not null)
{
@ -304,6 +407,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
headers.Add("MCP-Protocol-Version", protocolVersion);
}
if (lastEventId is not null)
{
headers.Add("Last-Event-ID", lastEventId);
}
if (additionalHeaders is null)
{
return;
@ -317,4 +425,22 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}
}
}
/// <summary>
/// Tracks state across SSE stream connections.
/// </summary>
private sealed class SseStreamState
{
public string? LastEventId { get; set; }
public TimeSpan? RetryInterval { get; set; }
}
/// <summary>
/// Represents the result of processing an SSE response.
/// </summary>
private readonly struct SseResponse
{
public JsonRpcMessageWithId? Response { get; init; }
public bool IsNetworkError { get; init; }
}
}