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

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