Skip to content

Commit 47b9582

Browse files
authored
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
1 parent 740a7d0 commit 47b9582

File tree

9 files changed

+28
-72
lines changed

9 files changed

+28
-72
lines changed

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
2929
builder.Services.TryAddSingleton<StreamableHttpHandler>();
3030
builder.Services.TryAddSingleton<SseHandler>();
3131
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
32-
builder.Services.AddDataProtection();
3332

3433
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
3534

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,16 +1,13 @@
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.Security.Claims;
1210
using System.Security.Cryptography;
13-
using System.Text.Json;
1411
using System.Text.Json.Serialization.Metadata;
1512

1613
namespace ModelContextProtocol.AspNetCore;
@@ -20,7 +17,6 @@ internal sealed class StreamableHttpHandler(
2017
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
2118
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
2219
StatefulSessionManager sessionManager,
23-
IDataProtectionProvider dataProtection,
2420
ILoggerFactory loggerFactory,
2521
IServiceProvider applicationServices)
2622
{
@@ -31,8 +27,6 @@ internal sealed class StreamableHttpHandler(
3127

3228
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
3329

34-
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
35-
3630
public async Task HandlePostRequestAsync(HttpContext context)
3731
{
3832
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
@@ -128,17 +122,6 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
128122
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
129123
return null;
130124
}
131-
else if (HttpServerTransportOptions.Stateless)
132-
{
133-
var sessionJson = Protector.Unprotect(sessionId);
134-
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
135-
var transport = new StreamableHttpServerTransport
136-
{
137-
Stateless = true,
138-
SessionId = sessionId,
139-
};
140-
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
141-
}
142125
else if (!sessionManager.TryGetValue(sessionId, out session))
143126
{
144127
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
@@ -170,6 +153,13 @@ await WriteJsonRpcErrorAsync(context,
170153
{
171154
return await StartNewSessionAsync(context);
172155
}
156+
else if (HttpServerTransportOptions.Stateless)
157+
{
158+
// In stateless mode, we should not be getting existing sessions via sessionId
159+
// This path should not be reached in stateless mode
160+
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest);
161+
return null;
162+
}
173163
else
174164
{
175165
return await GetSessionAsync(context, sessionId);
@@ -193,14 +183,12 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
193183
}
194184
else
195185
{
196-
// "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id
197-
// until after we receive the initialize request with the client info we need to serialize.
198-
sessionId = "(uninitialized stateless id)";
186+
// In stateless mode, each request is independent. Don't set any session ID on the transport.
187+
sessionId = "";
199188
transport = new()
200189
{
201190
Stateless = true,
202191
};
203-
ScheduleStatelessSessionIdWrite(context, transport);
204192
}
205193

206194
return await CreateSessionAsync(context, transport, sessionId);
@@ -209,21 +197,19 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
209197
private async ValueTask<StreamableHttpSession> CreateSessionAsync(
210198
HttpContext context,
211199
StreamableHttpServerTransport transport,
212-
string sessionId,
213-
StatelessSessionId? statelessId = null)
200+
string sessionId)
214201
{
215202
var mcpServerServices = applicationServices;
216203
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
217-
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null)
204+
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
218205
{
219206
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
220207

221-
if (statelessId is not null)
208+
if (HttpServerTransportOptions.Stateless)
222209
{
223210
// The session does not outlive the request in stateless mode.
224211
mcpServerServices = context.RequestServices;
225212
mcpServerOptions.ScopeRequests = false;
226-
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
227213
}
228214

229215
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
@@ -235,7 +221,7 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
235221
var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
236222
context.Features.Set(server);
237223

238-
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User);
224+
var userIdClaim = GetUserIdClaim(context.User);
239225
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
240226

241227
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
@@ -273,7 +259,6 @@ internal static string MakeNewSessionId()
273259
RandomNumberGenerator.Fill(buffer);
274260
return WebEncoders.Base64UrlEncode(buffer);
275261
}
276-
277262
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
278263
{
279264
// Implementation for reading a JSON-RPC message from the request body
@@ -290,22 +275,6 @@ internal static string MakeNewSessionId()
290275
return message;
291276
}
292277

293-
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
294-
{
295-
transport.OnInitRequestReceived = initRequestParams =>
296-
{
297-
var statelessId = new StatelessSessionId
298-
{
299-
ClientInfo = initRequestParams?.ClientInfo,
300-
UserIdClaim = GetUserIdClaim(context.User),
301-
};
302-
303-
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
304-
transport.SessionId = Protector.Protect(sessionJson);
305-
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
306-
return ValueTask.CompletedTask;
307-
};
308-
}
309278

310279
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
311280
=> session.RunAsync(requestAborted);

src/ModelContextProtocol.Core/Server/McpServer.Methods.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ private void ThrowIfSamplingUnsupported()
416416
{
417417
if (ClientCapabilities?.Sampling is null)
418418
{
419-
if (ServerOptions.KnownClientInfo is not null)
419+
if (ClientCapabilities is null)
420420
{
421421
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
422422
}
@@ -429,7 +429,7 @@ private void ThrowIfRootsUnsupported()
429429
{
430430
if (ClientCapabilities?.Roots is null)
431431
{
432-
if (ServerOptions.KnownClientInfo is not null)
432+
if (ClientCapabilities is null)
433433
{
434434
throw new InvalidOperationException("Roots are not supported in stateless mode.");
435435
}
@@ -442,7 +442,7 @@ private void ThrowIfElicitationUnsupported()
442442
{
443443
if (ClientCapabilities?.Elicitation is null)
444444
{
445-
if (ServerOptions.KnownClientInfo is not null)
445+
if (ClientCapabilities is null)
446446
{
447447
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
448448
}

src/ModelContextProtocol.Core/Server/McpServerExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public static class McpServerExtensions
2424
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
2525
/// <remarks>
2626
/// This method requires the client to support sampling capabilities.
27-
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
27+
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
2828
/// and token limits.
2929
/// </remarks>
3030
[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

tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs

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

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

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 5 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
}
@@ -179,8 +179,11 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia
179179
Assert.Equal("2025-03-26", mcpClient.NegotiatedProtocolVersion);
180180
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
181181

182+
await mcpClient.DisposeAsync();
183+
182184
// The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request.
183-
Assert.NotEmpty(protocolVersionHeaderValues);
185+
// The DELETE request won't be sent for Stateless mode due to the lack of an Mcp-Session-Id.
186+
Assert.Equal(Stateless ? 3 : 4, protocolVersionHeaderValues.Count);
184187
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
185188
}
186189
}

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)