Add server-side Streamable HTTP transport support (#330)

- Changes IJsonRpcMessage to an abstract base class so RelatedTransport will always be available
- Streamable HTTP supports multiple concurrent HTTP request with their own indpendent SSE response streams
- RelatedTransport indicates the source or destination of the JsonRpcMessage

- Changes the default RequestId to a JSON number for better compatibility with MCP servers in the wild
This commit is contained in:
Stephen Halter 2025-04-18 17:07:23 -07:00 • committed by GitHub
commit 7dcba357d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1637 additions and 395 deletions

View file

@ -11,11 +11,11 @@ public static class HttpMcpServerBuilderExtensions
{
/// <summary>
/// Adds the services necessary for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>
/// to handle MCP requests and sessions using the MCP HTTP Streaming transport. For more information on configuring the underlying HTTP server
/// to handle MCP requests and sessions using the MCP Streamable HTTP transport. For more information on configuring the underlying HTTP server
/// to control things like port binding custom TLS certificates, see the <see href="https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis">Minimal APIs quick reference</see>.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="configureOptions">Configures options for the HTTP Streaming transport. This allows configuring per-session
/// <param name="configureOptions">Configures options for the Streamable HTTP transport. This allows configuring per-session
/// <see cref="McpServerOptions"/> and running logic before and after a session.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
@ -23,6 +23,8 @@ public static class HttpMcpServerBuilderExtensions
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<StreamableHttpHandler>();
builder.Services.TryAddSingleton<SseHandler>();
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
if (configureOptions is not null)
{

View file

@ -1,18 +1,61 @@
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
using System.Security.Claims;
namespace ModelContextProtocol.AspNetCore;
internal class HttpMcpSession
internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport transport, ClaimsPrincipal user, TimeProvider timeProvider) : IAsyncDisposable
where TTransport : ITransport
{
public HttpMcpSession(SseResponseStreamTransport transport, ClaimsPrincipal user)
private int _referenceCount;
private int _getRequestStarted;
private CancellationTokenSource _disposeCts = new();
public string Id { get; } = sessionId;
public TTransport Transport { get; } = transport;
public (string Type, string Value, string Issuer)? UserIdClaim { get; } = GetUserIdClaim(user);
public CancellationToken SessionClosed => _disposeCts.Token;
public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0;
public long LastActivityTicks { get; private set; } = timeProvider.GetTimestamp();
public IMcpServer? Server { get; set; }
public Task? ServerRunTask { get; set; }
public IDisposable AcquireReference()
{
Transport = transport;
UserIdClaim = GetUserIdClaim(user);
Interlocked.Increment(ref _referenceCount);
return new UnreferenceDisposable(this, timeProvider);
}
public SseResponseStreamTransport Transport { get; }
public (string Type, string Value, string Issuer)? UserIdClaim { get; }
public bool TryStartGetRequest() => Interlocked.Exchange(ref _getRequestStarted, 1) == 0;
public async ValueTask DisposeAsync()
{
try
{
await _disposeCts.CancelAsync();
if (ServerRunTask is not null)
{
await ServerRunTask;
}
}
catch (OperationCanceledException)
{
}
finally
{
if (Server is not null)
{
await Server.DisposeAsync();
}
await Transport.DisposeAsync();
_disposeCts.Dispose();
}
}
public bool HasSameUserId(ClaimsPrincipal user)
=> UserIdClaim == GetUserIdClaim(user);
@ -36,4 +79,15 @@ internal class HttpMcpSession
return null;
}
private sealed class UnreferenceDisposable(HttpMcpSession<TTransport> session, TimeProvider timeProvider) : IDisposable
{
public void Dispose()
{
if (Interlocked.Decrement(ref session._referenceCount) == 0)
{
session.LastActivityTicks = timeProvider.GetTimestamp();
}
}
}
}

View file

@ -21,4 +21,17 @@ public class HttpServerTransportOptions
/// This is useful for running logic before a sessions starts and after it completes.
/// </summary>
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
/// <summary>
/// Represents the duration of time the server will wait between any active requests before timing out an
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will
/// receive a 404 status code and should restart their session. A client can keep their session open by
/// keeping a GET request open. The default value is set to 2 minutes.
/// </summary>
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Used for testing the <see cref="IdleTimeout"/>.
/// </summary>
public TimeProvider TimeProvider { get; set; } = TimeProvider.System;
}

View file

@ -0,0 +1,105 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Transport;
namespace ModelContextProtocol.AspNetCore;
internal sealed partial class IdleTrackingBackgroundService(
StreamableHttpHandler handler,
IOptions<HttpServerTransportOptions> options,
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
{
// The compiler will complain about the parameter being unused otherwise despite the source generator.
private ILogger _logger = logger;
// We can make this configurable once we properly harden the MCP server. In the meantime, anyone running
// this should be taking a cattle not pets approach to their servers and be able to launch more processes
// to handle more than 10,000 idle sessions at a time.
private const int MaxIdleSessionCount = 10_000;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var timeProvider = options.Value.TimeProvider;
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
try
{
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
var idleActivityCutoff = timeProvider.GetTimestamp() - options.Value.IdleTimeout.Ticks;
var idleCount = 0;
foreach (var (_, session) in handler.Sessions)
{
if (session.IsActive || session.SessionClosed.IsCancellationRequested)
{
// There's a request currently active or the session is already being closed.
continue;
}
idleCount++;
if (idleCount == MaxIdleSessionCount)
{
// Emit critical log at most once every 5 seconds the idle count it exceeded,
//since the IdleTimeout will no longer be respected.
LogMaxSessionIdleCountExceeded();
}
else if (idleCount < MaxIdleSessionCount && session.LastActivityTicks > idleActivityCutoff)
{
continue;
}
if (handler.Sessions.TryRemove(session.Id, out var removedSession))
{
LogSessionIdle(removedSession.Id);
// Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
_ = DisposeSessionAsync(removedSession);
}
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
finally
{
if (stoppingToken.IsCancellationRequested)
{
List<Task> disposeSessionTasks = [];
foreach (var (sessionKey, _) in handler.Sessions)
{
if (handler.Sessions.TryRemove(sessionKey, out var session))
{
disposeSessionTasks.Add(DisposeSessionAsync(session));
}
}
await Task.WhenAll(disposeSessionTasks);
}
}
}
private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransport> session)
{
try
{
await session.DisposeAsync();
}
catch (Exception ex)
{
LogSessionDisposeError(session.Id, ex);
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "Closing idle session {sessionId}.")]
private partial void LogSessionIdle(string sessionId);
[LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded static maximum of 10,000 idle connections. Now clearing all inactive connections regardless of timeout.")]
private partial void LogMaxSessionIdleCountExceeded();
[LoggerMessage(Level = LogLevel.Error, Message = "Error disposing the IMcpServer for session {sessionId}.")]
private partial void LogSessionDisposeError(string sessionId, Exception ex);
}

View file

@ -1,6 +1,9 @@
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Protocol.Messages;
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.AspNetCore.Builder;
@ -11,21 +14,42 @@ namespace Microsoft.AspNetCore.Builder;
public static class McpEndpointRouteBuilderExtensions
{
/// <summary>
/// Sets up endpoints for handling MCP HTTP Streaming transport.
/// See <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">the protocol specification</see> for details about the Streamable HTTP transport.
/// Sets up endpoints for handling MCP Streamable HTTP transport.
/// See <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">the 2025-03-26 protocol specification</see> for details about the Streamable HTTP transport.
/// Also maps legacy SSE endpoints for backward compatibility at the path "/sse" and "/message". <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">the 2024-11-05 protocol specification</see> for details about the HTTP with SSE transport.
/// </summary>
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
/// <param name="pattern">The route pattern prefix to map to.</param>
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
{
var handler = endpoints.ServiceProvider.GetService<StreamableHttpHandler>() ??
var streamableHttpHandler = endpoints.ServiceProvider.GetService<StreamableHttpHandler>() ??
throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code.");
var routeGroup = endpoints.MapGroup(pattern);
routeGroup.MapGet("", handler.HandleRequestAsync);
routeGroup.MapGet("/sse", handler.HandleRequestAsync);
routeGroup.MapPost("/message", handler.HandleRequestAsync);
return routeGroup;
var mcpGroup = endpoints.MapGroup(pattern);
var streamableHttpGroup = mcpGroup.MapGroup("")
.WithDisplayName(b => $"MCP Streamable HTTP | {b.DisplayName}")
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status404NotFound, typeof(JsonRpcError), contentTypes: ["application/json"]));
streamableHttpGroup.MapPost("", streamableHttpHandler.HandlePostRequestAsync)
.WithMetadata(new AcceptsMetadata(["application/json"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
// Map legacy HTTP with SSE endpoints.
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
var sseGroup = mcpGroup.MapGroup("")
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
.WithMetadata(new AcceptsMetadata(["application/json"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
return mcpGroup;
}
}

View file

@ -0,0 +1,110 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using System.Collections.Concurrent;
using System.Diagnostics;
namespace ModelContextProtocol.AspNetCore;
internal sealed class SseHandler(
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
IHostApplicationLifetime hostApplicationLifetime,
ILoggerFactory loggerFactory)
{
private readonly ConcurrentDictionary<string, HttpMcpSession<SseResponseStreamTransport>> _sessions = new(StringComparer.Ordinal);
public async Task HandleSseRequestAsync(HttpContext context)
{
var sessionId = StreamableHttpHandler.MakeNewSessionId();
// If the server is shutting down, we need to cancel all SSE connections immediately without waiting for HostOptions.ShutdownTimeout
// which defaults to 30 seconds.
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
var cancellationToken = sseCts.Token;
StreamableHttpHandler.InitializeSseResponse(context);
await using var transport = new SseResponseStreamTransport(context.Response.Body, $"message?sessionId={sessionId}");
await using var httpMcpSession = new HttpMcpSession<SseResponseStreamTransport>(sessionId, transport, context.User, httpMcpServerOptions.Value.TimeProvider);
if (!_sessions.TryAdd(sessionId, httpMcpSession))
{
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
}
try
{
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
await configureSessionOptions(context, mcpServerOptions, cancellationToken);
}
var transportTask = transport.RunAsync(cancellationToken);
try
{
await using var mcpServer = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices);
httpMcpSession.Server = mcpServer;
context.Features.Set(mcpServer);
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? StreamableHttpHandler.RunSessionAsync;
httpMcpSession.ServerRunTask = runSessionAsync(context, mcpServer, cancellationToken);
await httpMcpSession.ServerRunTask;
}
finally
{
await transport.DisposeAsync();
await transportTask;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// RequestAborted always triggers when the client disconnects before a complete response body is written,
// but this is how SSE connections are typically closed.
}
finally
{
_sessions.TryRemove(sessionId, out _);
}
}
public async Task HandleMessageRequestAsync(HttpContext context)
{
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
{
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
return;
}
if (!_sessions.TryGetValue(sessionId.ToString(), out var httpMcpSession))
{
await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
return;
}
if (!httpMcpSession.HasSameUserId(context.User))
{
await Results.Forbid().ExecuteAsync(context);
return;
}
var message = (JsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), context.RequestAborted);
if (message is null)
{
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
return;
}
await httpMcpSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted);
context.Response.StatusCode = StatusCodes.Status202Accepted;
await context.Response.WriteAsync("Accepted");
}
}

View file

@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Messages;
@ -10,7 +9,9 @@ using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO.Pipelines;
using System.Security.Cryptography;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore;
@ -18,130 +19,207 @@ internal sealed class StreamableHttpHandler(
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
IHostApplicationLifetime hostApplicationLifetime,
ILoggerFactory loggerFactory)
ILoggerFactory loggerFactory,
IServiceProvider applicationServices)
{
private static JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
private readonly ConcurrentDictionary<string, HttpMcpSession> _sessions = new(StringComparer.Ordinal);
private readonly ILogger _logger = loggerFactory.CreateLogger<StreamableHttpHandler>();
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
public async Task HandleRequestAsync(HttpContext context)
public async Task HandlePostRequestAsync(HttpContext context)
{
if (context.Request.Method == HttpMethods.Get)
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
// ASP.NET Core Minimal APIs mostly ry 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 acceptHeader = context.Request.Headers.Accept.ToString();
if (!acceptHeader.Contains("application/json", StringComparison.Ordinal) ||
!acceptHeader.Contains("text/event-stream", StringComparison.Ordinal))
{
await HandleSseRequestAsync(context);
await WriteJsonRpcErrorAsync(context,
"Not Acceptable: Client must accept both application/json and text/event-stream",
StatusCodes.Status406NotAcceptable);
return;
}
else if (context.Request.Method == HttpMethods.Post)
var session = await GetOrCreateSessionAsync(context);
if (session is null)
{
await HandleMessageRequestAsync(context);
return;
}
using var _ = session.AcquireReference();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, session.SessionClosed);
InitializeSseResponse(context);
var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), cts.Token);
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)
{
var acceptHeader = context.Request.Headers.Accept.ToString();
if (!acceptHeader.Contains("application/json", StringComparison.Ordinal))
{
await WriteJsonRpcErrorAsync(context,
"Not Acceptable: Client must accept text/event-stream",
StatusCodes.Status406NotAcceptable);
return;
}
var sessionId = context.Request.Headers["mcp-session-id"].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;
}
using var _ = session.AcquireReference();
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["mcp-session-id"].ToString();
if (Sessions.TryRemove(sessionId, out var session))
{
await session.DisposeAsync();
}
}
private void InitializeSessionResponse(HttpContext context, HttpMcpSession<StreamableHttpServerTransport> session)
{
context.Response.Headers["mcp-session-id"] = session.Id;
context.Features.Set(session.Server);
}
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetSessionAsync(HttpContext context, string sessionId)
{
if (Sessions.TryGetValue(sessionId, out var existingSession))
{
if (!existingSession.HasSameUserId(context.User))
{
await WriteJsonRpcErrorAsync(context,
"Forbidden: The currently authenticated user does not match the user who initiated the session.",
StatusCodes.Status403Forbidden);
return null;
}
InitializeSessionResponse(context, existingSession);
return existingSession;
}
// -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;
}
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context)
{
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
HttpMcpSession<StreamableHttpServerTransport>? session;
if (string.IsNullOrEmpty(sessionId))
{
session = await CreateSessionAsync(context);
if (!Sessions.TryAdd(session.Id, session))
{
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
}
return session;
}
else
{
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
await context.Response.WriteAsync("Method Not Allowed");
return await GetSessionAsync(context, sessionId);
}
}
public async Task HandleSseRequestAsync(HttpContext context)
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSessionAsync(HttpContext context)
{
// If the server is shutting down, we need to cancel all SSE connections immediately without waiting for HostOptions.ShutdownTimeout
// which defaults to 30 seconds.
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
var cancellationToken = sseCts.Token;
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
await configureSessionOptions(context, mcpServerOptions, context.RequestAborted);
}
var response = context.Response;
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache,no-store";
var transport = new StreamableHttpServerTransport();
// Use application instead of request services, because the session will likely outlive the first initialization request.
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, applicationServices);
// Make sure we disable all response buffering for SSE
var session = new HttpMcpSession<StreamableHttpServerTransport>(MakeNewSessionId(), transport, context.User, httpMcpServerOptions.Value.TimeProvider)
{
Server = server,
};
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? RunSessionAsync;
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
InitializeSessionResponse(context, session);
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();
var sessionId = MakeNewSessionId();
await using var transport = new SseResponseStreamTransport(response.Body, $"message?sessionId={sessionId}");
var httpMcpSession = new HttpMcpSession(transport, context.User);
if (!_sessions.TryAdd(sessionId, httpMcpSession))
{
Debug.Fail("Unreachable given good entropy!");
throw new InvalidOperationException($"Session with ID '{sessionId}' has already been created.");
}
try
{
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
await configureSessionOptions(context, mcpServerOptions, cancellationToken);
}
var transportTask = transport.RunAsync(cancellationToken);
try
{
await using var mcpServer = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices);
context.Features.Set(mcpServer);
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? RunSessionAsync;
await runSessionAsync(context, mcpServer, cancellationToken);
}
finally
{
await transport.DisposeAsync();
await transportTask;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// RequestAborted always triggers when the client disconnects before a complete response body is written,
// but this is how SSE connections are typically closed.
}
finally
{
_sessions.TryRemove(sessionId, out _);
}
}
public async Task HandleMessageRequestAsync(HttpContext context)
internal static string MakeNewSessionId()
{
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
{
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
return;
}
if (!_sessions.TryGetValue(sessionId.ToString(), out var httpMcpSession))
{
await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
return;
}
if (!httpMcpSession.HasSameUserId(context.User))
{
await Results.Forbid().ExecuteAsync(context);
return;
}
var message = (IJsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)), context.RequestAborted);
if (message is null)
{
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
return;
}
await httpMcpSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted);
context.Response.StatusCode = StatusCodes.Status202Accepted;
await context.Response.WriteAsync("Accepted");
}
private static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
=> session.RunAsync(requestAborted);
private static string MakeNewSessionId()
{
// 128 bits
Span<byte> buffer = stackalloc byte[16];
RandomNumberGenerator.Fill(buffer);
return WebEncoders.Base64UrlEncode(buffer);
}
internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
=> session.RunAsync(requestAborted);
private static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private sealed class HttpDuplexPipe(HttpContext context) : IDuplexPipe
{
public PipeReader Input => context.Request.BodyReader;
public PipeWriter Output => context.Response.BodyWriter;
}
}