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:
parent
ca3a8ab921
commit
ce9f73f43f
3 changed files with 785 additions and 230 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue