Skip to content

Commit 9c124b1

Browse files
authored
Add StreamableHttpHandler and WithHttpTransport (#291)
* Simplify ModelContextProtocol.AspNetCore README * Add StreamableHttpHandler * Use "docker info" in CheckIsDockerAvailable * Call WithHttpTransport in samples, tests and README * Cleanup test namespaces * Add CanConnect_WithMcpClient_AfterCustomizingRoute test * Simplify relative URI handling * Handle request made directly to the MapMcp route pattern * Add Messages_FromNewUser_AreRejected test * Fix README * Shorten UserIdClaim ValueTuple names * Remove MaxReconnectAttempts and ReconnectDelay from SseClientTransportOptions - Add proper AdditionalHeaders support
1 parent bca5d26 commit 9c124b1

24 files changed

+597
-324
lines changed

samples/AspNetCoreSseServer/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
var builder = WebApplication.CreateBuilder(args);
77
builder.Services.AddMcpServer()
8+
.WithHttpTransport()
89
.WithTools<EchoTool>()
910
.WithTools<SampleLlmTool>();
1011

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Microsoft.Extensions.DependencyInjection.Extensions;
2+
using ModelContextProtocol.AspNetCore;
3+
using ModelContextProtocol.Server;
4+
5+
namespace Microsoft.Extensions.DependencyInjection;
6+
7+
/// <summary>
8+
/// Provides methods for configuring HTTP MCP servers via dependency injection.
9+
/// </summary>
10+
public static class HttpMcpServerBuilderExtensions
11+
{
12+
/// <summary>
13+
/// Adds the services necessary for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>
14+
/// to handle MCP requests and sessions using the MCP HTTP Streaming transport. For more information on configuring the underlying HTTP server
15+
/// 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>.
16+
/// </summary>
17+
/// <param name="builder">The builder instance.</param>
18+
/// <param name="configureOptions">Configures options for the HTTP Streaming transport. This allows configuring per-session
19+
/// <see cref="McpServerOptions"/> and running logic before and after a session.</param>
20+
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
21+
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
22+
public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder, Action<HttpServerTransportOptions>? configureOptions = null)
23+
{
24+
ArgumentNullException.ThrowIfNull(builder);
25+
builder.Services.TryAddSingleton<StreamableHttpHandler>();
26+
27+
if (configureOptions is not null)
28+
{
29+
builder.Services.Configure(configureOptions);
30+
}
31+
32+
return builder;
33+
}
34+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using ModelContextProtocol.Protocol.Transport;
2+
using System.Security.Claims;
3+
4+
namespace ModelContextProtocol.AspNetCore;
5+
6+
internal class HttpMcpSession
7+
{
8+
public HttpMcpSession(SseResponseStreamTransport transport, ClaimsPrincipal user)
9+
{
10+
Transport = transport;
11+
UserIdClaim = GetUserIdClaim(user);
12+
}
13+
14+
public SseResponseStreamTransport Transport { get; }
15+
public (string Type, string Value, string Issuer)? UserIdClaim { get; }
16+
17+
public bool HasSameUserId(ClaimsPrincipal user)
18+
=> UserIdClaim == GetUserIdClaim(user);
19+
20+
// SignalR only checks for ClaimTypes.NameIdentifier in HttpConnectionDispatcher, but AspNetCore.Antiforgery checks that plus the sub and UPN claims.
21+
// However, we short-circuit unlike antiforgery since we expect to call this to verify MCP messages a lot more frequently than
22+
// verifying antiforgery tokens from <form> posts.
23+
private static (string Type, string Value, string Issuer)? GetUserIdClaim(ClaimsPrincipal user)
24+
{
25+
if (user?.Identity?.IsAuthenticated != true)
26+
{
27+
return null;
28+
}
29+
30+
var claim = user.FindFirst(ClaimTypes.NameIdentifier) ?? user.FindFirst("sub") ?? user.FindFirst(ClaimTypes.Upn);
31+
32+
if (claim is { } idClaim)
33+
{
34+
return (idClaim.Type, idClaim.Value, idClaim.Issuer);
35+
}
36+
37+
return null;
38+
}
39+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Microsoft.AspNetCore.Http;
2+
using ModelContextProtocol.Server;
3+
4+
namespace ModelContextProtocol.AspNetCore;
5+
6+
/// <summary>
7+
/// Configuration options for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>.
8+
/// which implements the Streaming HTTP transport for the Model Context Protocol.
9+
/// See the protocol specification for details on the Streamable HTTP transport. <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http"/>
10+
/// </summary>
11+
public class HttpServerTransportOptions
12+
{
13+
/// <summary>
14+
/// Gets or sets an optional asynchronous callback to configure per-session <see cref="McpServerOptions"/>
15+
/// with access to the <see cref="HttpContext"/> of the request that initiated the session.
16+
/// </summary>
17+
public Func<HttpContext, McpServerOptions, CancellationToken, Task>? ConfigureSessionOptions { get; set; }
18+
19+
/// <summary>
20+
/// Gets or sets an optional asynchronous callback for running new MCP sessions manually.
21+
/// This is useful for running logic before a sessions starts and after it completes.
22+
/// </summary>
23+
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
24+
}
Lines changed: 9 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
1-
using Microsoft.AspNetCore.Http;
2-
using Microsoft.AspNetCore.Http.Features;
3-
using Microsoft.AspNetCore.Routing;
4-
using Microsoft.AspNetCore.Routing.Patterns;
5-
using Microsoft.AspNetCore.WebUtilities;
1+
using Microsoft.AspNetCore.Routing;
62
using Microsoft.Extensions.DependencyInjection;
7-
using Microsoft.Extensions.Hosting;
8-
using Microsoft.Extensions.Logging;
9-
using Microsoft.Extensions.Options;
10-
using ModelContextProtocol.Protocol.Messages;
11-
using ModelContextProtocol.Protocol.Transport;
12-
using ModelContextProtocol.Server;
13-
using ModelContextProtocol.Utils.Json;
14-
using System.Collections.Concurrent;
3+
using ModelContextProtocol.AspNetCore;
154
using System.Diagnostics.CodeAnalysis;
16-
using System.Security.Cryptography;
175

186
namespace Microsoft.AspNetCore.Builder;
197

@@ -24,136 +12,20 @@ public static class McpEndpointRouteBuilderExtensions
2412
{
2513
/// <summary>
2614
/// Sets up endpoints for handling MCP HTTP Streaming transport.
15+
/// 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.
2716
/// </summary>
2817
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
2918
/// <param name="pattern">The route pattern prefix to map to.</param>
30-
/// <param name="configureOptionsAsync">Configure per-session options.</param>
31-
/// <param name="runSessionAsync">Provides an optional asynchronous callback for handling new MCP sessions.</param>
3219
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
33-
public static IEndpointConventionBuilder MapMcp(
34-
this IEndpointRouteBuilder endpoints,
35-
[StringSyntax("Route")] string pattern = "",
36-
Func<HttpContext, McpServerOptions, CancellationToken, Task>? configureOptionsAsync = null,
37-
Func<HttpContext, IMcpServer, CancellationToken, Task>? runSessionAsync = null)
38-
=> endpoints.MapMcp(RoutePatternFactory.Parse(pattern), configureOptionsAsync, runSessionAsync);
39-
40-
/// <summary>
41-
/// Sets up endpoints for handling MCP HTTP Streaming transport.
42-
/// </summary>
43-
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
44-
/// <param name="pattern">The route pattern prefix to map to.</param>
45-
/// <param name="configureOptionsAsync">Configure per-session options.</param>
46-
/// <param name="runSessionAsync">Provides an optional asynchronous callback for handling new MCP sessions.</param>
47-
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
48-
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints,
49-
RoutePattern pattern,
50-
Func<HttpContext, McpServerOptions, CancellationToken, Task>? configureOptionsAsync = null,
51-
Func<HttpContext, IMcpServer, CancellationToken, Task>? runSessionAsync = null)
20+
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
5221
{
53-
ConcurrentDictionary<string, SseResponseStreamTransport> _sessions = new(StringComparer.Ordinal);
54-
55-
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
56-
var optionsSnapshot = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
57-
var optionsFactory = endpoints.ServiceProvider.GetRequiredService<IOptionsFactory<McpServerOptions>>();
58-
var hostApplicationLifetime = endpoints.ServiceProvider.GetRequiredService<IHostApplicationLifetime>();
22+
var handler = endpoints.ServiceProvider.GetService<StreamableHttpHandler>() ??
23+
throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code.");
5924

6025
var routeGroup = endpoints.MapGroup(pattern);
61-
62-
routeGroup.MapGet("/sse", async context =>
63-
{
64-
// If the server is shutting down, we need to cancel all SSE connections immediately without waiting for HostOptions.ShutdownTimeout
65-
// which defaults to 30 seconds.
66-
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
67-
var cancellationToken = sseCts.Token;
68-
69-
var response = context.Response;
70-
response.Headers.ContentType = "text/event-stream";
71-
response.Headers.CacheControl = "no-cache,no-store";
72-
73-
// Make sure we disable all response buffering for SSE
74-
context.Response.Headers.ContentEncoding = "identity";
75-
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
76-
77-
var sessionId = MakeNewSessionId();
78-
await using var transport = new SseResponseStreamTransport(response.Body, $"/message?sessionId={sessionId}");
79-
if (!_sessions.TryAdd(sessionId, transport))
80-
{
81-
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
82-
}
83-
84-
var options = optionsSnapshot.Value;
85-
if (configureOptionsAsync is not null)
86-
{
87-
options = optionsFactory.Create(Options.DefaultName);
88-
await configureOptionsAsync.Invoke(context, options, cancellationToken);
89-
}
90-
91-
try
92-
{
93-
var transportTask = transport.RunAsync(cancellationToken);
94-
95-
try
96-
{
97-
await using var mcpServer = McpServerFactory.Create(transport, options, loggerFactory, endpoints.ServiceProvider);
98-
context.Features.Set(mcpServer);
99-
100-
runSessionAsync ??= RunSession;
101-
await runSessionAsync(context, mcpServer, cancellationToken);
102-
}
103-
finally
104-
{
105-
await transport.DisposeAsync();
106-
await transportTask;
107-
}
108-
}
109-
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
110-
{
111-
// RequestAborted always triggers when the client disconnects before a complete response body is written,
112-
// but this is how SSE connections are typically closed.
113-
}
114-
finally
115-
{
116-
_sessions.TryRemove(sessionId, out _);
117-
}
118-
});
119-
120-
routeGroup.MapPost("/message", async context =>
121-
{
122-
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
123-
{
124-
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
125-
return;
126-
}
127-
128-
if (!_sessions.TryGetValue(sessionId.ToString(), out var transport))
129-
{
130-
await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
131-
return;
132-
}
133-
134-
var message = (IJsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)), context.RequestAborted);
135-
if (message is null)
136-
{
137-
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
138-
return;
139-
}
140-
141-
await transport.OnMessageReceivedAsync(message, context.RequestAborted);
142-
context.Response.StatusCode = StatusCodes.Status202Accepted;
143-
await context.Response.WriteAsync("Accepted");
144-
});
145-
26+
routeGroup.MapGet("", handler.HandleRequestAsync);
27+
routeGroup.MapGet("/sse", handler.HandleRequestAsync);
28+
routeGroup.MapPost("/message", handler.HandleRequestAsync);
14629
return routeGroup;
14730
}
148-
149-
private static Task RunSession(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
150-
=> session.RunAsync(requestAborted);
151-
152-
private static string MakeNewSessionId()
153-
{
154-
// 128 bits
155-
Span<byte> buffer = stackalloc byte[16];
156-
RandomNumberGenerator.Fill(buffer);
157-
return WebEncoders.Base64UrlEncode(buffer);
158-
}
15931
}

src/ModelContextProtocol.AspNetCore/README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,14 @@ using ModelContextProtocol.Server;
3434
using System.ComponentModel;
3535

3636
var builder = WebApplication.CreateBuilder(args);
37-
builder.WebHost.ConfigureKestrel(options =>
38-
{
39-
options.ListenLocalhost(3001);
40-
});
41-
builder.Services.AddMcpServer().WithToolsFromAssembly();
37+
builder.Services.AddMcpServer()
38+
.WithHttpTransport()
39+
.WithToolsFromAssembly();
4240
var app = builder.Build();
4341

4442
app.MapMcp();
4543

46-
app.Run();
44+
app.Run("http://localhost:3001");
4745

4846
[McpServerToolType]
4947
public static class EchoTool

0 commit comments

Comments
 (0)