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
4 changed files with 2675 additions and 246 deletions

View file

@ -43,6 +43,21 @@ public class HttpServerTransportOptions
/// </remarks>
public bool Stateless { get; set; }
/// <summary>
/// Gets or sets the event store for resumability support.
/// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header.
/// </summary>
/// <remarks>
/// When configured, the server will:
/// <list type="bullet">
/// <item><description>Generate unique event IDs for each SSE message</description></item>
/// <item><description>Store events for later replay</description></item>
/// <item><description>Replay missed events when a client reconnects with a Last-Event-ID header</description></item>
/// <item><description>Send priming events to establish resumability before any actual messages</description></item>
/// </list>
/// </remarks>
public ISseEventStreamStore? EventStreamStore { get; set; }
/// <summary>
/// Gets or sets a value that indicates whether the server uses a single execution context for the entire session.
/// </summary>

View file

@ -41,10 +41,12 @@ public static class McpEndpointRouteBuilderExtensions
if (!streamableHttpHandler.HttpServerTransportOptions.Stateless)
{
// The GET and DELETE endpoints are not mapped in Stateless mode since there's no way to send unsolicited messages
// for the GET to handle, and there is no server-side state for the DELETE to clean up.
// The GET endpoint is not mapped in Stateless mode since there's no way to send unsolicited messages.
// Resuming streams via GET is currently not supported in Stateless mode.
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
// The DELETE endpoint is not mapped in Stateless mode since there is no server-side state for the DELETE to clean up.
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
// Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests

View file

@ -0,0 +1,53 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Buffers;
using System.Net.ServerSentEvents;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Provides extension methods for <see cref="ISseEventStreamReader"/>.
/// </summary>
internal static class SseEventStreamReaderExtensions
{
/// <summary>
/// Copies all events from the reader to the destination stream in SSE format.
/// </summary>
/// <param name="reader">The event stream reader to copy events from.</param>
/// <param name="destination">The destination stream to write SSE-formatted events to.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task that represents the asynchronous copy operation.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="reader"/> or <paramref name="destination"/> is null.</exception>
public static async Task CopyToAsync(this ISseEventStreamReader reader, Stream destination, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(destination);
Utf8JsonWriter? jsonWriter = null;
var jsonTypeInfo = (JsonTypeInfo<JsonRpcMessage>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage));
var events = reader.ReadEventsAsync(cancellationToken);
await SseFormatter.WriteAsync(events, destination, FormatEvent, cancellationToken).ConfigureAwait(false);
void FormatEvent(SseItem<JsonRpcMessage?> item, IBufferWriter<byte> writer)
{
if (item.Data is null)
{
return;
}
if (jsonWriter is null)
{
jsonWriter = new Utf8JsonWriter(writer);
}
else
{
jsonWriter.Reset(writer);
}
JsonSerializer.Serialize(jsonWriter, item.Data, jsonTypeInfo);
}
}
}

View file

@ -23,6 +23,7 @@ internal sealed class StreamableHttpHandler(
ILoggerFactory loggerFactory)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private const string LastEventIdHeaderName = "Last-Event-ID";
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
@ -88,10 +89,57 @@ internal sealed class StreamableHttpHandler(
return;
}
var lastEventId = context.Request.Headers[LastEventIdHeaderName].ToString();
if (!string.IsNullOrEmpty(lastEventId))
{
await HandleResumedStreamAsync(context, session, lastEventId);
}
else
{
await HandleUnsolicitedMessageStreamAsync(context, session);
}
}
private async Task HandleResumedStreamAsync(HttpContext context, StreamableHttpSession session, string lastEventId)
{
if (HttpServerTransportOptions.Stateless)
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The Last-Event-ID header is not supported in stateless mode.",
StatusCodes.Status400BadRequest);
return;
}
var eventStreamReader = await GetEventStreamReaderAsync(context, lastEventId);
if (eventStreamReader is null)
{
// There was an error obtaining the event stream; consider the request failed.
return;
}
if (!string.Equals(session.Id, eventStreamReader.SessionId, StringComparison.Ordinal))
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The Last-Event-ID header refers to a session with a different session ID.",
StatusCodes.Status400BadRequest);
return;
}
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
var cancellationToken = sseCts.Token;
await using var _ = await session.AcquireReferenceAsync(cancellationToken);
InitializeSseResponse(context);
await eventStreamReader.CopyToAsync(context.Response.Body, context.RequestAborted);
}
private async Task HandleUnsolicitedMessageStreamAsync(HttpContext context, StreamableHttpSession session)
{
if (!session.TryStartGetRequest())
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: This server does not support multiple GET requests. Start a new session to get a new GET SSE response.",
"Bad Request: This server does not support multiple GET requests. Start a new session or use Last-Event-ID header to resume.",
StatusCodes.Status400BadRequest);
return;
}
@ -120,6 +168,12 @@ internal sealed class StreamableHttpHandler(
}
}
private static async Task HandleResumePostResponseStreamAsync(HttpContext context, ISseEventStreamReader eventStreamReader)
{
InitializeSseResponse(context);
await eventStreamReader.CopyToAsync(context.Response.Body, context.RequestAborted);
}
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
@ -131,14 +185,13 @@ internal sealed class StreamableHttpHandler(
private async ValueTask<StreamableHttpSession?> GetSessionAsync(HttpContext context, string sessionId)
{
StreamableHttpSession? session;
if (string.IsNullOrEmpty(sessionId))
{
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
return null;
}
else if (!sessionManager.TryGetValue(sessionId, out session))
if (!sessionManager.TryGetValue(sessionId, out var session))
{
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
@ -194,12 +247,15 @@ internal sealed class StreamableHttpHandler(
{
SessionId = sessionId,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
};
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
}
else
{
// In stateless mode, each request is independent. Don't set any session ID on the transport.
// If in the future we support resuming stateless requests, we should populate
// the event stream store and retry interval here as well.
sessionId = "";
transport = new()
{
@ -246,6 +302,28 @@ internal sealed class StreamableHttpHandler(
return session;
}
private async ValueTask<ISseEventStreamReader?> GetEventStreamReaderAsync(HttpContext context, string lastEventId)
{
if (HttpServerTransportOptions.EventStreamStore is not { } eventStreamStore)
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: This server does not support resuming streams.",
StatusCodes.Status400BadRequest);
return null;
}
var eventStreamReader = await eventStreamStore.GetStreamReaderAsync(lastEventId, context.RequestAborted);
if (eventStreamReader is null)
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The specified Last-Event-ID is either invalid or expired.",
StatusCodes.Status400BadRequest);
return null;
}
return eventStreamReader;
}
private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000)
{
var jsonRpcError = new JsonRpcError