|  | 
|  | 1 | +using Microsoft.AspNetCore.Http; | 
|  | 2 | +using Microsoft.AspNetCore.Routing; | 
|  | 3 | +using Microsoft.AspNetCore.WebUtilities; | 
|  | 4 | +using Microsoft.Extensions.DependencyInjection; | 
|  | 5 | +using Microsoft.Extensions.Logging; | 
|  | 6 | +using Microsoft.Extensions.Options; | 
|  | 7 | +using ModelContextProtocol.Protocol.Messages; | 
|  | 8 | +using ModelContextProtocol.Protocol.Transport; | 
|  | 9 | +using ModelContextProtocol.Server; | 
|  | 10 | +using ModelContextProtocol.Utils.Json; | 
|  | 11 | +using System.Collections.Concurrent; | 
|  | 12 | +using System.Security.Cryptography; | 
|  | 13 | + | 
|  | 14 | +namespace Microsoft.AspNetCore.Builder; | 
|  | 15 | + | 
|  | 16 | +/// <summary> | 
|  | 17 | +/// Extension methods for <see cref="IEndpointRouteBuilder"/> to add MCP endpoints. | 
|  | 18 | +/// </summary> | 
|  | 19 | +public static class McpEndpointRouteBuilderExtensions | 
|  | 20 | +{ | 
|  | 21 | +    /// <summary> | 
|  | 22 | +    /// Sets up endpoints for handling MCP HTTP Streaming transport. | 
|  | 23 | +    /// </summary> | 
|  | 24 | +    /// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param> | 
|  | 25 | +    /// <param name="runSession">Provides an optional asynchronous callback for handling new MCP sessions.</param> | 
|  | 26 | +    /// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns> | 
|  | 27 | +    public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, Func<HttpContext, IMcpServer, CancellationToken, Task>? runSession = null) | 
|  | 28 | +    { | 
|  | 29 | +        ConcurrentDictionary<string, SseResponseStreamTransport> _sessions = new(StringComparer.Ordinal); | 
|  | 30 | + | 
|  | 31 | +        var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>(); | 
|  | 32 | +        var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>(); | 
|  | 33 | + | 
|  | 34 | +        var routeGroup = endpoints.MapGroup(""); | 
|  | 35 | + | 
|  | 36 | +        routeGroup.MapGet("/sse", async context => | 
|  | 37 | +        { | 
|  | 38 | +            var response = context.Response; | 
|  | 39 | +            var requestAborted = context.RequestAborted; | 
|  | 40 | + | 
|  | 41 | +            response.Headers.ContentType = "text/event-stream"; | 
|  | 42 | +            response.Headers.CacheControl = "no-store"; | 
|  | 43 | + | 
|  | 44 | +            var sessionId = MakeNewSessionId(); | 
|  | 45 | +            await using var transport = new SseResponseStreamTransport(response.Body, $"/message?sessionId={sessionId}"); | 
|  | 46 | +            if (!_sessions.TryAdd(sessionId, transport)) | 
|  | 47 | +            { | 
|  | 48 | +                throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created."); | 
|  | 49 | +            } | 
|  | 50 | + | 
|  | 51 | +            try | 
|  | 52 | +            { | 
|  | 53 | +                var transportTask = transport.RunAsync(cancellationToken: requestAborted); | 
|  | 54 | +                await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider); | 
|  | 55 | + | 
|  | 56 | +                try | 
|  | 57 | +                { | 
|  | 58 | +                    runSession ??= RunSession; | 
|  | 59 | +                    await runSession(context, server, requestAborted); | 
|  | 60 | +                } | 
|  | 61 | +                finally | 
|  | 62 | +                { | 
|  | 63 | +                    await transport.DisposeAsync(); | 
|  | 64 | +                    await transportTask; | 
|  | 65 | +                } | 
|  | 66 | +            } | 
|  | 67 | +            catch (OperationCanceledException) when (requestAborted.IsCancellationRequested) | 
|  | 68 | +            { | 
|  | 69 | +                // RequestAborted always triggers when the client disconnects before a complete response body is written, | 
|  | 70 | +                // but this is how SSE connections are typically closed. | 
|  | 71 | +            } | 
|  | 72 | +            finally | 
|  | 73 | +            { | 
|  | 74 | +                _sessions.TryRemove(sessionId, out _); | 
|  | 75 | +            } | 
|  | 76 | +        }); | 
|  | 77 | + | 
|  | 78 | +        routeGroup.MapPost("/message", async context => | 
|  | 79 | +        { | 
|  | 80 | +            if (!context.Request.Query.TryGetValue("sessionId", out var sessionId)) | 
|  | 81 | +            { | 
|  | 82 | +                await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context); | 
|  | 83 | +                return; | 
|  | 84 | +            } | 
|  | 85 | + | 
|  | 86 | +            if (!_sessions.TryGetValue(sessionId.ToString(), out var transport)) | 
|  | 87 | +            { | 
|  | 88 | +                await Results.BadRequest($"Session ID not found.").ExecuteAsync(context); | 
|  | 89 | +                return; | 
|  | 90 | +            } | 
|  | 91 | + | 
|  | 92 | +            var message = (IJsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)), context.RequestAborted); | 
|  | 93 | +            if (message is null) | 
|  | 94 | +            { | 
|  | 95 | +                await Results.BadRequest("No message in request body.").ExecuteAsync(context); | 
|  | 96 | +                return; | 
|  | 97 | +            } | 
|  | 98 | + | 
|  | 99 | +            await transport.OnMessageReceivedAsync(message, context.RequestAborted); | 
|  | 100 | +            context.Response.StatusCode = StatusCodes.Status202Accepted; | 
|  | 101 | +            await context.Response.WriteAsync("Accepted"); | 
|  | 102 | +        }); | 
|  | 103 | + | 
|  | 104 | +        return routeGroup; | 
|  | 105 | +    } | 
|  | 106 | + | 
|  | 107 | +    private static Task RunSession(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted) | 
|  | 108 | +        => session.RunAsync(requestAborted); | 
|  | 109 | + | 
|  | 110 | +    private static string MakeNewSessionId() | 
|  | 111 | +    { | 
|  | 112 | +        // 128 bits | 
|  | 113 | +        Span<byte> buffer = stackalloc byte[16]; | 
|  | 114 | +        RandomNumberGenerator.Fill(buffer); | 
|  | 115 | +        return WebEncoders.Base64UrlEncode(buffer); | 
|  | 116 | +    } | 
|  | 117 | +} | 
0 commit comments