Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ce9f73f

Browse files
authoredMay 14, 2025··
Add stateless Streamable HTTP support (#392)
- It allows a single MCP session spanning multiple requests to be handled by different servers without sharing state - It does require the servers share data protection keys, but this is standard for ASP.NET Core cookies and antiforgery as well - It immediately throws for unsupported operations like sampling
1 parent ca3a8ab commit ce9f73f

31 files changed

+785
-230
lines changed
 

‎src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
2626
builder.Services.TryAddSingleton<StreamableHttpHandler>();
2727
builder.Services.TryAddSingleton<SseHandler>();
2828
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
29+
builder.Services.AddDataProtection();
2930

3031
if (configureOptions is not null)
3132
{

‎src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
using ModelContextProtocol.Protocol.Transport;
1+
using ModelContextProtocol.AspNetCore.Stateless;
2+
using ModelContextProtocol.Protocol.Transport;
23
using ModelContextProtocol.Server;
34
using System.Security.Claims;
45

56
namespace ModelContextProtocol.AspNetCore;
67

7-
internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport transport, ClaimsPrincipal user, TimeProvider timeProvider) : IAsyncDisposable
8+
internal sealed class HttpMcpSession<TTransport>(
9+
string sessionId,
10+
TTransport transport,
11+
UserIdClaim? userId,
12+
TimeProvider timeProvider) : IAsyncDisposable
813
where TTransport : ITransport
914
{
1015
private int _referenceCount;
@@ -13,7 +18,7 @@ internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport tr
1318

1419
public string Id { get; } = sessionId;
1520
public TTransport Transport { get; } = transport;
16-
public (string Type, string Value, string Issuer)? UserIdClaim { get; } = GetUserIdClaim(user);
21+
public UserIdClaim? UserIdClaim { get; } = userId;
1722

1823
public CancellationToken SessionClosed => _disposeCts.Token;
1924

@@ -63,27 +68,7 @@ public async ValueTask DisposeAsync()
6368
}
6469

6570
public bool HasSameUserId(ClaimsPrincipal user)
66-
=> UserIdClaim == GetUserIdClaim(user);
67-
68-
// SignalR only checks for ClaimTypes.NameIdentifier in HttpConnectionDispatcher, but AspNetCore.Antiforgery checks that plus the sub and UPN claims.
69-
// However, we short-circuit unlike antiforgery since we expect to call this to verify MCP messages a lot more frequently than
70-
// verifying antiforgery tokens from <form> posts.
71-
private static (string Type, string Value, string Issuer)? GetUserIdClaim(ClaimsPrincipal user)
72-
{
73-
if (user?.Identity?.IsAuthenticated != true)
74-
{
75-
return null;
76-
}
77-
78-
var claim = user.FindFirst(ClaimTypes.NameIdentifier) ?? user.FindFirst("sub") ?? user.FindFirst(ClaimTypes.Upn);
79-
80-
if (claim is { } idClaim)
81-
{
82-
return (idClaim.Type, idClaim.Value, idClaim.Issuer);
83-
}
84-
85-
return null;
86-
}
71+
=> UserIdClaim == StreamableHttpHandler.GetUserIdClaim(user);
8772

8873
private sealed class UnreferenceDisposable(HttpMcpSession<TTransport> session, TimeProvider timeProvider) : IDisposable
8974
{

‎src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,37 @@ public class HttpServerTransportOptions
2323
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
2424

2525
/// <summary>
26-
/// Represents the duration of time the server will wait between any active requests before timing out an
27-
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will
28-
/// receive a 404 status code and should restart their session. A client can keep their session open by
29-
/// keeping a GET request open. The default value is set to 2 hours.
26+
/// Gets or sets whether the server should run in a stateless mode that does not require all requests for a given session
27+
/// to arrive to the same ASP.NET Core application process.
3028
/// </summary>
29+
/// <remarks>
30+
/// If <see langword="true"/>, the "/sse" endpoint will be disabled, and client information will be round-tripped as part
31+
/// of the "mcp-session-id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client
32+
/// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process.
33+
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
34+
/// Defaults to <see langword="false"/>.
35+
/// </remarks>
36+
public bool Stateless { get; set; }
37+
38+
/// <summary>
39+
/// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session.
40+
/// </summary>
41+
/// <remarks>
42+
/// This is checked in background every 5 seconds. A client trying to resume a session will receive a 404 status code
43+
/// and should restart their session. A client can keep their session open by keeping a GET request open.
44+
/// Defaults to 2 hours.
45+
/// </remarks>
3146
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
3247

3348
/// <summary>
34-
/// The maximum number of idle sessions to track. This is used to limit the number of sessions that can be idle at once.
49+
/// Gets or sets maximum number of idle sessions to track in memory. This is used to limit the number of sessions that can be idle at once.
50+
/// </summary>
51+
/// <remarks>
3552
/// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached
3653
/// their <see cref="IdleTimeout"/> until the idle session count is below this limit. Clients that keep their session open by
37-
/// keeping a GET request open will not count towards this limit. The default value is set to 100,000 sessions.
38-
/// </summary>
54+
/// keeping a GET request open will not count towards this limit.
55+
/// Defaults to 100,000 sessions.
56+
/// </remarks>
3957
public int MaxIdleSessionCount { get; set; } = 100_000;
4058

4159
/// <summary>

‎src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal sealed partial class IdleTrackingBackgroundService(
1212
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
1313
{
1414
// The compiler will complain about the parameter being unused otherwise despite the source generator.
15-
private ILogger _logger = logger;
15+
private readonly ILogger _logger = logger;
1616

1717
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
1818
{

‎src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,27 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo
3535
.WithMetadata(new AcceptsMetadata(["application/json"]))
3636
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]))
3737
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
38-
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
39-
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
40-
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
41-
42-
// Map legacy HTTP with SSE endpoints.
43-
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
44-
var sseGroup = mcpGroup.MapGroup("")
45-
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
46-
47-
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
48-
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
49-
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
50-
.WithMetadata(new AcceptsMetadata(["application/json"]))
51-
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
38+
39+
if (!streamableHttpHandler.HttpServerTransportOptions.Stateless)
40+
{
41+
// The GET and DELETE endpoints are not mapped in Stateless mode since there's no way to send unsolicited messages
42+
// for the GET to handle, and there is no server-side state for the DELETE to clean up.
43+
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
44+
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
45+
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
46+
47+
// Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests
48+
// will be handled by the same process as the /sse request.
49+
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
50+
var sseGroup = mcpGroup.MapGroup("")
51+
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
52+
53+
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
54+
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
55+
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
56+
.WithMetadata(new AcceptsMetadata(["application/json"]))
57+
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
58+
}
5259

5360
return mcpGroup;
5461
}

‎src/ModelContextProtocol.AspNetCore/SseHandler.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ public async Task HandleSseRequestAsync(HttpContext context)
3434
var requestPath = (context.Request.PathBase + context.Request.Path).ToString();
3535
var endpointPattern = requestPath[..(requestPath.LastIndexOf('/') + 1)];
3636
await using var transport = new SseResponseStreamTransport(context.Response.Body, $"{endpointPattern}message?sessionId={sessionId}");
37-
await using var httpMcpSession = new HttpMcpSession<SseResponseStreamTransport>(sessionId, transport, context.User, httpMcpServerOptions.Value.TimeProvider);
37+
38+
var userIdClaim = StreamableHttpHandler.GetUserIdClaim(context.User);
39+
await using var httpMcpSession = new HttpMcpSession<SseResponseStreamTransport>(sessionId, transport, userIdClaim, httpMcpServerOptions.Value.TimeProvider);
40+
3841
if (!_sessions.TryAdd(sessionId, httpMcpSession))
3942
{
4043
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using ModelContextProtocol.Protocol.Types;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModelContextProtocol.AspNetCore.Stateless;
5+
6+
internal sealed class StatelessSessionId
7+
{
8+
[JsonPropertyName("clientInfo")]
9+
public Implementation? ClientInfo { get; init; }
10+
11+
[JsonPropertyName("userIdClaim")]
12+
public UserIdClaim? UserIdClaim { get; init; }
13+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.AspNetCore.Stateless;
4+
5+
[JsonSerializable(typeof(StatelessSessionId))]
6+
internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace ModelContextProtocol.AspNetCore.Stateless;
2+
3+
internal sealed record UserIdClaim(string Type, string Value, string Issuer);

0 commit comments

Comments
 (0)
Please sign in to comment.