diff --git a/src/ModelContextProtocol.AspNetCore/DefaultMcpEndpointRouteBuilderConfigurator.cs b/src/ModelContextProtocol.AspNetCore/DefaultMcpEndpointRouteBuilderConfigurator.cs new file mode 100644 index 00000000..df5e3a62 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/DefaultMcpEndpointRouteBuilderConfigurator.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Default implementation that wires up the MCP Streamable HTTP transport endpoints +/// (and legacy SSE endpoints when applicable). +/// +internal sealed class DefaultMcpEndpointRouteBuilderConfigurator( + StreamableHttpHandler streamableHttpHandler +) : IMcpEndpointRouteBuilderConfigurator +{ + public IEndpointConventionBuilder Configure(IEndpointRouteBuilder endpoints, string pattern) + { + 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)); + + if (!streamableHttpHandler.HttpServerTransportOptions.Stateless) + { + // The GET and DELETE endpoints are not mapped in Stateless mode since there's no way to send unsolicited messages + // for the GET to handle, and there is no server-side state for the DELETE to clean up. + streamableHttpGroup + .MapGet("", streamableHttpHandler.HandleGetRequestAsync) + .WithMetadata( + new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, + contentTypes: ["text/event-stream"] + ) + ); + streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync); + + // Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests + // will be handled by the same process as the /sse request. + var sseHandler = endpoints.ServiceProvider.GetRequiredService(); + 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; + } + + IEndpointConventionBuilder IMcpEndpointRouteBuilderConfigurator.Configure(IEndpointRouteBuilder endpoints, string pattern) + { + throw new NotImplementedException(); + } +} diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 0cdc4e37..25e4db04 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -28,6 +28,11 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder builder.Services.AddHostedService(); builder.Services.AddDataProtection(); + builder.Services.TryAddSingleton< + IMcpEndpointRouteBuilderConfigurator, + DefaultMcpEndpointRouteBuilderConfigurator + >(); + if (configureOptions is not null) { builder.Services.Configure(configureOptions); diff --git a/src/ModelContextProtocol.AspNetCore/IMcpEndpointRouteBuilderConfigurator.cs b/src/ModelContextProtocol.AspNetCore/IMcpEndpointRouteBuilderConfigurator.cs new file mode 100644 index 00000000..ddb59deb --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/IMcpEndpointRouteBuilderConfigurator.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Abstraction for configuring MCP endpoints on an . +/// Register an implementation in DI to override the default MapMcp behavior and enable hot swapping +/// of the transport/endpoints wiring without changing application code. +/// +public interface IMcpEndpointRouteBuilderConfigurator +{ + /// + /// Configures the MCP endpoints on the provided using the given . + /// Implementations should return the representing the mapped group + /// to allow callers to apply additional endpoint conventions (e.g., authorization). + /// + /// The endpoint route builder to attach MCP endpoints to. + /// The route pattern prefix to map to. + /// An that can be used to configure conventions. + IEndpointConventionBuilder Configure(IEndpointRouteBuilder endpoints, string pattern); +} diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index e7c5f97c..32813f38 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -1,9 +1,6 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Builder; @@ -16,47 +13,20 @@ public static class McpEndpointRouteBuilderExtensions /// /// Sets up endpoints for handling MCP Streamable HTTP transport. /// See the 2025-06-18 protocol specification for details about the Streamable HTTP transport. - /// Also maps legacy SSE endpoints for backward compatibility at the path "/sse" and "/message". the 2024-11-05 protocol specification for details about the HTTP with SSE transport. /// /// The web application to attach MCP HTTP endpoints. /// The route pattern prefix to map to. /// Returns a builder for configuring additional endpoint conventions like authorization policies. - public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + public static IEndpointConventionBuilder MapMcp( + this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string pattern = "" + ) { - var streamableHttpHandler = endpoints.ServiceProvider.GetService() ?? - throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code."); - - 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)); - - if (!streamableHttpHandler.HttpServerTransportOptions.Stateless) - { - // The GET and DELETE endpoints are not mapped in Stateless mode since there's no way to send unsolicited messages - // for the GET to handle, and there is no server-side state for the DELETE to clean up. - streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync) - .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); - streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync); - - // Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests - // will be handled by the same process as the /sse request. - var sseHandler = endpoints.ServiceProvider.GetRequiredService(); - 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; + var configurator = + endpoints.ServiceProvider.GetService() + ?? throw new InvalidOperationException( + "You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code." + ); + return configurator.Configure(endpoints, pattern); } }