From a3b519ba1a9f020516741689de3000dc2fdf9b01 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 9 Sep 2025 19:02:25 -0700 Subject: [PATCH] Make "Stateless" mode sessionless - 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/McpServerExtensions.cs | 8 +-- .../HttpServerIntegrationTests.cs | 4 +- .../MapMcpStreamableHttpTests.cs | 5 +- .../MapMcpTests.cs | 2 + 8 files changed, 26 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 2d6b29fd9..2a2ad43c3 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -27,7 +27,6 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.AddHostedService(); - builder.Services.AddDataProtection(); if (configureOptions is not null) { diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs b/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs deleted file mode 100644 index 0257f6d95..000000000 --- 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 6963ed609..000000000 --- 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 bfbd805de..0bdd4c705 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -1,17 +1,14 @@ -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.IO.Pipelines; using System.Security.Claims; using System.Security.Cryptography; -using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -21,7 +18,6 @@ internal sealed class StreamableHttpHandler( IOptionsFactory mcpServerOptionsFactory, IOptions httpServerTransportOptions, StatefulSessionManager sessionManager, - IDataProtectionProvider dataProtection, ILoggerFactory loggerFactory, IServiceProvider applicationServices) { @@ -30,8 +26,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. @@ -118,17 +112,6 @@ public async Task HandleDeleteRequestAsync(HttpContext context) 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. @@ -160,6 +143,13 @@ await WriteJsonRpcErrorAsync(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 { return await GetSessionAsync(context, sessionId); @@ -183,14 +173,12 @@ private async ValueTask StartNewSessionAsync(HttpContext } 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); @@ -199,21 +187,19 @@ private async ValueTask StartNewSessionAsync(HttpContext 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) @@ -225,7 +211,7 @@ private async ValueTask CreateSessionAsync( var server = McpServerFactory.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; @@ -264,23 +250,6 @@ internal static string MakeNewSessionId() return WebEncoders.Base64UrlEncode(buffer); } - 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, IMcpServer session, CancellationToken requestAborted) => session.RunAsync(requestAborted); diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 277ed737b..682f4abaa 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -23,7 +23,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. /// public static ValueTask SampleAsync( @@ -238,7 +238,7 @@ private static void ThrowIfSamplingUnsupported(IMcpServer server) { if (server.ClientCapabilities?.Sampling is null) { - if (server.ServerOptions.KnownClientInfo is not null) + if (server.ClientCapabilities is null) { throw new InvalidOperationException("Sampling is not supported in stateless mode."); } @@ -251,7 +251,7 @@ private static void ThrowIfRootsUnsupported(IMcpServer server) { if (server.ClientCapabilities?.Roots is null) { - if (server.ServerOptions.KnownClientInfo is not null) + if (server.ClientCapabilities is null) { throw new InvalidOperationException("Roots are not supported in stateless mode."); } @@ -264,7 +264,7 @@ private static void ThrowIfElicitationUnsupported(IMcpServer server) { if (server.ClientCapabilities?.Elicitation is null) { - if (server.ServerOptions.KnownClientInfo is not null) + if (server.ClientCapabilities is null) { throw new InvalidOperationException("Elicitation is not supported in stateless mode."); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 9b3c91b94..b787c6cfb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -53,8 +53,10 @@ public async Task Connect_TestServer_ShouldProvideServerFields() Assert.NotNull(client.ServerCapabilities); Assert.NotNull(client.ServerInfo); - 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); } else diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index cb1f86db9..e2a636fd3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -158,7 +158,7 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia { 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"]); } @@ -180,7 +180,8 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia } // 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 4d0d73562..37bbb6e38 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -85,6 +85,8 @@ public async Task Can_UseIHttpContextAccessor_InTool() [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.