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
39 changed files with 2675 additions and 246 deletions
2
.github/workflows/ci-build-test.yml
vendored
2
.github/workflows/ci-build-test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
198
src/Common/ServerSentEvents/ArrayBuffer.cs
Normal file
198
src/Common/ServerSentEvents/ArrayBuffer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/Common/ServerSentEvents/PooledByteBufferWriter.cs
Normal file
35
src/Common/ServerSentEvents/PooledByteBufferWriter.cs
Normal 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();
|
||||
}
|
||||
136
src/Common/ServerSentEvents/SseEventWriter.cs
Normal file
136
src/Common/ServerSentEvents/SseEventWriter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
71
src/Common/ServerSentEvents/SseEventWriterHelpers.cs
Normal file
71
src/Common/ServerSentEvents/SseEventWriterHelpers.cs
Normal 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();
|
||||
}
|
||||
31
src/Common/ServerSentEvents/SseItem.cs
Normal file
31
src/Common/ServerSentEvents/SseItem.cs
Normal 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");
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
src/ModelContextProtocol.Core/.editorconfig
Normal file
2
src/ModelContextProtocol.Core/.editorconfig
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[*.cs]
|
||||
dotnet_diagnostic.CA2007.severity = error # CA2007: Do not directly await a Task without ConfigureAwait
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 >= 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;
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
23
src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs
Normal file
23
src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue