Make "Stateless" mode sessionless (#760)

- This removes the requirement to configure data protection which is the motivating reason for this change
- This means that McpServer.ClientInfo will always be null in stateless mode
This commit is contained in:
Stephen Halter 2025-10-02 15:57:01 -07:00 • committed by GitHub
commit 47b9582fd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 28 additions and 72 deletions

View file

@ -29,7 +29,6 @@ public static class HttpMcpServerBuilderExtensions
builder.Services.TryAddSingleton<StreamableHttpHandler>(); builder.Services.TryAddSingleton<StreamableHttpHandler>();
builder.Services.TryAddSingleton<SseHandler>(); builder.Services.TryAddSingleton<SseHandler>();
builder.Services.AddHostedService<IdleTrackingBackgroundService>(); builder.Services.AddHostedService<IdleTrackingBackgroundService>();
builder.Services.AddDataProtection();
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());

View file

@ -1,13 +0,0 @@
using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.AspNetCore.Stateless;
internal sealed class StatelessSessionId
{
[JsonPropertyName("clientInfo")]
public Implementation? ClientInfo { get; init; }
[JsonPropertyName("userIdClaim")]
public UserIdClaim? UserIdClaim { get; init; }
}

View file

@ -1,6 +0,0 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.AspNetCore.Stateless;
[JsonSerializable(typeof(StatelessSessionId))]
internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext;

View file

@ -1,16 +1,13 @@
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using ModelContextProtocol.AspNetCore.Stateless;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore; namespace ModelContextProtocol.AspNetCore;
@ -20,7 +17,6 @@ internal sealed class StreamableHttpHandler(
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory, IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
IOptions<HttpServerTransportOptions> httpServerTransportOptions, IOptions<HttpServerTransportOptions> httpServerTransportOptions,
StatefulSessionManager sessionManager, StatefulSessionManager sessionManager,
IDataProtectionProvider dataProtection,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IServiceProvider applicationServices) IServiceProvider applicationServices)
{ {
@ -31,8 +27,6 @@ internal sealed class StreamableHttpHandler(
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
public async Task HandlePostRequestAsync(HttpContext context) public async Task HandlePostRequestAsync(HttpContext context)
{ {
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
@ -128,17 +122,6 @@ internal sealed class StreamableHttpHandler(
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest); await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
return null; return null;
} }
else if (HttpServerTransportOptions.Stateless)
{
var sessionJson = Protector.Unprotect(sessionId);
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
var transport = new StreamableHttpServerTransport
{
Stateless = true,
SessionId = sessionId,
};
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
}
else if (!sessionManager.TryGetValue(sessionId, out session)) else if (!sessionManager.TryGetValue(sessionId, out session))
{ {
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does. // -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
@ -170,6 +153,13 @@ internal sealed class StreamableHttpHandler(
{ {
return await StartNewSessionAsync(context); return await StartNewSessionAsync(context);
} }
else if (HttpServerTransportOptions.Stateless)
{
// In stateless mode, we should not be getting existing sessions via sessionId
// This path should not be reached in stateless mode
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest);
return null;
}
else else
{ {
return await GetSessionAsync(context, sessionId); return await GetSessionAsync(context, sessionId);
@ -193,14 +183,12 @@ internal sealed class StreamableHttpHandler(
} }
else else
{ {
// "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id // In stateless mode, each request is independent. Don't set any session ID on the transport.
// until after we receive the initialize request with the client info we need to serialize. sessionId = "";
sessionId = "(uninitialized stateless id)";
transport = new() transport = new()
{ {
Stateless = true, Stateless = true,
}; };
ScheduleStatelessSessionIdWrite(context, transport);
} }
return await CreateSessionAsync(context, transport, sessionId); return await CreateSessionAsync(context, transport, sessionId);
@ -209,21 +197,19 @@ internal sealed class StreamableHttpHandler(
private async ValueTask<StreamableHttpSession> CreateSessionAsync( private async ValueTask<StreamableHttpSession> CreateSessionAsync(
HttpContext context, HttpContext context,
StreamableHttpServerTransport transport, StreamableHttpServerTransport transport,
string sessionId, string sessionId)
StatelessSessionId? statelessId = null)
{ {
var mcpServerServices = applicationServices; var mcpServerServices = applicationServices;
var mcpServerOptions = mcpServerOptionsSnapshot.Value; var mcpServerOptions = mcpServerOptionsSnapshot.Value;
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null) if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
{ {
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
if (statelessId is not null) if (HttpServerTransportOptions.Stateless)
{ {
// The session does not outlive the request in stateless mode. // The session does not outlive the request in stateless mode.
mcpServerServices = context.RequestServices; mcpServerServices = context.RequestServices;
mcpServerOptions.ScopeRequests = false; mcpServerOptions.ScopeRequests = false;
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
} }
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions) if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
@ -235,7 +221,7 @@ internal sealed class StreamableHttpHandler(
var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices); var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
context.Features.Set(server); context.Features.Set(server);
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User); var userIdClaim = GetUserIdClaim(context.User);
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager); var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync; var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
@ -273,7 +259,6 @@ internal sealed class StreamableHttpHandler(
RandomNumberGenerator.Fill(buffer); RandomNumberGenerator.Fill(buffer);
return WebEncoders.Base64UrlEncode(buffer); return WebEncoders.Base64UrlEncode(buffer);
} }
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context) internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
{ {
// Implementation for reading a JSON-RPC message from the request body // Implementation for reading a JSON-RPC message from the request body
@ -290,22 +275,6 @@ internal sealed class StreamableHttpHandler(
return message; return message;
} }
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
{
transport.OnInitRequestReceived = initRequestParams =>
{
var statelessId = new StatelessSessionId
{
ClientInfo = initRequestParams?.ClientInfo,
UserIdClaim = GetUserIdClaim(context.User),
};
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
transport.SessionId = Protector.Protect(sessionJson);
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
return ValueTask.CompletedTask;
};
}
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted) internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
=> session.RunAsync(requestAborted); => session.RunAsync(requestAborted);

View file

@ -416,7 +416,7 @@ public abstract partial class McpServer : McpSession, IMcpServer
{ {
if (ClientCapabilities?.Sampling is null) if (ClientCapabilities?.Sampling is null)
{ {
if (ServerOptions.KnownClientInfo is not null) if (ClientCapabilities is null)
{ {
throw new InvalidOperationException("Sampling is not supported in stateless mode."); throw new InvalidOperationException("Sampling is not supported in stateless mode.");
} }
@ -429,7 +429,7 @@ public abstract partial class McpServer : McpSession, IMcpServer
{ {
if (ClientCapabilities?.Roots is null) if (ClientCapabilities?.Roots is null)
{ {
if (ServerOptions.KnownClientInfo is not null) if (ClientCapabilities is null)
{ {
throw new InvalidOperationException("Roots are not supported in stateless mode."); throw new InvalidOperationException("Roots are not supported in stateless mode.");
} }
@ -442,7 +442,7 @@ public abstract partial class McpServer : McpSession, IMcpServer
{ {
if (ClientCapabilities?.Elicitation is null) if (ClientCapabilities?.Elicitation is null)
{ {
if (ServerOptions.KnownClientInfo is not null) if (ClientCapabilities is null)
{ {
throw new InvalidOperationException("Elicitation is not supported in stateless mode."); throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
} }

View file

@ -54,8 +54,10 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
Assert.NotNull(client.ServerInfo); Assert.NotNull(client.ServerInfo);
Assert.NotNull(client.NegotiatedProtocolVersion); Assert.NotNull(client.NegotiatedProtocolVersion);
if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse")) if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse") ||
ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/stateless"))
{ {
// In SSE and in Streamable HTTP's stateless mode, no protocol-defined session IDs are used.:w
Assert.Null(client.SessionId); Assert.Null(client.SessionId);
} }
else else

View file

@ -158,7 +158,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
{ {
return async context => return async context =>
{ {
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"])) if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-protocol-version"]))
{ {
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]); protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
} }
@ -179,8 +179,11 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Assert.Equal("2025-03-26", mcpClient.NegotiatedProtocolVersion); Assert.Equal("2025-03-26", mcpClient.NegotiatedProtocolVersion);
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
await mcpClient.DisposeAsync();
// The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request. // The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request.
Assert.NotEmpty(protocolVersionHeaderValues); // The DELETE request won't be sent for Stateless mode due to the lack of an Mcp-Session-Id.
Assert.Equal(Stateless ? 3 : 4, protocolVersionHeaderValues.Count);
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v)); Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
} }
} }

View file

@ -85,6 +85,8 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
[Fact] [Fact]
public async Task Messages_FromNewUser_AreRejected() public async Task Messages_FromNewUser_AreRejected()
{ {
Assert.SkipWhen(Stateless, "User validation across requests is not applicable in stateless mode.");
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>(); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
// Add an authentication scheme that will send a 403 Forbidden response. // Add an authentication scheme that will send a 403 Forbidden response.