Skip to content

Commit 322d89b

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 322d89b

File tree

8 files changed

+30
-68
lines changed

8 files changed

+30
-68
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: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
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;
@@ -21,7 +19,6 @@ internal sealed class StreamableHttpHandler(
2119
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
2220
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
2321
StatefulSessionManager sessionManager,
24-
IDataProtectionProvider dataProtection,
2522
ILoggerFactory loggerFactory,
2623
IServiceProvider applicationServices)
2724
{
@@ -30,8 +27,6 @@ internal sealed class StreamableHttpHandler(
3027

3128
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
3229

33-
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
34-
3530
public async Task HandlePostRequestAsync(HttpContext context)
3631
{
3732
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
@@ -120,14 +115,10 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
120115
}
121116
else if (HttpServerTransportOptions.Stateless)
122117
{
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);
118+
// In stateless mode, we should not be getting existing sessions via sessionId
119+
// This path should not be reached in stateless mode
120+
await WriteJsonRpcErrorAsync(context, "Bad Request: Session management not supported in stateless mode", StatusCodes.Status400BadRequest);
121+
return null;
131122
}
132123
else if (!sessionManager.TryGetValue(sessionId, out session))
133124
{
@@ -154,6 +145,12 @@ await WriteJsonRpcErrorAsync(context,
154145

155146
private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context)
156147
{
148+
if (HttpServerTransportOptions.Stateless)
149+
{
150+
// In stateless mode, always create a new session for each request
151+
return await StartNewSessionAsync(context);
152+
}
153+
157154
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
158155

159156
if (string.IsNullOrEmpty(sessionId))
@@ -183,14 +180,13 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
183180
}
184181
else
185182
{
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)";
183+
// In stateless mode, don't set any session ID - each request is independent
184+
sessionId = MakeNewSessionId(); // Still need an internal ID for logging/tracking
189185
transport = new()
190186
{
191187
Stateless = true,
192188
};
193-
ScheduleStatelessSessionIdWrite(context, transport);
189+
// Do not set Mcp-Session-Id header in stateless mode
194190
}
195191

196192
return await CreateSessionAsync(context, transport, sessionId);
@@ -199,21 +195,19 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
199195
private async ValueTask<StreamableHttpSession> CreateSessionAsync(
200196
HttpContext context,
201197
StreamableHttpServerTransport transport,
202-
string sessionId,
203-
StatelessSessionId? statelessId = null)
198+
string sessionId)
204199
{
205200
var mcpServerServices = applicationServices;
206201
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
207-
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null)
202+
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
208203
{
209204
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
210205

211-
if (statelessId is not null)
206+
if (HttpServerTransportOptions.Stateless)
212207
{
213208
// The session does not outlive the request in stateless mode.
214209
mcpServerServices = context.RequestServices;
215210
mcpServerOptions.ScopeRequests = false;
216-
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
217211
}
218212

219213
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
@@ -225,7 +219,7 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
225219
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
226220
context.Features.Set(server);
227221

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

231225
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
@@ -264,23 +258,6 @@ internal static string MakeNewSessionId()
264258
return WebEncoders.Base64UrlEncode(buffer);
265259
}
266260

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-
284261
internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
285262
=> session.RunAsync(requestAborted);
286263

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)