From 47b9582fd703f6769e17d365c25a2775c35b9a2a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 2 Oct 2025 15:57:01 -0700 Subject: [PATCH] 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 --- .../HttpMcpServerBuilderExtensions.cs | 1 - .../Stateless/StatelessSessionId.cs | 13 ---- .../StatelessSessionIdJsonContext.cs | 6 -- .../StreamableHttpHandler.cs | 59 +++++-------------- .../Server/McpServer.Methods.cs | 6 +- .../Server/McpServerExtensions.cs | 2 +- .../HttpServerIntegrationTests.cs | 4 +- .../MapMcpStreamableHttpTests.cs | 7 ++- .../MapMcpTests.cs | 2 + 9 files changed, 28 insertions(+), 72 deletions(-) delete mode 100644 src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs delete mode 100644 src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index fbceab4..313cbfa 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -29,7 +29,6 @@ public static class HttpMcpServerBuilderExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.AddHostedService(); - builder.Services.AddDataProtection(); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs b/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs deleted file mode 100644 index 0257f6d..0000000 --- a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs +++ /dev/null @@ -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; } -} diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs b/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs deleted file mode 100644 index 6963ed6..0000000 --- a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.AspNetCore.Stateless; - -[JsonSerializable(typeof(StatelessSessionId))] -internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 14093fa..70976d4 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -1,16 +1,13 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -using ModelContextProtocol.AspNetCore.Stateless; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Security.Claims; using System.Security.Cryptography; -using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -20,7 +17,6 @@ internal sealed class StreamableHttpHandler( IOptionsFactory mcpServerOptionsFactory, IOptions httpServerTransportOptions, StatefulSessionManager sessionManager, - IDataProtectionProvider dataProtection, ILoggerFactory loggerFactory, IServiceProvider applicationServices) { @@ -31,8 +27,6 @@ internal sealed class StreamableHttpHandler( public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; - private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId"); - public async Task HandlePostRequestAsync(HttpContext context) { // 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); 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)) { // -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); } + 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 { return await GetSessionAsync(context, sessionId); @@ -193,14 +183,12 @@ internal sealed class StreamableHttpHandler( } else { - // "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id - // until after we receive the initialize request with the client info we need to serialize. - sessionId = "(uninitialized stateless id)"; + // In stateless mode, each request is independent. Don't set any session ID on the transport. + sessionId = ""; transport = new() { Stateless = true, }; - ScheduleStatelessSessionIdWrite(context, transport); } return await CreateSessionAsync(context, transport, sessionId); @@ -209,21 +197,19 @@ internal sealed class StreamableHttpHandler( private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, - string sessionId, - StatelessSessionId? statelessId = null) + string sessionId) { var mcpServerServices = applicationServices; 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); - if (statelessId is not null) + if (HttpServerTransportOptions.Stateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; mcpServerOptions.ScopeRequests = false; - mcpServerOptions.KnownClientInfo = statelessId.ClientInfo; } if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions) @@ -235,7 +221,7 @@ internal sealed class StreamableHttpHandler( var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices); 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 runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync; @@ -273,7 +259,6 @@ internal sealed class StreamableHttpHandler( RandomNumberGenerator.Fill(buffer); return WebEncoders.Base64UrlEncode(buffer); } - internal static async Task ReadJsonRpcMessageAsync(HttpContext context) { // Implementation for reading a JSON-RPC message from the request body @@ -290,22 +275,6 @@ internal sealed class StreamableHttpHandler( 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) => session.RunAsync(requestAborted); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 00fc0a7..0bae663 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -416,7 +416,7 @@ public abstract partial class McpServer : McpSession, IMcpServer { 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."); } @@ -429,7 +429,7 @@ public abstract partial class McpServer : McpSession, IMcpServer { 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."); } @@ -442,7 +442,7 @@ public abstract partial class McpServer : McpSession, IMcpServer { 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."); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index cd8c368..b208655 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -24,7 +24,7 @@ public static class McpServerExtensions /// The client does not support sampling. /// /// This method requires the client to support sampling capabilities. - /// It allows detailed control over sampling parameters including messages, system prompt, temperature, + /// It allows detailed control over sampling parameters including messages, system prompt, temperature, /// and token limits. /// [Obsolete($"Use {nameof(McpServer)}.{nameof(McpServer.SampleAsync)} instead. This member will be removed in a subsequent release.")] // See: https://github.com/modelcontextprotocol/csharp-sdk/issues/774 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 1d27a21..5e3a654 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -54,8 +54,10 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture { - 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"]); } @@ -179,8 +179,11 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT Assert.Equal("2025-03-26", mcpClient.NegotiatedProtocolVersion); 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. - 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)); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 341171c..d7b9eaa 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -85,6 +85,8 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI [Fact] 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(); // Add an authentication scheme that will send a 403 Forbidden response.