Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<Project Path="docs/concepts/progress/samples/server/Progress.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AspNetCoreMcpControllerServer/AspNetCoreMcpControllerServer.csproj" />
<Project Path="samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj" />
<Project Path="samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj" />
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
</ItemGroup>

</Project>
26 changes: 26 additions & 0 deletions samples/AspNetCoreMcpControllerServer/Controllers/McpController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
using ModelContextProtocol.AspNetCore;

namespace AspNetCoreMcpControllerServer.Controllers;

/// <summary>
/// An MVC controller that handles MCP Streamable HTTP transport requests
/// by delegating to the <see cref="StreamableHttpHandler"/> registered by
/// <c>WithHttpTransport()</c>.
/// </summary>
[ApiController]
[Route("mcp")]
public class McpController : ControllerBase
{
[HttpPost]
public Task Post([FromServices] StreamableHttpHandler handler) =>
handler.HandlePostRequestAsync(HttpContext);

[HttpGet]
public Task Get([FromServices] StreamableHttpHandler handler) =>
handler.HandleGetRequestAsync(HttpContext);

[HttpDelete]
public Task Delete([FromServices] StreamableHttpHandler handler) =>
handler.HandleDeleteRequestAsync(HttpContext);
}
13 changes: 13 additions & 0 deletions samples/AspNetCoreMcpControllerServer/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using AspNetCoreMcpControllerServer.Tools;

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

var app = builder.Build();

app.MapControllers();

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:3001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
14 changes: 14 additions & 0 deletions samples/AspNetCoreMcpControllerServer/Tools/EchoTool.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions samples/AspNetCoreMcpControllerServer/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
42 changes: 42 additions & 0 deletions src/ModelContextProtocol.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,45 @@ 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 inject the `StreamableHttpHandler` directly into a controller:

```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.Mvc;
using ModelContextProtocol.AspNetCore;

[ApiController]
[Route("mcp")]
public class McpController : ControllerBase
{
[HttpPost]
public Task Post([FromServices] StreamableHttpHandler handler) =>
handler.HandlePostRequestAsync(HttpContext);

[HttpGet]
public Task Get([FromServices] StreamableHttpHandler handler) =>
handler.HandleGetRequestAsync(HttpContext);

[HttpDelete]
public Task Delete([FromServices] StreamableHttpHandler handler) =>
handler.HandleDeleteRequestAsync(HttpContext);
}
```
116 changes: 95 additions & 21 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,95 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json.Serialization.Metadata;

namespace ModelContextProtocol.AspNetCore;

internal sealed class StreamableHttpHandler(
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
StatefulSessionManager sessionManager,
IHostApplicationLifetime hostApplicationLifetime,
IServiceProvider applicationServices,
ILoggerFactory loggerFactory)
/// <summary>
/// Handles MCP Streamable HTTP transport requests (POST, GET, DELETE) for an ASP.NET Core server.
/// </summary>
/// <remarks>
/// <para>
/// This handler is registered as a singleton service by <see cref="HttpMcpServerBuilderExtensions.WithHttpTransport"/>
/// and is used internally by <c>MapMcp()</c> to map MCP endpoints using minimal APIs.
/// </para>
/// <para>
/// It can also be injected directly into MVC controllers or other request-handling code
/// to support scenarios where minimal APIs are not used:
/// </para>
/// <code>
/// [ApiController]
/// [Route("mcp")]
/// public class McpController : ControllerBase
/// {
/// [HttpPost]
/// public Task Post([FromServices] StreamableHttpHandler handler) =&gt; handler.HandlePostRequestAsync(HttpContext);
///
/// [HttpGet]
/// public Task Get([FromServices] StreamableHttpHandler handler) =&gt; handler.HandleGetRequestAsync(HttpContext);
///
/// [HttpDelete]
/// public Task Delete([FromServices] StreamableHttpHandler handler) =&gt; handler.HandleDeleteRequestAsync(HttpContext);
/// }
/// </code>
/// </remarks>
public sealed class StreamableHttpHandler
{
private readonly IOptions<McpServerOptions> _mcpServerOptionsSnapshot;
private readonly IOptionsFactory<McpServerOptions> _mcpServerOptionsFactory;
private readonly IOptions<HttpServerTransportOptions> _httpServerTransportOptions;
private readonly StatefulSessionManager _sessionManager;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly IServiceProvider _applicationServices;
private readonly ILoggerFactory _loggerFactory;

/// <summary>
/// Initializes a new instance of the <see cref="StreamableHttpHandler"/> class.
/// This constructor is intended for use by the dependency injection container.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public StreamableHttpHandler(IServiceProvider serviceProvider)
Copy link
Contributor

Choose a reason for hiding this comment

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

I understand not wanting to add a public constructor with a bunch of parameters, but this forces StremableHttpHandler to use the service locator pattern which is generally considered bad practice. Not that constructing a singleton is a perf hotspot, but it's generally better to give the DI/IoC container more detailed knowledge of the service dependencies.

If we decide to make something like this public, I would sooner introduce an IStreamableHttpHandler interface and leave the implementation internal.

{
ArgumentNullException.ThrowIfNull(serviceProvider);
_mcpServerOptionsSnapshot = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>();
_mcpServerOptionsFactory = serviceProvider.GetRequiredService<IOptionsFactory<McpServerOptions>>();
_httpServerTransportOptions = serviceProvider.GetRequiredService<IOptions<HttpServerTransportOptions>>();
_sessionManager = serviceProvider.GetRequiredService<StatefulSessionManager>();
_hostApplicationLifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
_applicationServices = serviceProvider;
_loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
}

private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private const string LastEventIdHeaderName = "Last-Event-ID";

private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();

public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;

/// <summary>
/// Gets the configured <see cref="AspNetCore.HttpServerTransportOptions"/> for the MCP server.
/// </summary>
public HttpServerTransportOptions HttpServerTransportOptions => _httpServerTransportOptions.Value;

/// <summary>
/// Handles an MCP Streamable HTTP POST request containing a JSON-RPC message.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> for the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// The response will be either a streamed SSE response containing JSON-RPC messages
/// or a 202 Accepted response if the request contained only notifications.
/// </remarks>
public async Task HandlePostRequestAsync(HttpContext context)
{
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
Expand Down Expand Up @@ -72,6 +132,15 @@ await WriteJsonRpcErrorAsync(context,
}
}

/// <summary>
/// Handles an MCP Streamable HTTP GET request for receiving unsolicited server-to-client messages via SSE.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> for the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// This endpoint keeps the connection open and streams SSE events to the client.
/// It requires a valid <c>Mcp-Session-Id</c> header and is not available in stateless mode.
/// </remarks>
public async Task HandleGetRequestAsync(HttpContext context)
{
if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType))
Expand Down Expand Up @@ -125,7 +194,7 @@ await WriteJsonRpcErrorAsync(context,
return;
}

using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, _hostApplicationLifetime.ApplicationStopping);
var cancellationToken = sseCts.Token;

await using var _ = await session.AcquireReferenceAsync(cancellationToken);
Expand All @@ -147,7 +216,7 @@ await WriteJsonRpcErrorAsync(context,
// Link the GET request to both RequestAborted and ApplicationStopping.
// The GET request should complete immediately during graceful shutdown without waiting for
// in-flight POST requests to complete. This prevents slow shutdown when clients are still connected.
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, _hostApplicationLifetime.ApplicationStopping);
var cancellationToken = sseCts.Token;

try
Expand All @@ -169,10 +238,15 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
await eventStreamReader.CopyToAsync(context.Response.Body, context.RequestAborted);
}

/// <summary>
/// Handles an MCP Streamable HTTP DELETE request to terminate an existing session.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> for the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (sessionManager.TryRemove(sessionId, out var session))
if (_sessionManager.TryRemove(sessionId, out var session))
{
await session.DisposeAsync();
}
Expand All @@ -186,7 +260,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
return null;
}

if (!sessionManager.TryGetValue(sessionId, out var session))
if (!_sessionManager.TryGetValue(sessionId, out var session))
{
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
Expand Down Expand Up @@ -238,7 +312,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
if (!HttpServerTransportOptions.Stateless)
{
sessionId = MakeNewSessionId();
transport = new(loggerFactory)
transport = new(_loggerFactory)
{
SessionId = sessionId,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
Expand All @@ -252,7 +326,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
// If in the future we support resuming stateless requests, we should populate
// the event stream store and retry interval here as well.
sessionId = "";
transport = new(loggerFactory)
transport = new(_loggerFactory)
{
Stateless = true,
};
Expand All @@ -266,11 +340,11 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
StreamableHttpServerTransport transport,
string sessionId)
{
var mcpServerServices = applicationServices;
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
var mcpServerServices = _applicationServices;
var mcpServerOptions = _mcpServerOptionsSnapshot.Value;
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
mcpServerOptions = _mcpServerOptionsFactory.Create(Options.DefaultName);

if (HttpServerTransportOptions.Stateless)
{
Expand All @@ -285,11 +359,11 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
}
}

var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
var server = McpServer.Create(transport, mcpServerOptions, _loggerFactory, mcpServerServices);
context.Features.Set(server);

var userIdClaim = GetUserIdClaim(context.User);
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, _sessionManager);

var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
Expand Down
Loading