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; #pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private ISseEventStreamWriter? _storeSseWriter; #pragma warning restore MCP9006 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. /// [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] 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); } } /// /// /// /// This method sends server-to-client messages via the standalone SSE stream opened by an /// optional HTTP GET request (see ). /// /// /// This is generally the wrong channel for server-to-client requests. Requests /// sent via the GET stream depend on the client keeping a long-lived GET open, have no per-request /// correlation to a caller, and race with GET startup and teardown. When called from inside a /// tool, prompt, or resource handler, use the instance available via /// RequestContext instead — it routes through the originating POST response stream via /// , which is always open for the duration of /// the request. A diagnostic is emitted whenever a /// is sent through this method. /// /// /// If no GET SSE stream has yet been opened on this session, behavior depends on the message kind: /// messages throw because the /// awaiting caller has no way to receive a response; messages are /// dropped (notifications are best-effort and the spec does not require clients to issue a GET) /// and a diagnostic is logged; other messages are dropped and a /// diagnostic is logged. /// /// /// /// is , or is a /// and no GET SSE stream has been opened on this session. /// 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) { switch (message) { case JsonRpcRequest request: throw new InvalidOperationException( $"Cannot send server-to-client JSON-RPC request '{request.Method}' because no GET SSE stream has been opened on this session " + $"(SessionId: '{SessionId}'). " + "Inside a tool, prompt, or resource handler, use the IMcpServer instance from RequestContext (or any IMcpServer obtained via DI from a request-scoped service provider) so the request is routed through the originating POST response stream via JsonRpcMessageContext.RelatedTransport. " + "The standalone GET SSE stream is optional for clients and is not a reliable channel for server-to-client requests."); case JsonRpcNotification notification: // Clients are not required to make a GET request for unsolicited messages. // If no GET request has been made, drop the notification (best-effort). LogNotificationDroppedNoGetStream(notification.Method, SessionId ?? string.Empty); return; default: // JsonRpcResponse / JsonRpcError generally flow through the originating POST response // stream, so receiving one here without a GET is unexpected. Log loudly and drop. LogMessageDroppedNoGetStream(message.GetType().Name, GetMessageId(message), SessionId ?? string.Empty); return; } } if (message is JsonRpcRequest openRequest) { LogServerRequestOverGetStream(openRequest.Method, SessionId ?? string.Empty); } 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); } } } private static string GetMessageId(JsonRpcMessage message) => message is JsonRpcMessageWithId withId ? withId.Id.ToString() : string.Empty; /// 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(); } } } #pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. 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; } #pragma warning restore MCP9006 [LoggerMessage(Level = LogLevel.Warning, Message = "Sending server-to-client JSON-RPC request '{Method}' over the standalone GET SSE stream (SessionId: '{SessionId}'). " + "Consider using the IMcpServer instance from RequestContext inside a tool, prompt, or resource handler so the request is routed through the originating POST response stream via JsonRpcMessageContext.RelatedTransport, which is more reliable than the optional GET SSE stream.")] private partial void LogServerRequestOverGetStream(string method, string sessionId); [LoggerMessage(Level = LogLevel.Debug, Message = "Dropping server-to-client JSON-RPC notification '{Method}' because no GET SSE stream has been opened on this session (SessionId: '{SessionId}').")] private partial void LogNotificationDroppedNoGetStream(string method, string sessionId); [LoggerMessage(Level = LogLevel.Warning, Message = "Dropping unexpected server-to-client {MessageType} (Id: '{MessageId}') because no GET SSE stream has been opened on this session (SessionId: '{SessionId}'). " + "Responses normally flow through the originating POST response stream via JsonRpcMessageContext.RelatedTransport.")] private partial void LogMessageDroppedNoGetStream(string messageType, string messageId, string sessionId); }