Skip to content

Commit b314605

Browse files
committed
Add stateless Streamable HTTP support
- This allows a single MCP session spanning multiple requests to be handled by different servers without sharing state - This does require the servers share data protection keys, but this is standard for ASP.NET Core cookies and antiforgery as well
1 parent c750f09 commit b314605

25 files changed

Lines changed: 501 additions & 146 deletions

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: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
namespace ModelContextProtocol.AspNetCore;
66

7-
internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport transport, ClaimsPrincipal user, TimeProvider timeProvider) : IAsyncDisposable
7+
internal sealed class HttpMcpSession<TTransport>(
8+
string sessionId,
9+
TTransport transport,
10+
(string Type, string Value, string Issuer)? userIdClaim,
11+
TimeProvider timeProvider) : IAsyncDisposable
812
where TTransport : ITransport
913
{
1014
private int _referenceCount;
@@ -13,7 +17,7 @@ internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport tr
1317

1418
public string Id { get; } = sessionId;
1519
public TTransport Transport { get; } = transport;
16-
public (string Type, string Value, string Issuer)? UserIdClaim { get; } = GetUserIdClaim(user);
20+
public (string Type, string Value, string Issuer)? UserIdClaim { get; } = userIdClaim;
1721

1822
public CancellationToken SessionClosed => _disposeCts.Token;
1923

@@ -63,27 +67,7 @@ public async ValueTask DisposeAsync()
6367
}
6468

6569
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-
}
70+
=> UserIdClaim == StreamableHttpHandler.GetUserIdClaim(user);
8771

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

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ public class HttpServerTransportOptions
2222
/// </summary>
2323
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
2424

25+
/// <summary>
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. If true, the /sse endpoint will be disabled, and
28+
/// client capabilities will be round-tripped as part of the mcp-session-id header instead of stored in memory. Defaults to false.
29+
/// </summary>
30+
public bool Stateless { get; set; }
31+
2532
/// <summary>
2633
/// Represents the duration of time the server will wait between any active requests before timing out an
2734
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using ModelContextProtocol.Protocol.Types;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModelContextProtocol.AspNetCore;
5+
6+
internal class StatelessSessionId
7+
{
8+
[JsonPropertyName("capabilities")]
9+
public ClientCapabilities? Capabilities { get; init; }
10+
11+
[JsonPropertyName("clientInfo")]
12+
public Implementation? ClientInfo { get; init; }
13+
14+
[JsonPropertyName("userIdClaim")]
15+
public (string Type, string Value, string Issuer)? UserIdClaim { get; init; }
16+
}
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;
4+
5+
[JsonSerializable(typeof(StatelessSessionId))]
6+
internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext;

0 commit comments

Comments
 (0)