Skip to content

Add StreamableHttpHandler and WithHttpTransport #291

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 14, 2025
Merged
1 change: 1 addition & 0 deletions samples/AspNetCoreSseServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<EchoTool>()
.WithTools<SampleLlmTool>();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Provides methods for configuring HTTP MCP servers via dependency injection.
/// </summary>
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 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
/// <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>
public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder, Action<HttpServerTransportOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<StreamableHttpHandler>();

if (configureOptions is not null)
{
builder.Services.Configure(configureOptions);
}

return builder;
}
}
39 changes: 39 additions & 0 deletions src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using ModelContextProtocol.Protocol.Transport;
using System.Security.Claims;

namespace ModelContextProtocol.AspNetCore;

internal class HttpMcpSession
{
public HttpMcpSession(SseResponseStreamTransport transport, ClaimsPrincipal user)
{
Transport = transport;
UserIdClaim = GetUserIdClaim(user);
}

public SseResponseStreamTransport Transport { get; }
public (string Type, string Value, string Issuer)? UserIdClaim { get; }

public bool HasSameUserId(ClaimsPrincipal user)
=> UserIdClaim == GetUserIdClaim(user);

// 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.
private static (string Type, string Value, string Issuer)? 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 (idClaim.Type, idClaim.Value, idClaim.Issuer);
}

return null;
}
}
24 changes: 24 additions & 0 deletions src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Server;

namespace ModelContextProtocol.AspNetCore;

/// <summary>
/// Configuration options for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>.
/// which implements the Streaming HTTP transport for the Model Context Protocol.
/// See the protocol specification for details on the Streamable HTTP transport. <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http"/>
/// </summary>
public class HttpServerTransportOptions
{
/// <summary>
/// Gets or sets an optional asynchronous callback to configure per-session <see cref="McpServerOptions"/>
/// with access to the <see cref="HttpContext"/> of the request that initiated the session.
/// </summary>
public Func<HttpContext, McpServerOptions, CancellationToken, Task>? ConfigureSessionOptions { get; set; }

/// <summary>
/// Gets or sets an optional asynchronous callback for running new MCP sessions manually.
/// This is useful for running logic before a sessions starts and after it completes.
/// </summary>
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
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 ModelContextProtocol.AspNetCore;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;

namespace Microsoft.AspNetCore.Builder;

Expand All @@ -24,136 +12,20 @@ public static class McpEndpointRouteBuilderExtensions
{
/// <summary>
/// Sets up endpoints for handling MCP HTTP Streaming transport.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we don't want it to automatically do WithHttpTransport? Could/should it do it but then if a user calls it themselves it overrides whatever default MapMcp would have used?

Copy link
Contributor Author

@halter73 halter73 Apr 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's typical for things that need to configure routes like Mvc and SignalR to require that you call two methods: one to configure service, and then another to add the routes. For MVC, it's AddMvc and MapControllers/MapControllerRoutes. For SignalR, it's AddSignalR and MapHub. Even for minimal APIs, it's AddRouting, UseRouting, and MapGet/Post, but AddRouting and UseRouting get added explicitly for WebApplicationBuilder.

This is why I was so hesitant to add WithHttpTransport. The MapMcp call is the one thing we cannot easily get rid of. We could get rid of WithHttpTransport if we newed up the StreamableHttpHandler and any other services we need when they were missing, but it would be inconvenient for us as MapMcp implementers. And it is very conventional to need to call both an "Add" and a "Map" method, even if I'm personally not a fan of requiring the extra code.

/// 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.
/// </summary>
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
/// <param name="pattern">The route pattern prefix to map to.</param>
/// <param name="configureOptionsAsync">Configure per-session options.</param>
/// <param name="runSessionAsync">Provides an optional asynchronous callback for handling new MCP sessions.</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 = "",
Func<HttpContext, McpServerOptions, CancellationToken, Task>? configureOptionsAsync = null,
Func<HttpContext, IMcpServer, CancellationToken, Task>? runSessionAsync = null)
=> endpoints.MapMcp(RoutePatternFactory.Parse(pattern), configureOptionsAsync, runSessionAsync);

/// <summary>
/// Sets up endpoints for handling MCP HTTP Streaming 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>
/// <param name="configureOptionsAsync">Configure per-session options.</param>
/// <param name="runSessionAsync">Provides an optional asynchronous callback for handling new MCP sessions.</param>
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints,
RoutePattern pattern,
Func<HttpContext, McpServerOptions, CancellationToken, Task>? configureOptionsAsync = null,
Func<HttpContext, IMcpServer, CancellationToken, Task>? runSessionAsync = null)
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
{
ConcurrentDictionary<string, SseResponseStreamTransport> _sessions = new(StringComparer.Ordinal);

var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var optionsSnapshot = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
var optionsFactory = endpoints.ServiceProvider.GetRequiredService<IOptionsFactory<McpServerOptions>>();
var hostApplicationLifetime = endpoints.ServiceProvider.GetRequiredService<IHostApplicationLifetime>();
var handler = 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("/sse", async 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 response = context.Response;
response.Headers.ContentType = "text/event-stream";
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}");
if (!_sessions.TryAdd(sessionId, transport))
{
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
}

var options = optionsSnapshot.Value;
if (configureOptionsAsync is not null)
{
options = optionsFactory.Create(Options.DefaultName);
await configureOptionsAsync.Invoke(context, options, cancellationToken);
}

try
{
var transportTask = transport.RunAsync(cancellationToken);

try
{
await using var mcpServer = McpServerFactory.Create(transport, options, loggerFactory, endpoints.ServiceProvider);
context.Features.Set(mcpServer);

runSessionAsync ??= RunSession;
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 _);
}
});

routeGroup.MapPost("/message", async 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 transport))
{
await Results.BadRequest($"Session ID not found.").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 transport.OnMessageReceivedAsync(message, context.RequestAborted);
context.Response.StatusCode = StatusCodes.Status202Accepted;
await context.Response.WriteAsync("Accepted");
});

routeGroup.MapGet("", handler.HandleRequestAsync);
routeGroup.MapGet("/sse", handler.HandleRequestAsync);
routeGroup.MapPost("/message", handler.HandleRequestAsync);
return routeGroup;
}

private static Task RunSession(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);
}
}
10 changes: 4 additions & 6 deletions src/ModelContextProtocol.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,14 @@ using ModelContextProtocol.Server;
using System.ComponentModel;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(3001);
});
builder.Services.AddMcpServer().WithToolsFromAssembly();
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
var app = builder.Build();

app.MapMcp();

app.Run();
app.Run("http://localhost:3001");

[McpServerToolType]
public static class EchoTool
Expand Down
Loading