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

View file

@ -62,7 +62,7 @@ jobs:
node-version: '20'
- name: 📦 Install dependencies for tests
run: npm install @modelcontextprotocol/server-everything
run: npm install @modelcontextprotocol/server-everything@2025.12.18
- name: 📦 Install dependencies for tests
run: npm install @modelcontextprotocol/server-memory

View file

@ -0,0 +1,198 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// Copied from https://github.com/dotnet/runtime/blob/dcbf3413c5f7ae431a68fd0d3f09af095b525887/src/libraries/Common/src/System/Net/ArrayBuffer.cs
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace System.Net.ServerSentEvents;
// Warning: Mutable struct!
// The purpose of this struct is to simplify buffer management.
// It manages a sliding buffer where bytes can be added at the end and removed at the beginning.
// [ActiveSpan/Memory] contains the current buffer contents; these bytes will be preserved
// (copied, if necessary) on any call to EnsureAvailableBytes.
// [AvailableSpan/Memory] contains the available bytes past the end of the current content,
// and can be written to in order to add data to the end of the buffer.
// Commit(byteCount) will extend the ActiveSpan by [byteCount] bytes into the AvailableSpan.
// Discard(byteCount) will discard [byteCount] bytes as the beginning of the ActiveSpan.
[StructLayout(LayoutKind.Auto)]
internal struct ArrayBuffer : IDisposable
{
#if NET
private static int ArrayMaxLength => Array.MaxLength;
#else
private const int ArrayMaxLength = 0X7FFFFFC7;
#endif
private readonly bool _usePool;
private byte[] _bytes;
private int _activeStart;
private int _availableStart;
// Invariants:
// 0 <= _activeStart <= _availableStart <= bytes.Length
public ArrayBuffer(int initialSize, bool usePool = false)
{
Debug.Assert(initialSize > 0 || usePool);
_usePool = usePool;
_bytes = initialSize == 0
? Array.Empty<byte>()
: usePool ? ArrayPool<byte>.Shared.Rent(initialSize) : new byte[initialSize];
_activeStart = 0;
_availableStart = 0;
}
public ArrayBuffer(byte[] buffer)
{
Debug.Assert(buffer.Length > 0);
_usePool = false;
_bytes = buffer;
_activeStart = 0;
_availableStart = 0;
}
public void Dispose()
{
_activeStart = 0;
_availableStart = 0;
byte[] array = _bytes;
_bytes = null!;
if (array is not null)
{
ReturnBufferIfPooled(array);
}
}
// This is different from Dispose as the instance remains usable afterwards (_bytes will not be null).
public void ClearAndReturnBuffer()
{
Debug.Assert(_usePool);
Debug.Assert(_bytes is not null);
_activeStart = 0;
_availableStart = 0;
byte[] bufferToReturn = _bytes!;
_bytes = Array.Empty<byte>();
ReturnBufferIfPooled(bufferToReturn);
}
public int ActiveLength => _availableStart - _activeStart;
public Span<byte> ActiveSpan => new Span<byte>(_bytes, _activeStart, _availableStart - _activeStart);
public ReadOnlySpan<byte> ActiveReadOnlySpan => new ReadOnlySpan<byte>(_bytes, _activeStart, _availableStart - _activeStart);
public Memory<byte> ActiveMemory => new Memory<byte>(_bytes, _activeStart, _availableStart - _activeStart);
public int AvailableLength => _bytes.Length - _availableStart;
public Span<byte> AvailableSpan => _bytes.AsSpan(_availableStart);
public Memory<byte> AvailableMemory => _bytes.AsMemory(_availableStart);
public Memory<byte> AvailableMemorySliced(int length) => new Memory<byte>(_bytes, _availableStart, length);
public int Capacity => _bytes.Length;
public int ActiveStartOffset => _activeStart;
public byte[] DangerousGetUnderlyingBuffer() => _bytes;
public void Discard(int byteCount)
{
Debug.Assert(byteCount <= ActiveLength, $"Expected {byteCount} <= {ActiveLength}");
_activeStart += byteCount;
if (_activeStart == _availableStart)
{
_activeStart = 0;
_availableStart = 0;
}
}
public void Commit(int byteCount)
{
Debug.Assert(byteCount <= AvailableLength);
_availableStart += byteCount;
}
// Ensure at least [byteCount] bytes to write to.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void EnsureAvailableSpace(int byteCount)
{
if (byteCount > AvailableLength)
{
EnsureAvailableSpaceCore(byteCount);
}
}
private void EnsureAvailableSpaceCore(int byteCount)
{
Debug.Assert(AvailableLength < byteCount);
if (_bytes.Length == 0)
{
Debug.Assert(_usePool && _activeStart == 0 && _availableStart == 0);
_bytes = ArrayPool<byte>.Shared.Rent(byteCount);
return;
}
int totalFree = _activeStart + AvailableLength;
if (byteCount <= totalFree)
{
// We can free up enough space by just shifting the bytes down, so do so.
Buffer.BlockCopy(_bytes, _activeStart, _bytes, 0, ActiveLength);
_availableStart = ActiveLength;
_activeStart = 0;
Debug.Assert(byteCount <= AvailableLength);
return;
}
int desiredSize = ActiveLength + byteCount;
if ((uint)desiredSize > ArrayMaxLength)
{
throw new OutOfMemoryException();
}
// Double the existing buffer size (capped at Array.MaxLength).
int newSize = Math.Max(desiredSize, (int)Math.Min(ArrayMaxLength, 2 * (uint)_bytes.Length));
byte[] newBytes = _usePool ?
ArrayPool<byte>.Shared.Rent(newSize) :
new byte[newSize];
byte[] oldBytes = _bytes;
if (ActiveLength != 0)
{
Buffer.BlockCopy(oldBytes, _activeStart, newBytes, 0, ActiveLength);
}
_availableStart = ActiveLength;
_activeStart = 0;
_bytes = newBytes;
ReturnBufferIfPooled(oldBytes);
Debug.Assert(byteCount <= AvailableLength);
}
public void Grow()
{
EnsureAvailableSpaceCore(AvailableLength + 1);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ReturnBufferIfPooled(byte[] buffer)
{
// The buffer may be Array.Empty<byte>()
if (_usePool && buffer.Length > 0)
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}

View file

@ -0,0 +1,35 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// Copied from https://github.com/dotnet/runtime/blob/dcbf3413c5f7ae431a68fd0d3f09af095b525887/src/libraries/System.Net.ServerSentEvents/src/System/Net/ServerSentEvents/PooledByteBufferWriter.cs
using System.Buffers;
using System.Diagnostics;
namespace System.Net.ServerSentEvents;
internal sealed class PooledByteBufferWriter : IBufferWriter<byte>, IDisposable
{
private const int MinimumBufferSize = 256;
private ArrayBuffer _buffer = new(initialSize: 256, usePool: true);
public void Advance(int count) => _buffer.Commit(count);
public Memory<byte> GetMemory(int sizeHint = 0)
{
_buffer.EnsureAvailableSpace(Math.Max(sizeHint, MinimumBufferSize));
return _buffer.AvailableMemory;
}
public Span<byte> GetSpan(int sizeHint = 0)
{
_buffer.EnsureAvailableSpace(Math.Max(sizeHint, MinimumBufferSize));
return _buffer.AvailableSpan;
}
public ReadOnlyMemory<byte> WrittenMemory => _buffer.ActiveMemory;
public int Capacity => _buffer.Capacity;
public int WrittenCount => _buffer.ActiveLength;
public void Reset() => _buffer.Discard(_buffer.ActiveLength);
public void Dispose() => _buffer.Dispose();
}

View file

@ -0,0 +1,136 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// Based on https://github.com/dotnet/runtime/blob/dcbf3413c5f7ae431a68fd0d3f09af095b525887/src/libraries/System.Net.ServerSentEvents/src/System/Net/ServerSentEvents/SseFormatter.cs
using System.Buffers;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace System.Net.ServerSentEvents;
/// <summary>
/// Provides methods for writing SSE events to a stream.
/// </summary>
internal sealed class SseEventWriter : IDisposable
{
private static readonly byte[] s_newLine = "\n"u8.ToArray();
private readonly Stream _destination;
private readonly PooledByteBufferWriter _bufferWriter = new();
private readonly PooledByteBufferWriter _userDataBufferWriter = new();
/// <summary>
/// Initializes a new instance of the <see cref="SseEventWriter"/> class with the specified destination stream and item formatter.
/// </summary>
/// <param name="destination">The stream to write SSE events to.</param>
/// <exception cref="ArgumentNullException"><paramref name="destination"/> is <see langword="null"/>.</exception>
public SseEventWriter(Stream destination)
{
_destination = destination ?? throw new ArgumentNullException(nameof(destination));
}
/// <summary>
/// Writes an SSE item to the destination stream.
/// </summary>
/// <param name="item">The SSE item to write.</param>
/// <param name="itemFormatter"></param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous write operation.</returns>
public async ValueTask WriteAsync<T>(SseItem<T> item, Action<SseItem<T>, IBufferWriter<byte>> itemFormatter, CancellationToken cancellationToken = default)
{
itemFormatter(item, _userDataBufferWriter);
FormatSseEvent(
_bufferWriter,
eventType: item.EventType,
data: _userDataBufferWriter.WrittenMemory.Span,
eventId: item.EventId,
reconnectionInterval: item.ReconnectionInterval);
await _destination.WriteAsync(_bufferWriter.WrittenMemory, cancellationToken).ConfigureAwait(false);
await _destination.FlushAsync(cancellationToken).ConfigureAwait(false);
_userDataBufferWriter.Reset();
_bufferWriter.Reset();
}
private static void FormatSseEvent(
IBufferWriter<byte> bufferWriter,
string? eventType,
ReadOnlySpan<byte> data,
string? eventId,
TimeSpan? reconnectionInterval)
{
if (eventType is not null)
{
Debug.Assert(!eventType.ContainsLineBreaks());
bufferWriter.WriteUtf8String("event: "u8);
bufferWriter.WriteUtf8String(eventType);
bufferWriter.WriteUtf8String(s_newLine);
}
WriteLinesWithPrefix(bufferWriter, prefix: "data: "u8, data);
bufferWriter.Write(s_newLine);
if (eventId is not null)
{
Debug.Assert(!eventId.ContainsLineBreaks());
bufferWriter.WriteUtf8String("id: "u8);
bufferWriter.WriteUtf8String(eventId);
bufferWriter.WriteUtf8String(s_newLine);
}
if (reconnectionInterval is { } retry)
{
Debug.Assert(retry >= TimeSpan.Zero);
bufferWriter.WriteUtf8String("retry: "u8);
bufferWriter.WriteUtf8Number((long)retry.TotalMilliseconds);
bufferWriter.WriteUtf8String(s_newLine);
}
bufferWriter.WriteUtf8String(s_newLine);
}
private static void WriteLinesWithPrefix(IBufferWriter<byte> writer, ReadOnlySpan<byte> prefix, ReadOnlySpan<byte> data)
{
// Writes a potentially multi-line string, prefixing each line with the given prefix.
// Both \n and \r\n sequences are normalized to \n.
while (true)
{
writer.WriteUtf8String(prefix);
int i = data.IndexOfAny((byte)'\r', (byte)'\n');
if (i < 0)
{
writer.WriteUtf8String(data);
return;
}
int lineLength = i;
if (data[i++] == '\r' && i < data.Length && data[i] == '\n')
{
i++;
}
ReadOnlySpan<byte> nextLine = data.Slice(0, lineLength);
data = data.Slice(i);
writer.WriteUtf8String(nextLine);
writer.WriteUtf8String(s_newLine);
}
}
/// <inheritdoc/>
public void Dispose()
{
_bufferWriter.Dispose();
_userDataBufferWriter.Dispose();
}
}

View file

@ -0,0 +1,71 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// Copied from https://github.com/dotnet/runtime/blob/dcbf3413c5f7ae431a68fd0d3f09af095b525887/src/libraries/System.Net.ServerSentEvents/src/System/Net/ServerSentEvents/Helpers.cs
using System.Buffers;
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace System.Net.ServerSentEvents;
internal static class SseEventWriterHelpers
{
public static void WriteUtf8Number(this IBufferWriter<byte> writer, long value)
{
#if NET
const int MaxDecimalDigits = 20;
Span<byte> buffer = writer.GetSpan(MaxDecimalDigits);
Debug.Assert(MaxDecimalDigits <= buffer.Length);
bool success = value.TryFormat(buffer, out int bytesWritten, provider: CultureInfo.InvariantCulture);
Debug.Assert(success);
writer.Advance(bytesWritten);
#else
writer.WriteUtf8String(value.ToString(CultureInfo.InvariantCulture));
#endif
}
public static void WriteUtf8String(this IBufferWriter<byte> writer, ReadOnlySpan<byte> value)
{
if (value.IsEmpty)
{
return;
}
Span<byte> buffer = writer.GetSpan(value.Length);
Debug.Assert(value.Length <= buffer.Length);
value.CopyTo(buffer);
writer.Advance(value.Length);
}
public static void WriteUtf8String(this IBufferWriter<byte> writer, ReadOnlySpan<char> value)
{
if (value.IsEmpty)
{
return;
}
#if NET
int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length);
Span<byte> buffer = writer.GetSpan(maxByteCount);
Debug.Assert(buffer.Length >= maxByteCount);
int bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
writer.Advance(bytesWritten);
#else
// netstandard2.0 doesn't have the Span overload of GetBytes
byte[] bytes = Encoding.UTF8.GetBytes(value.ToString());
Span<byte> buffer = writer.GetSpan(bytes.Length);
bytes.AsSpan().CopyTo(buffer);
writer.Advance(bytes.Length);
#endif
}
public static bool ContainsLineBreaks(this ReadOnlySpan<char> text) =>
text.IndexOfAny('\r', '\n') >= 0;
public static bool ContainsLineBreaks(this string? text) =>
text is not null && text.AsSpan().ContainsLineBreaks();
}

View file

@ -0,0 +1,31 @@
namespace System.Net.ServerSentEvents;
/// <summary>
/// Provides factory methods for creating server-sent event (SSE) items with specific event types and data payloads.
/// </summary>
internal static class SseItem
{
/// <summary>
/// Creates a new server-sent event (SSE) message containing the specified data and the default event type.
/// </summary>
/// <typeparam name="T">The type of the data to include in the SSE message.</typeparam>
/// <param name="data">The data to include in the SSE message. Can be null.</param>
/// <returns>An <see cref="SseItem{T}"/> representing an SSE message with the specified data and the default event type.</returns>
public static SseItem<T?> Message<T>(T? data)
=> new(data: data, SseParser.EventTypeDefault);
/// <summary>
/// Creates a new Server-Sent Events (SSE) item representing a 'prime' event with no data.
/// </summary>
/// <returns>An <see cref="SseItem{T}"/> instance representing a 'prime' event with no data.</returns>
public static SseItem<T?> Prime<T>()
=> new(data: default, eventType: "prime");
/// <summary>
/// Creates a server-sent event (SSE) item representing the specified endpoint.
/// </summary>
/// <param name="endpoint">The endpoint string to include in the SSE item. Cannot be null.</param>
/// <returns>An <see cref="SseItem{String}"/> containing the specified endpoint value.</returns>
public static SseItem<string> Endpoint(string endpoint)
=> new(endpoint, "endpoint");
}

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

View file

@ -0,0 +1,2 @@
[*.cs]
dotnet_diagnostic.CA2007.severity = error # CA2007: Do not directly await a Task without ConfigureAwait

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; }
}
}

View file

@ -29,16 +29,38 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
"mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent.");
/// <summary>The latest version of the protocol supported by this implementation.</summary>
internal const string LatestProtocolVersion = "2025-06-18";
internal const string LatestProtocolVersion = "2025-11-25";
/// <summary>All protocol versions supported by this implementation.</summary>
internal static readonly string[] SupportedProtocolVersions =
[
"2024-11-05",
"2025-03-26",
"2025-06-18",
LatestProtocolVersion,
];
/// <summary>
/// Checks if the given protocol version supports priming events.
/// </summary>
/// <param name="protocolVersion">The protocol version to check.</param>
/// <returns>True if the protocol version supports resumability.</returns>
/// <remarks>
/// Priming events are only supported in protocol version &gt;= 2025-11-25.
/// Older clients may crash when receiving SSE events with empty data.
/// </remarks>
internal static bool SupportsPrimingEvent(string? protocolVersion)
{
const string MinResumabilityProtocolVersion = "2025-11-25";
if (protocolVersion is null)
{
return false;
}
return string.Compare(protocolVersion, MinResumabilityProtocolVersion, StringComparison.Ordinal) >= 0;
}
private readonly bool _isServer;
private readonly string _transportKind;
private readonly ITransport _transport;

View file

@ -23,6 +23,7 @@
<Compile Include="..\Common\Throw.cs" Link="Throw.cs" />
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup>
<!-- Dependencies only needed by netstandard2.0 -->

View file

@ -0,0 +1,36 @@
using ModelContextProtocol.Protocol;
using System.Net.ServerSentEvents;
namespace ModelContextProtocol.Server;
/// <summary>
/// Provides read access to an SSE event stream, allowing events to be consumed asynchronously.
/// </summary>
public interface ISseEventStreamReader
{
/// <summary>
/// Gets the session ID associated with the stream being read.
/// </summary>
string SessionId { get; }
/// <summary>
/// Gets the ID of the stream.
/// </summary>
/// <remarks>
/// This value is guaranteed to be unique on a per-session basis.
/// </remarks>
string StreamId { get; }
/// <summary>
/// Gets the messages from the stream as an <see cref="IAsyncEnumerable{T}"/>.
/// </summary>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An <see cref="IAsyncEnumerable{T}"/> of <see cref="SseItem{T}"/> containing JSON-RPC messages.</returns>
/// <remarks>
/// If the stream's mode is set to <see cref="SseEventStreamMode.Polling"/>, the returned
/// messages will only include the currently-available events starting at the last event ID specified
/// when the reader was created. Otherwise, the returned messages will continue until the associated
/// <see cref="ISseEventStreamWriter"/> is disposed.
/// </remarks>
IAsyncEnumerable<SseItem<JsonRpcMessage?>> ReadEventsAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,23 @@
namespace ModelContextProtocol.Server;
/// <summary>
/// Provides storage and retrieval of SSE event streams, enabling resumability and redelivery of events.
/// </summary>
public interface ISseEventStreamStore
{
/// <summary>
/// Creates a new SSE event stream with the specified options.
/// </summary>
/// <param name="options">The configuration options for the new stream.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A writer for the newly created event stream.</returns>
ValueTask<ISseEventStreamWriter> CreateStreamAsync(SseEventStreamOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a reader for an existing event stream based on the last event ID.
/// </summary>
/// <param name="lastEventId">The ID of the last event received by the client, used to resume from that point.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A reader for the event stream, or <c>null</c> if no matching stream is found.</returns>
ValueTask<ISseEventStreamReader?> GetStreamReaderAsync(string lastEventId, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,30 @@
using ModelContextProtocol.Protocol;
using System.Net.ServerSentEvents;
namespace ModelContextProtocol.Server;
/// <summary>
/// Provides write access to an SSE event stream, allowing events to be written and tracked with unique IDs.
/// </summary>
public interface ISseEventStreamWriter : IAsyncDisposable
{
/// <summary>
/// Sets the mode of the event stream.
/// </summary>
/// <param name="mode">The new mode to set for the event stream.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
ValueTask SetModeAsync(SseEventStreamMode mode, CancellationToken cancellationToken = default);
/// <summary>
/// Writes an event to the stream.
/// </summary>
/// <param name="sseItem">The original <see cref="SseItem{T}"/>.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A new <see cref="SseItem{T}"/> with a populated event ID.</returns>
/// <remarks>
/// If the provided <paramref name="sseItem"/> already has an event ID, this method skips writing the event.
/// Otherwise, an event ID unique to all sessions and streams is generated and assigned to the event.
/// </remarks>
ValueTask<SseItem<JsonRpcMessage?>> WriteEventAsync(SseItem<JsonRpcMessage?> sseItem, CancellationToken cancellationToken = default);
}

View file

@ -561,7 +561,7 @@ internal sealed partial class McpServerImpl : McpServer
try
{
return await handler(request, cancellationToken);
return await handler(request, cancellationToken).ConfigureAwait(false);
}
catch (Exception e) when (e is not OperationCanceledException and not McpProtocolException)
{

Some files were not shown because too many files have changed in this diff Show more