From d25782f21a24563a8f7544e23607dd9a0cf9102d Mon Sep 17 00:00:00 2001 From: Jaime Tarquino Date: Thu, 19 Feb 2026 07:08:21 -0500 Subject: [PATCH] feat: expose StreamableHttpHandler for MVC controller support Make StreamableHttpHandler public so it can be injected into traditional ASP.NET Core MVC controllers, enabling MCP server scenarios without minimal APIs. Changes: - StreamableHttpHandler: internal -> public, constructor takes IServiceProvider - Updated WithHttpTransport() XML docs to mention controller injection - Added 'Using with MVC Controllers' section to AspNetCore README - New sample: AspNetCoreMcpControllerServer - New integration tests: McpControllerTests (connect, call tool, list tools) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ModelContextProtocol.slnx | 1 + .../AspNetCoreMcpControllerServer.csproj | 13 ++ .../Controllers/McpController.cs | 22 ++++ .../AspNetCoreMcpControllerServer/Program.cs | 13 ++ .../Properties/launchSettings.json | 13 ++ .../Tools/EchoTool.cs | 14 +++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 9 ++ .../McpRequestDelegateFactory.cs | 72 +++++++++++ src/ModelContextProtocol.AspNetCore/README.md | 38 ++++++ .../McpControllerTests.cs | 117 ++++++++++++++++++ 11 files changed, 320 insertions(+) create mode 100644 samples/AspNetCoreMcpControllerServer/AspNetCoreMcpControllerServer.csproj create mode 100644 samples/AspNetCoreMcpControllerServer/Controllers/McpController.cs create mode 100644 samples/AspNetCoreMcpControllerServer/Program.cs create mode 100644 samples/AspNetCoreMcpControllerServer/Properties/launchSettings.json create mode 100644 samples/AspNetCoreMcpControllerServer/Tools/EchoTool.cs create mode 100644 samples/AspNetCoreMcpControllerServer/appsettings.Development.json create mode 100644 samples/AspNetCoreMcpControllerServer/appsettings.json create mode 100644 src/ModelContextProtocol.AspNetCore/McpRequestDelegateFactory.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/McpControllerTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 1090c5377..2c10afffd 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -39,6 +39,7 @@ + diff --git a/samples/AspNetCoreMcpControllerServer/AspNetCoreMcpControllerServer.csproj b/samples/AspNetCoreMcpControllerServer/AspNetCoreMcpControllerServer.csproj new file mode 100644 index 000000000..755bb5d81 --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/AspNetCoreMcpControllerServer.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/samples/AspNetCoreMcpControllerServer/Controllers/McpController.cs b/samples/AspNetCoreMcpControllerServer/Controllers/McpController.cs new file mode 100644 index 000000000..0870274ba --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/Controllers/McpController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ModelContextProtocol.AspNetCore; + +namespace AspNetCoreMcpControllerServer.Controllers; + +/// +/// An MVC controller that handles MCP Streamable HTTP transport requests +/// by delegating to a created by +/// . +/// +[ApiController] +[Route("mcp")] +public class McpController : ControllerBase +{ + private static readonly RequestDelegate _mcpHandler = McpRequestDelegateFactory.Create(); + + [HttpPost] + [HttpGet] + [HttpDelete] + public Task Handle() => _mcpHandler(HttpContext); +} diff --git a/samples/AspNetCoreMcpControllerServer/Program.cs b/samples/AspNetCoreMcpControllerServer/Program.cs new file mode 100644 index 000000000..4c3b38e86 --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/Program.cs @@ -0,0 +1,13 @@ +using AspNetCoreMcpControllerServer.Tools; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/AspNetCoreMcpControllerServer/Properties/launchSettings.json b/samples/AspNetCoreMcpControllerServer/Properties/launchSettings.json new file mode 100644 index 000000000..ca25bdc6d --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/AspNetCoreMcpControllerServer/Tools/EchoTool.cs b/samples/AspNetCoreMcpControllerServer/Tools/EchoTool.cs new file mode 100644 index 000000000..dd211c0a2 --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/Tools/EchoTool.cs @@ -0,0 +1,14 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace AspNetCoreMcpControllerServer.Tools; + +[McpServerToolType] +public sealed class EchoTool +{ + [McpServerTool, Description("Echoes the input back to the client.")] + public static string Echo(string message) + { + return "hello " + message; + } +} diff --git a/samples/AspNetCoreMcpControllerServer/appsettings.Development.json b/samples/AspNetCoreMcpControllerServer/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/AspNetCoreMcpControllerServer/appsettings.json b/samples/AspNetCoreMcpControllerServer/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/samples/AspNetCoreMcpControllerServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/ModelContextProtocol.AspNetCore/McpRequestDelegateFactory.cs b/src/ModelContextProtocol.AspNetCore/McpRequestDelegateFactory.cs new file mode 100644 index 000000000..8429a9e9b --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/McpRequestDelegateFactory.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Provides a method to create a that handles MCP Streamable HTTP +/// transport requests for an ASP.NET Core server. +/// +/// +/// +/// This factory creates a that routes MCP requests +/// based on the HTTP method (POST, GET, DELETE) of the incoming request. +/// The required services must be registered by calling WithHttpTransport() +/// during application startup. +/// +/// +/// This is useful for integrating MCP into applications that use traditional MVC controllers +/// or other request-handling patterns instead of minimal APIs: +/// +/// +/// [ApiController] +/// [Route("mcp")] +/// public class McpController : ControllerBase +/// { +/// private static readonly RequestDelegate _mcpHandler = McpRequestDelegateFactory.Create(); +/// +/// [HttpPost] +/// [HttpGet] +/// [HttpDelete] +/// public Task Handle() => _mcpHandler(HttpContext); +/// } +/// +/// +public static class McpRequestDelegateFactory +{ + /// + /// Creates a that handles MCP Streamable HTTP transport requests. + /// + /// + /// A that routes incoming requests to the appropriate MCP handler + /// based on the HTTP method. POST requests handle JSON-RPC messages, GET requests handle + /// SSE streams for server-to-client messages, and DELETE requests terminate sessions. + /// Unsupported HTTP methods receive a 405 Method Not Allowed response. + /// + /// + /// The returned delegate resolves the internal MCP handler from + /// on each invocation. The required services are registered by + /// calling WithHttpTransport() during application startup. + /// + public static RequestDelegate Create() + { + return context => + { + var handler = context.RequestServices.GetRequiredService(); + + return context.Request.Method switch + { + "POST" => handler.HandlePostRequestAsync(context), + "GET" => handler.HandleGetRequestAsync(context), + "DELETE" => handler.HandleDeleteRequestAsync(context), + _ => HandleMethodNotAllowedAsync(context), + }; + }; + } + + private static Task HandleMethodNotAllowedAsync(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return Task.CompletedTask; + } +} diff --git a/src/ModelContextProtocol.AspNetCore/README.md b/src/ModelContextProtocol.AspNetCore/README.md index f27c76cec..7f5492dad 100644 --- a/src/ModelContextProtocol.AspNetCore/README.md +++ b/src/ModelContextProtocol.AspNetCore/README.md @@ -50,3 +50,41 @@ public static class EchoTool public static string Echo(string message) => $"hello {message}"; } ``` + +## Using with MVC Controllers + +If your application uses traditional MVC controllers instead of minimal APIs, +you can use `McpRequestDelegateFactory.Create()` to create a `RequestDelegate` that handles MCP requests: + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithToolsFromAssembly(); +var app = builder.Build(); + +app.MapControllers(); // No MapMcp() needed! + +app.Run("http://localhost:3001"); +``` + +```csharp +// Controllers/McpController.cs +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ModelContextProtocol.AspNetCore; + +[ApiController] +[Route("mcp")] +public class McpController : ControllerBase +{ + private static readonly RequestDelegate _mcpHandler = McpRequestDelegateFactory.Create(); + + [HttpPost] + [HttpGet] + [HttpDelete] + public Task Handle() => _mcpHandler(HttpContext); +} +``` diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/McpControllerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/McpControllerTests.cs new file mode 100644 index 000000000..ba1f3dedc --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/McpControllerTests.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class McpControllerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper) +{ + private async Task ConnectAsync() + { + await using var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + return await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CanConnect_WithMcpClient_ViaController() + { + Builder.Services.AddControllers() + .AddApplicationPart(typeof(TestMcpController).Assembly); + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new() + { + Name = "ControllerTestServer", + Version = "1.0.0", + }; + }).WithHttpTransport().WithTools(); + + await using var app = Builder.Build(); + + app.MapControllers(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(); + + Assert.Equal("ControllerTestServer", mcpClient.ServerInfo.Name); + } + + [Fact] + public async Task CanCallTool_ViaController() + { + Builder.Services.AddControllers() + .AddApplicationPart(typeof(TestMcpController).Assembly); + Builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + + await using var app = Builder.Build(); + + app.MapControllers(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(); + + var result = await mcpClient.CallToolAsync( + "echo", + new Dictionary { ["message"] = "Hello from controller!" }, + cancellationToken: TestContext.Current.CancellationToken); + + var textContent = Assert.Single(result.Content.OfType()); + Assert.Equal("hello Hello from controller!", textContent.Text); + } + + [Fact] + public async Task CanListTools_ViaController() + { + Builder.Services.AddControllers() + .AddApplicationPart(typeof(TestMcpController).Assembly); + Builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + + await using var app = Builder.Build(); + + app.MapControllers(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(); + + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "echo"); + } +} + +[McpServerToolType] +internal sealed class ControllerTestTools +{ + [McpServerTool(Name = "echo"), Description("Echoes the input back to the client.")] + public static string Echo(string message) => "hello " + message; +} + +[ApiController] +[Route("mcp")] +public class TestMcpController : ControllerBase +{ + private static readonly RequestDelegate _mcpHandler = McpRequestDelegateFactory.Create(); + + [HttpPost] + [HttpGet] + [HttpDelete] + public Task Handle() => _mcpHandler(HttpContext); +}