Skip to content

Commit a3b519b

Browse files
committed
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
1 parent 0f0aad0 commit a3b519b

File tree

8 files changed

+26
-72
lines changed

8 files changed

+26
-72
lines changed

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
2727
builder.Services.TryAddSingleton<StreamableHttpHandler>();
2828
builder.Services.TryAddSingleton<SseHandler>();
2929
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
30-
builder.Services.AddDataProtection();
3130

3231
if (configureOptions is not null)
3332
{

src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs

Lines changed: 0 additions & 6 deletions
This file was deleted.

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
using Microsoft.AspNetCore.DataProtection;
2-
using Microsoft.AspNetCore.Http;
1+
using Microsoft.AspNetCore.Http;
32
using Microsoft.AspNetCore.Http.Features;
43
using Microsoft.AspNetCore.WebUtilities;
54
using Microsoft.Extensions.Logging;
65
using Microsoft.Extensions.Options;
76
using Microsoft.Net.Http.Headers;
8-
using ModelContextProtocol.AspNetCore.Stateless;
97
using ModelContextProtocol.Protocol;
108
using ModelContextProtocol.Server;
119
using System.IO.Pipelines;
1210
using System.Security.Claims;
1311
using System.Security.Cryptography;
14-
using System.Text.Json;
1512
using System.Text.Json.Serialization.Metadata;
1613

1714
namespace ModelContextProtocol.AspNetCore;
@@ -21,7 +18,6 @@ internal sealed class StreamableHttpHandler(
2118
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
2219
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
2320
StatefulSessionManager sessionManager,
24-
IDataProtectionProvider dataProtection,
2521
ILoggerFactory loggerFactory,
2622
IServiceProvider applicationServices)
2723
{
@@ -30,8 +26,6 @@ internal sealed class StreamableHttpHandler(
3026

3127
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
3228

33-
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
34-
3529
public async Task HandlePostRequestAsync(HttpContext context)
3630
{
3731
// 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)
118112
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
119113
return null;
120114
}
121-
else if (HttpServerTransportOptions.Stateless)
122-
{
123-
var sessionJson = Protector.Unprotect(sessionId);
124-
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
125-
var transport = new StreamableHttpServerTransport
126-
{
127-
Stateless = true,
128-
SessionId = sessionId,
129-
};
130-
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
131-
}
132115
else if (!sessionManager.TryGetValue(sessionId, out session))
133116
{
134117
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
@@ -160,6 +143,13 @@ await WriteJsonRpcErrorAsync(context,
160143
{
161144
return await StartNewSessionAsync(context);
162145
}
146+
else if (HttpServerTransportOptions.Stateless)
147+
{
148+
// In stateless mode, we should not be getting existing sessions via sessionId
149+
// This path should not be reached in stateless mode
150+
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest);
151+
return null;
152+
}
163153
else
164154
{
165155
return await GetSessionAsync(context, sessionId);
@@ -183,14 +173,12 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
183173
}
184174
else
185175
{
186-
// "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id
187-
// until after we receive the initialize request with the client info we need to serialize.
188-
sessionId = "(uninitialized stateless id)";
176+
// In stateless mode, each request is independent. Don't set any session ID on the transport.
177+
sessionId = "";
189178
transport = new()
190179
{
191180
Stateless = true,
192181
};
193-
ScheduleStatelessSessionIdWrite(context, transport);
194182
}
195183

196184
return await CreateSessionAsync(context, transport, sessionId);
@@ -199,21 +187,19 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
199187
private async ValueTask<StreamableHttpSession> CreateSessionAsync(
200188
HttpContext context,
201189
StreamableHttpServerTransport transport,
202-
string sessionId,
203-
StatelessSessionId? statelessId = null)
190+
string sessionId)
204191
{
205192
var mcpServerServices = applicationServices;
206193
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
207-
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null)
194+
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
208195
{
209196
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
210197

211-
if (statelessId is not null)
198+
if (HttpServerTransportOptions.Stateless)
212199
{
213200
// The session does not outlive the request in stateless mode.
214201
mcpServerServices = context.RequestServices;
215202
mcpServerOptions.ScopeRequests = false;
216-
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
217203
}
218204

219205
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
@@ -225,7 +211,7 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
225211
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
226212
context.Features.Set(server);
227213

228-
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User);
214+
var userIdClaim = GetUserIdClaim(context.User);
229215
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
230216

231217
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
@@ -264,23 +250,6 @@ internal static string MakeNewSessionId()
264250
return WebEncoders.Base64UrlEncode(buffer);
265251
}
266252

267-
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
268-
{
269-
transport.OnInitRequestReceived = initRequestParams =>
270-
{
271-
var statelessId = new StatelessSessionId
272-
{
273-
ClientInfo = initRequestParams?.ClientInfo,
274-
UserIdClaim = GetUserIdClaim(context.User),
275-
};
276-
277-
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
278-
transport.SessionId = Protector.Protect(sessionJson);
279-
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
280-
return ValueTask.CompletedTask;
281-
};
282-
}
283-
284253
internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
285254
=> session.RunAsync(requestAborted);
286255

src/ModelContextProtocol.Core/Server/McpServerExtensions.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public static class McpServerExtensions
2323
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
2424
/// <remarks>
2525
/// This method requires the client to support sampling capabilities.
26-
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
26+
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
2727
/// and token limits.
2828
/// </remarks>
2929
public static ValueTask<CreateMessageResult> SampleAsync(
@@ -238,7 +238,7 @@ private static void ThrowIfSamplingUnsupported(IMcpServer server)
238238
{
239239
if (server.ClientCapabilities?.Sampling is null)
240240
{
241-
if (server.ServerOptions.KnownClientInfo is not null)
241+
if (server.ClientCapabilities is null)
242242
{
243243
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
244244
}
@@ -251,7 +251,7 @@ private static void ThrowIfRootsUnsupported(IMcpServer server)
251251
{
252252
if (server.ClientCapabilities?.Roots is null)
253253
{
254-
if (server.ServerOptions.KnownClientInfo is not null)
254+
if (server.ClientCapabilities is null)
255255
{
256256
throw new InvalidOperationException("Roots are not supported in stateless mode.");
257257
}
@@ -264,7 +264,7 @@ private static void ThrowIfElicitationUnsupported(IMcpServer server)
264264
{
265265
if (server.ClientCapabilities?.Elicitation is null)
266266
{
267-
if (server.ServerOptions.KnownClientInfo is not null)
267+
if (server.ClientCapabilities is null)
268268
{
269269
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
270270
}

tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ public async Task Connect_TestServer_ShouldProvideServerFields()
5353
Assert.NotNull(client.ServerCapabilities);
5454
Assert.NotNull(client.ServerInfo);
5555

56-
if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse"))
56+
if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse") ||
57+
ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/stateless"))
5758
{
59+
// In SSE and in Streamable HTTP's stateless mode, no protocol-defined session IDs are used.:w
5860
Assert.Null(client.SessionId);
5961
}
6062
else

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia
158158
{
159159
return async context =>
160160
{
161-
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"]))
161+
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-protocol-version"]))
162162
{
163163
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
164164
}
@@ -180,7 +180,8 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia
180180
}
181181

182182
// The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request.
183-
Assert.NotEmpty(protocolVersionHeaderValues);
183+
// The DELETE request won't be sent for Stateless mode due to the lack of an Mcp-Session-Id.
184+
Assert.Equal(Stateless ? 3 : 4, protocolVersionHeaderValues.Count);
184185
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
185186
}
186187
}

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ public async Task Can_UseIHttpContextAccessor_InTool()
8585
[Fact]
8686
public async Task Messages_FromNewUser_AreRejected()
8787
{
88+
Assert.SkipWhen(Stateless, "User validation across requests is not applicable in stateless mode.");
89+
8890
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
8991

9092
// Add an authentication scheme that will send a 403 Forbidden response.

0 commit comments

Comments
 (0)