Streamable HTTP resumability + redelivery + SSE polling via server-side disconnect (#1077)
Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
parent
4cb2fd4655
commit
b8311756b4
1 changed files with 2675 additions and 246 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue