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
3 changed files with 785 additions and 230 deletions

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