using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.ServerSentEvents; using System.Security.Claims; using System.Threading.Channels; namespace ModelContextProtocol.Server; /// /// Provides an implementation using Server-Sent Events (SSE) for server-to-client communication. /// /// /// /// This transport provides one-way communication from server to client using the SSE protocol over HTTP, /// while receiving client messages through a separate mechanism. It writes messages as /// SSE events to a response stream, typically associated with an HTTP response. /// /// /// This transport is used in scenarios where the server needs to push messages to the client in real-time, /// such as when streaming completion results or providing progress updates during long-running operations. /// /// public sealed partial class StreamableHttpServerTransport : ITransport { /// /// The stream ID used for unsolicited messages sent via the standalone GET SSE stream. /// public static readonly string UnsolicitedMessageStreamId = "__get__"; private readonly Channel _incomingChannel = Channel.CreateBounded(new BoundedChannelOptions(1) { SingleReader = true, SingleWriter = false, }); private readonly CancellationTokenSource _transportDisposedCts = new(); private readonly SemaphoreSlim _unsolicitedMessageLock = new(1, 1); private readonly ILogger _logger; private SseEventWriter? _httpSseWriter; private ISseEventStreamWriter? _storeSseWriter; private TaskCompletionSource? _httpResponseTcs; private string? _negotiatedProtocolVersion; private bool _getHttpRequestStarted; private bool _disposed; /// /// Initializes a new instance of the class. /// /// Optional logger factory used for logging employed by the transport. public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null) { _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; } /// public string? SessionId { get; init; } /// /// Gets or initializes a value that indicates whether the transport should be in stateless mode that does not require all requests for a given session /// to arrive to the same ASP.NET Core application process. Unsolicited server-to-client messages are not supported in this mode, /// so calling results in an . /// Server-to-client requests are also unsupported, because the responses might arrive at another ASP.NET Core application process. /// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests. /// public bool Stateless { get; init; } /// /// Gets or initializes a value indicating whether the execution context should flow from the calls to /// to the corresponding property contained in the instances returned by the . /// /// /// The default is . /// public bool FlowExecutionContextFromRequests { get; init; } /// /// 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. /// public ISseEventStreamStore? EventStreamStore { get; init; } /// /// Gets or sets an optional callback invoked after the initialization handshake completes. /// /// /// When set, this callback is invoked with the after a successful /// initialization handshake. This can be used to persist session data for cross-instance migration. /// public Func? OnSessionInitialized { get; init; } /// public ChannelReader MessageReader => _incomingChannel.Reader; internal ChannelWriter MessageWriter => _incomingChannel.Writer; /// /// Handles initialization by capturing the negotiated protocol version and optionally invoking /// so session data can be persisted. /// /// /// This is called automatically when an initialize request is processed via /// . It can also be called /// directly when restoring a migrated session with known . /// /// The initialization parameters from the client, or if unavailable. public async ValueTask HandleInitializeRequestAsync(InitializeRequestParams? initParams) { _negotiatedProtocolVersion = initParams?.ProtocolVersion; if (initParams is not null && OnSessionInitialized is { } callback) { await callback(initParams, _transportDisposedCts.Token).ConfigureAwait(false); } } /// /// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by /// writing any unsolicited JSON-RPC messages sent via /// to the SSE response stream until cancellation is requested or the transport is disposed. /// /// The response stream to write MCP JSON-RPC messages as SSE events to. /// The to monitor for cancellation requests. The default is . /// A task representing the send loop that writes JSON-RPC messages to the SSE response stream. /// is . /// /// is and GET requests are not supported in stateless mode. /// public async Task HandleGetRequestAsync(Stream sseResponseStream, CancellationToken cancellationToken = default) { Throw.IfNull(sseResponseStream); if (Stateless) { throw new InvalidOperationException("GET requests are not supported in stateless mode."); } try { using (await _unsolicitedMessageLock.LockAsync(cancellationToken).ConfigureAwait(false)) { if (_getHttpRequestStarted) { throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session."); } _getHttpRequestStarted = true; _httpSseWriter = new SseEventWriter(sseResponseStream); _httpResponseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _storeSseWriter = await TryCreateEventStreamAsync(streamId: UnsolicitedMessageStreamId, cancellationToken).ConfigureAwait(false); if (_storeSseWriter is not null) { var primingItem = await _storeSseWriter.WriteEventAsync(SseItem.Prime(), cancellationToken).ConfigureAwait(false); await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false); } else { // If there's no priming write, flush the stream to ensure HTTP response headers are // sent to the client now that the transport is ready to accept messages via SendMessageAsync. await sseResponseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } } // Wait for the response to be written before returning from the handler. // This keeps the HTTP response open until the final response message is sent. await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } finally { // Release the SseEventWriter's reference to the response stream promptly when the GET // request ends, regardless of how it exits. Otherwise the response stream (and the // underlying Kestrel connection and associated memory pool buffers) remains pinned // in memory until the session itself is disposed (via explicit DELETE or idle timeout). // Clients that disconnect without sending DELETE — common with long-lived SSE — would // otherwise accumulate significant unmanaged memory per session during that interval. using (await _unsolicitedMessageLock.LockAsync(CancellationToken.None).ConfigureAwait(false)) { if (_httpSseWriter is { } writer) { _httpSseWriter = null; writer.Dispose(); } } } } /// /// Handles a Streamable HTTP POST request processing both the request body and response body ensuring that /// and other correlated messages are sent back to the client directly in response /// to the that initiated the message. /// /// The JSON-RPC message received from the client via the POST request body. /// The to monitor for cancellation requests. The default is . /// The POST response body to write MCP JSON-RPC messages to. /// /// if data was written to the response body. /// if nothing was written because the request body did not contain any messages to respond to. /// The HTTP application should typically respond with an empty "202 Accepted" response in this scenario. /// /// or is . /// /// If an authenticated sent the message, that can be included in the . /// No other part of the context should be set. /// public async Task HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, CancellationToken cancellationToken = default) { Throw.IfNull(message); Throw.IfNull(responseStream); var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger); using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_transportDisposedCts.Token, cancellationToken); await using (postTransport.ConfigureAwait(false)) { return await postTransport.HandlePostAsync( message, cancellationToken: postCts.Token) .ConfigureAwait(false); } } /// public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { Throw.IfNull(message); if (Stateless) { throw new InvalidOperationException("Unsolicited server to client messages are not supported in stateless mode."); } using var _ = await _unsolicitedMessageLock.LockAsync(cancellationToken).ConfigureAwait(false); if (!_getHttpRequestStarted) { // Clients are not required to make a GET request for unsolicited messages. // If no GET request has been made, drop the message. return; } Debug.Assert(_httpResponseTcs is not null); var item = SseItem.Message(message); if (_storeSseWriter is not null) { // Always record the message in the event store (if configured) — even when the GET // response stream is gone — so a reconnecting client can replay it via Last-Event-ID. item = await _storeSseWriter.WriteEventAsync(item, cancellationToken).ConfigureAwait(false); } if (_httpSseWriter is { } writer) { try { await writer.WriteAsync(item, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { _httpResponseTcs!.TrySetException(ex); } } } /// public async ValueTask DisposeAsync() { using var _ = await _unsolicitedMessageLock.LockAsync().ConfigureAwait(false); if (_disposed) { return; } _disposed = true; try { _incomingChannel.Writer.TryComplete(); await _transportDisposedCts.CancelAsync().ConfigureAwait(false); } finally { try { _httpResponseTcs?.TrySetResult(true); if (_httpSseWriter is { } writer) { _httpSseWriter = null; writer.Dispose(); } if (_storeSseWriter is not null) { await _storeSseWriter.DisposeAsync().ConfigureAwait(false); } } finally { _transportDisposedCts.Dispose(); } } } internal async ValueTask TryCreateEventStreamAsync(string streamId, CancellationToken cancellationToken) { if (EventStreamStore is null || !McpSessionHandler.SupportsPrimingEvent(_negotiatedProtocolVersion)) { return null; } // We use the 'Streaming' stream mode so that in the case of an unexpected network disconnection, // the client can continue reading the remaining messages in a single, streamed response. const SseEventStreamMode Mode = SseEventStreamMode.Streaming; var sseEventStreamWriter = await EventStreamStore.CreateStreamAsync(new SseEventStreamOptions { SessionId = SessionId ?? Guid.NewGuid().ToString("N"), StreamId = streamId, Mode = Mode, }, cancellationToken).ConfigureAwait(false); return sseEventStreamWriter; } }