Add stateless Streamable HTTP support (#392)

- It allows a single MCP session spanning multiple requests
  to be handled by different servers without sharing state
- It does require the servers share data protection keys, but this
  is standard for ASP.NET Core cookies and antiforgery as well
- It immediately throws for unsupported operations like sampling
This commit is contained in:
Stephen Halter 2025-05-14 04:51:03 -07:00 • committed by GitHub
commit ce9f73f43f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 785 additions and 230 deletions

View file

@ -35,7 +35,8 @@ public class JsonRpcRequest : JsonRpcMessageWithId
JsonRpc = JsonRpc,
Id = id,
Method = Method,
Params = Params
Params = Params,
RelatedTransport = RelatedTransport,
};
}
}

View file

@ -57,7 +57,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
cancellationToken = sendCts.Token;
#if NET
using var content = JsonContent.Create(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>());
using var content = JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
#else
using var content = new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),

View file

@ -1,5 +1,4 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.IO.Pipelines;
using System.Net.ServerSentEvents;
@ -13,12 +12,11 @@ namespace ModelContextProtocol.Protocol.Transport;
/// Handles processing the request/response body pairs for the Streamable HTTP transport.
/// This is typically used via <see cref="JsonRpcMessage.RelatedTransport"/>.
/// </summary>
internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>? incomingChannel, IDuplexPipe httpBodies) : ITransport
internal sealed class StreamableHttpPostTransport(StreamableHttpServerTransport parentTransport, IDuplexPipe httpBodies) : ITransport
{
private readonly SseWriter _sseWriter = new();
private readonly HashSet<RequestId> _pendingRequests = [];
private RequestId _pendingRequest;
// REVIEW: Should we introduce a send-only interface for RelatedTransport?
public ChannelReader<JsonRpcMessage> MessageReader => throw new NotSupportedException("JsonRpcMessage.RelatedTransport should only be used for sending messages.");
/// <returns>
@ -28,15 +26,11 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
/// </returns>
public async ValueTask<bool> RunAsync(CancellationToken cancellationToken)
{
// The incomingChannel is null to handle the potential client GET request to handle unsolicited JsonRpcMessages.
if (incomingChannel is not null)
{
var message = await JsonSerializer.DeserializeAsync(httpBodies.Input.AsStream(),
McpJsonUtilities.JsonContext.Default.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
await OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
}
var message = await JsonSerializer.DeserializeAsync(httpBodies.Input.AsStream(),
McpJsonUtilities.JsonContext.Default.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
await OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
if (_pendingRequests.Count == 0)
if (_pendingRequest.Id is null)
{
return false;
}
@ -48,6 +42,11 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (parentTransport.Stateless && message is JsonRpcRequest)
{
throw new InvalidOperationException("Server to client requests are not supported in stateless mode.");
}
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
@ -62,13 +61,10 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
{
yield return message;
if (message.Data is JsonRpcMessageWithId response)
if (message.Data is JsonRpcMessageWithId response && response.Id == _pendingRequest)
{
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
{
// Complete the SSE response stream now that all pending requests have been processed.
break;
}
// Complete the SSE response stream now that all pending requests have been processed.
break;
}
}
}
@ -82,13 +78,17 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
if (message is JsonRpcRequest request)
{
_pendingRequests.Add(request.Id);
_pendingRequest = request.Id;
// Store client capabilities so they can be serialized by "stateless" callers for use in later requests.
if (parentTransport.Stateless && request.Method == RequestMethods.Initialize)
{
parentTransport.InitializeRequest = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams);
}
}
message.RelatedTransport = this;
// Really an assertion. This doesn't get called when incomingChannel is null for GET requests.
Throw.IfNull(incomingChannel);
await incomingChannel.WriteAsync(message, cancellationToken).ConfigureAwait(false);
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}
}

View file

@ -1,4 +1,5 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
using System.IO.Pipelines;
using System.Threading.Channels;
@ -36,6 +37,25 @@ public sealed class StreamableHttpServerTransport : ITransport
private int _getRequestStarted;
/// <summary>
/// Configures 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 <see cref="HandleGetRequest(Stream, CancellationToken)"/> results in an <see cref="InvalidOperationException"/>.
/// Server-to-client requests are also unsupported, because the responses may 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.
/// </summary>
public bool Stateless { get; init; }
/// <summary>
/// Gets the initialize request if it was received by <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/> and <see cref="Stateless"/> is set to <see langword="true"/>.
/// </summary>
public InitializeRequestParams? InitializeRequest { get; internal set; }
/// <inheritdoc/>
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
internal ChannelWriter<JsonRpcMessage> MessageWriter => _incomingChannel.Writer;
/// <summary>
/// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by
/// writing any unsolicited JSON-RPC messages sent via <see cref="SendMessageAsync"/>
@ -46,6 +66,11 @@ public sealed class StreamableHttpServerTransport : ITransport
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
public async Task HandleGetRequest(Stream sseResponseStream, CancellationToken cancellationToken)
{
if (Stateless)
{
throw new InvalidOperationException("GET requests are not supported in stateless mode.");
}
if (Interlocked.Exchange(ref _getRequestStarted, 1) == 1)
{
throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session.");
@ -63,37 +88,45 @@ public sealed class StreamableHttpServerTransport : ITransport
/// <param name="httpBodies">The duplex pipe facilitates the reading and writing of HTTP request and response data.</param>
/// <param name="cancellationToken">This token allows for the operation to be canceled if needed.</param>
/// <returns>
/// True, if data was written to the respond body.
/// True, if data was written to the response body.
/// False, if nothing was written because the request body did not contain any <see cref="JsonRpcRequest"/> messages to respond to.
/// The HTTP application should typically respond with an empty "202 Accepted" response in this scenario.
/// </returns>
public async Task<bool> HandlePostRequest(IDuplexPipe httpBodies, CancellationToken cancellationToken)
{
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, cancellationToken);
await using var postTransport = new StreamableHttpPostTransport(_incomingChannel.Writer, httpBodies);
await using var postTransport = new StreamableHttpPostTransport(this, httpBodies);
return await postTransport.RunAsync(postCts.Token).ConfigureAwait(false);
}
/// <inheritdoc/>
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
/// <inheritdoc/>
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (Stateless)
{
throw new InvalidOperationException("Unsolicited server to client messages are not supported in stateless mode.");
}
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
_disposeCts.Cancel();
try
{
await _sseWriter.DisposeAsync().ConfigureAwait(false);
await _disposeCts.CancelAsync();
}
finally
{
_disposeCts.Dispose();
try
{
await _sseWriter.DisposeAsync().ConfigureAwait(false);
}
finally
{
_disposeCts.Dispose();
}
}
}
}

View file

@ -24,7 +24,8 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
private readonly bool _servicesScopePerRequest;
private readonly List<Action> _disposables = [];
private string _endpointName;
private readonly string _serverOnlyEndpointName;
private string? _endpointName;
private int _started;
/// <summary>Holds a boxed <see cref="LoggingLevel"/> value for the server.</summary>
@ -54,9 +55,12 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
_sessionTransport = transport;
ServerOptions = options;
Services = serviceProvider;
_endpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})";
_serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})";
_servicesScopePerRequest = options.ScopeRequests;
ClientInfo = options.KnownClientInfo;
UpdateEndpointNameWithClientInfo();
// Configure all request handlers based on the supplied options.
ServerCapabilities = new();
ConfigureInitialize(options);
@ -75,26 +79,29 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
}
// Now that everything has been configured, subscribe to any necessary notifications.
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false)
{
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
tools.Changed += changed;
_disposables.Add(() => tools.Changed -= changed);
}
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
{
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
tools.Changed += changed;
_disposables.Add(() => tools.Changed -= changed);
}
if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
{
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
prompts.Changed += changed;
_disposables.Add(() => prompts.Changed -= changed);
}
if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
{
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
prompts.Changed += changed;
_disposables.Add(() => prompts.Changed -= changed);
}
var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
if (resources is not null)
{
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
resources.Changed += changed;
_disposables.Add(() => resources.Changed -= changed);
var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
if (resources is not null)
{
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
resources.Changed += changed;
_disposables.Add(() => resources.Changed -= changed);
}
}
// And initialize the session.
@ -116,7 +123,7 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
public IServiceProvider? Services { get; }
/// <inheritdoc />
public override string EndpointName => _endpointName;
public override string EndpointName => _endpointName ?? _serverOnlyEndpointName;
/// <inheritdoc />
public LoggingLevel? LoggingLevel => _loggingLevel?.Value;
@ -163,8 +170,8 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
ClientInfo = request?.ClientInfo;
// Use the ClientInfo to update the session EndpointName for logging.
_endpointName = $"{_endpointName}, Client ({ClientInfo?.Name} {ClientInfo?.Version})";
GetSessionOrThrow().EndpointName = _endpointName;
UpdateEndpointNameWithClientInfo();
GetSessionOrThrow().EndpointName = EndpointName;
return new InitializeResult
{
@ -544,6 +551,16 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
requestTypeInfo, responseTypeInfo);
}
private void UpdateEndpointNameWithClientInfo()
{
if (ClientInfo is null)
{
return;
}
_endpointName = $"{_serverOnlyEndpointName}, Client ({ClientInfo.Name} {ClientInfo.Version})";
}
/// <summary>Maps a <see cref="LogLevel"/> to a <see cref="LoggingLevel"/>.</summary>
internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
level switch

View file

@ -30,14 +30,10 @@ public static class McpServerExtensions
/// and token limits.
/// </remarks>
public static ValueTask<CreateMessageResult> RequestSamplingAsync(
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken)
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken = default)
{
Throw.IfNull(server);
if (server.ClientCapabilities?.Sampling is null)
{
throw new InvalidOperationException("Client does not support sampling.");
}
ThrowIfSamplingUnsupported(server);
return server.SendRequestAsync(
RequestMethods.SamplingCreateMessage,
@ -163,11 +159,7 @@ public static class McpServerExtensions
public static IChatClient AsSamplingChatClient(this IMcpServer server)
{
Throw.IfNull(server);
if (server.ClientCapabilities?.Sampling is null)
{
throw new InvalidOperationException("Client does not support sampling.");
}
ThrowIfSamplingUnsupported(server);
return new SamplingChatClient(server);
}
@ -198,14 +190,10 @@ public static class McpServerExtensions
/// or other structured data sources that the client makes available through the protocol.
/// </remarks>
public static ValueTask<ListRootsResult> RequestRootsAsync(
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken)
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken = default)
{
Throw.IfNull(server);
if (server.ClientCapabilities?.Roots is null)
{
throw new InvalidOperationException("Client does not support roots.");
}
ThrowIfRootsUnsupported(server);
return server.SendRequestAsync(
RequestMethods.RootsList,
@ -215,6 +203,32 @@ public static class McpServerExtensions
cancellationToken: cancellationToken);
}
private static void ThrowIfSamplingUnsupported(IMcpServer server)
{
if (server.ClientCapabilities?.Sampling is null)
{
if (server.ServerOptions.KnownClientInfo is not null)
{
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
}
throw new InvalidOperationException("Client does not support sampling.");
}
}
private static void ThrowIfRootsUnsupported(IMcpServer server)
{
if (server.ClientCapabilities?.Roots is null)
{
if (server.ServerOptions.KnownClientInfo is not null)
{
throw new InvalidOperationException("Roots are not supported in stateless mode.");
}
throw new InvalidOperationException("Client does not support roots.");
}
}
/// <summary>Provides an <see cref="IChatClient"/> implementation that's implemented via client sampling.</summary>
private sealed class SamplingChatClient(IMcpServer server) : IChatClient
{

View file

@ -65,4 +65,15 @@ public class McpServerOptions
/// handler will be invoked within a new service scope.
/// </remarks>
public bool ScopeRequests { get; set; } = true;
/// <summary>
/// Gets or sets preexisting knowledge about the client including its name and version to help support
/// stateless Streamable HTTP servers that encode this knowledge in the mcp-session-id header.
/// </summary>
/// <remarks>
/// <para>
/// When not specified, this information is sourced from the client's initialize request.
/// </para>
/// </remarks>
public Implementation? KnownClientInfo { get; set; }
}