csharp-sdk/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Stephen Halter 47b9582fd7
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
2025-10-02 15:57:01 -07:00

309 lines
12 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore;
internal sealed class StreamableHttpHandler(
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
StatefulSessionManager sessionManager,
ILoggerFactory loggerFactory,
IServiceProvider applicationServices)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
public async Task HandlePostRequestAsync(HttpContext context)
{
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
// but it's probably good to at least start out trying to be strict.
var typedHeaders = context.Request.GetTypedHeaders();
if (!typedHeaders.Accept.Any(MatchesApplicationJsonMediaType) || !typedHeaders.Accept.Any(MatchesTextEventStreamMediaType))
{
await WriteJsonRpcErrorAsync(context,
"Not Acceptable: Client must accept both application/json and text/event-stream",
StatusCodes.Status406NotAcceptable);
return;
}
var session = await GetOrCreateSessionAsync(context);
if (session is null)
{
return;
}
await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
var message = await ReadJsonRpcMessageAsync(context);
if (message is null)
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The POST body did not contain a valid JSON-RPC message.",
StatusCodes.Status400BadRequest);
return;
}
InitializeSseResponse(context);
var wroteResponse = await session.Transport.HandlePostRequest(message, context.Response.Body, context.RequestAborted);
if (!wroteResponse)
{
// We wound up writing nothing, so there should be no Content-Type response header.
context.Response.Headers.ContentType = (string?)null;
context.Response.StatusCode = StatusCodes.Status202Accepted;
}
}
public async Task HandleGetRequestAsync(HttpContext context)
{
if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType))
{
await WriteJsonRpcErrorAsync(context,
"Not Acceptable: Client must accept text/event-stream",
StatusCodes.Status406NotAcceptable);
return;
}
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
var session = await GetSessionAsync(context, sessionId);
if (session is null)
{
return;
}
if (!session.TryStartGetRequest())
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: This server does not support multiple GET requests. Start a new session to get a new GET SSE response.",
StatusCodes.Status400BadRequest);
return;
}
await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
InitializeSseResponse(context);
// We should flush headers to indicate a 200 success quickly, because the initialization response
// will be sent in response to a different POST request. It might be a while before we send a message
// over this response body.
await context.Response.Body.FlushAsync(context.RequestAborted);
await session.Transport.HandleGetRequest(context.Response.Body, context.RequestAborted);
}
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (sessionManager.TryRemove(sessionId, out var session))
{
await session.DisposeAsync();
}
}
private async ValueTask<StreamableHttpSession?> GetSessionAsync(HttpContext context, string sessionId)
{
StreamableHttpSession? session;
if (string.IsNullOrEmpty(sessionId))
{
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
return null;
}
else if (!sessionManager.TryGetValue(sessionId, out session))
{
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
// JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound
// https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001);
return null;
}
if (!session.HasSameUserId(context.User))
{
await WriteJsonRpcErrorAsync(context,
"Forbidden: The currently authenticated user does not match the user who initiated the session.",
StatusCodes.Status403Forbidden);
return null;
}
context.Response.Headers[McpSessionIdHeaderName] = session.Id;
context.Features.Set(session.Server);
return session;
}
private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (string.IsNullOrEmpty(sessionId))
{
return await StartNewSessionAsync(context);
}
else if (HttpServerTransportOptions.Stateless)
{
// In stateless mode, we should not be getting existing sessions via sessionId
// This path should not be reached in stateless mode
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest);
return null;
}
else
{
return await GetSessionAsync(context, sessionId);
}
}
private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext context)
{
string sessionId;
StreamableHttpServerTransport transport;
if (!HttpServerTransportOptions.Stateless)
{
sessionId = MakeNewSessionId();
transport = new()
{
SessionId = sessionId,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
};
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
}
else
{
// In stateless mode, each request is independent. Don't set any session ID on the transport.
sessionId = "";
transport = new()
{
Stateless = true,
};
}
return await CreateSessionAsync(context, transport, sessionId);
}
private async ValueTask<StreamableHttpSession> CreateSessionAsync(
HttpContext context,
StreamableHttpServerTransport transport,
string sessionId)
{
var mcpServerServices = applicationServices;
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
if (HttpServerTransportOptions.Stateless)
{
// The session does not outlive the request in stateless mode.
mcpServerServices = context.RequestServices;
mcpServerOptions.ScopeRequests = false;
}
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
{
await configureSessionOptions(context, mcpServerOptions, context.RequestAborted);
}
}
var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
context.Features.Set(server);
var userIdClaim = GetUserIdClaim(context.User);
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
return session;
}
private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000)
{
var jsonRpcError = new JsonRpcError
{
Error = new()
{
Code = errorCode,
Message = errorMessage,
},
};
return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context);
}
internal static void InitializeSseResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache,no-store";
// Make sure we disable all response buffering for SSE.
context.Response.Headers.ContentEncoding = "identity";
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
}
internal static string MakeNewSessionId()
{
Span<byte> buffer = stackalloc byte[16];
RandomNumberGenerator.Fill(buffer);
return WebEncoders.Base64UrlEncode(buffer);
}
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
{
// Implementation for reading a JSON-RPC message from the request body
var message = await context.Request.ReadFromJsonAsync(s_messageTypeInfo, context.RequestAborted);
if (context.User?.Identity?.IsAuthenticated == true && message is not null)
{
message.Context = new()
{
User = context.User,
};
}
return message;
}
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
=> session.RunAsync(requestAborted);
// SignalR only checks for ClaimTypes.NameIdentifier in HttpConnectionDispatcher, but AspNetCore.Antiforgery checks that plus the sub and UPN claims.
// However, we short-circuit unlike antiforgery since we expect to call this to verify MCP messages a lot more frequently than
// verifying antiforgery tokens from <form> posts.
internal static UserIdClaim? GetUserIdClaim(ClaimsPrincipal user)
{
if (user?.Identity?.IsAuthenticated != true)
{
return null;
}
var claim = user.FindFirst(ClaimTypes.NameIdentifier) ?? user.FindFirst("sub") ?? user.FindFirst(ClaimTypes.Upn);
if (claim is { } idClaim)
{
return new(idClaim.Type, idClaim.Value, idClaim.Issuer);
}
return null;
}
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("application/json");
private static bool MatchesTextEventStreamMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("text/event-stream");
}