Add ModelContextProtocol.AspNetCore (#160)

* Add ModelContextProtocol.AspNetCore

- Remove IServerTransport and HttpListenerSseServerTransport
- Replace HttpListenerSseServerTransport usage in tests with ASP.NET Core.

Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>
Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
This commit is contained in:
Stephen Halter 2025-03-31 12:01:04 -07:00 • committed by GitHub
commit 973c7c093f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 332 additions and 831 deletions

View file

@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickstartWeatherServer", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickstartClient", "samples\QuickstartClient\QuickstartClient.csproj", "{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore", "src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj", "{37B6A5E0-9995-497D-8B43-3BC6870CC716}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -92,6 +94,10 @@ Global
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}.Release|Any CPU.Build.0 = Release|Any CPU
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -107,6 +113,7 @@ Global
{0C6D0512-D26D-63D3-5019-C5F7A657B28C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{4653EB0C-8FC0-98F4-E9C8-220EDA7A69DF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{37B6A5E0-9995-497D-8B43-3BC6870CC716} = {A2F1F52A-9107-4BF8-8C3F-2F6670E7D0AD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89}

View file

@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
</ItemGroup>
</Project>

View file

@ -1,62 +0,0 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Transport;
namespace AspNetCoreSseServer;
public static class McpEndpointRouteBuilderExtensions
{
public static IEndpointConventionBuilder MapMcpSse(this IEndpointRouteBuilder endpoints)
{
SseResponseStreamTransport? transport = null;
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
var routeGroup = endpoints.MapGroup("");
routeGroup.MapGet("/sse", async (HttpResponse response, CancellationToken requestAborted) =>
{
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache";
await using var localTransport = transport = new SseResponseStreamTransport(response.Body);
await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
try
{
var transportTask = transport.RunAsync(cancellationToken: requestAborted);
await server.RunAsync(cancellationToken: requestAborted);
await transportTask;
}
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
{
// RequestAborted always triggers when the client disconnects before a complete response body is written,
// but this is how SSE connections are typically closed.
}
});
routeGroup.MapPost("/message", async context =>
{
if (transport is null)
{
await Results.BadRequest("Connect to the /sse endpoint before sending messages.").ExecuteAsync(context);
return;
}
var message = await context.Request.ReadFromJsonAsync<IJsonRpcMessage>(McpJsonUtilities.DefaultOptions, context.RequestAborted);
if (message is null)
{
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
return;
}
await transport.OnMessageReceivedAsync(message, context.RequestAborted);
context.Response.StatusCode = StatusCodes.Status202Accepted;
await context.Response.WriteAsync("Accepted");
});
return routeGroup;
}
}

View file

@ -1,10 +1,9 @@
using AspNetCoreSseServer;
using ModelContextProtocol.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer().WithToolsFromAssembly();
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.MapMcpSse();
app.MapMcp();
app.Run();

View file

@ -0,0 +1,118 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Extension methods for <see cref="IEndpointRouteBuilder"/> to add MCP endpoints.
/// </summary>
public static class McpEndpointRouteBuilderExtensions
{
/// <summary>
/// Sets up endpoints for handling MCP HTTP Streaming transport.
/// </summary>
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
/// <param name="runSession">Provides an optional asynchronous callback for handling new MCP sessions.</param>
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, Func<HttpContext, IMcpServer, CancellationToken, Task>? runSession = null)
{
ConcurrentDictionary<string, SseResponseStreamTransport> _sessions = new(StringComparer.Ordinal);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
var routeGroup = endpoints.MapGroup("");
routeGroup.MapGet("/sse", async context =>
{
var response = context.Response;
var requestAborted = context.RequestAborted;
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache";
var sessionId = MakeNewSessionId();
await using var transport = new SseResponseStreamTransport(response.Body, $"/message?sessionId={sessionId}");
if (!_sessions.TryAdd(sessionId, transport))
{
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
}
await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
try
{
var transportTask = transport.RunAsync(cancellationToken: requestAborted);
runSession ??= RunSession;
try
{
await runSession(context, server, requestAborted);
}
finally
{
await transport.DisposeAsync();
await transportTask;
}
}
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
{
// RequestAborted always triggers when the client disconnects before a complete response body is written,
// but this is how SSE connections are typically closed.
}
finally
{
_sessions.TryRemove(sessionId, out _);
}
});
routeGroup.MapPost("/message", async context =>
{
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
{
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
return;
}
if (!_sessions.TryGetValue(sessionId.ToString(), out var transport))
{
await Results.BadRequest($"Session {sessionId} not found.").ExecuteAsync(context);
return;
}
var message = await context.Request.ReadFromJsonAsync<IJsonRpcMessage>(McpJsonUtilities.DefaultOptions, context.RequestAborted);
if (message is null)
{
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
return;
}
await transport.OnMessageReceivedAsync(message, context.RequestAborted);
context.Response.StatusCode = StatusCodes.Status202Accepted;
await context.Response.WriteAsync("Accepted");
});
return routeGroup;
}
private static Task RunSession(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
=> session.RunAsync(requestAborted);
private static string MakeNewSessionId()
{
// 128 bits
Span<byte> buffer = stackalloc byte[16];
RandomNumberGenerator.Fill(buffer);
return WebEncoders.Base64UrlEncode(buffer);
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<PackageId>ModelContextProtocol.AspNetCore</PackageId>
<Description>ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModelContextProtocol\ModelContextProtocol.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,54 @@
# ASP.NET Core extensions for the MCP C# SDK
[![NuGet preview version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol/absoluteLatest)
The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit our [API documentation](https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.html) for more details on available functionality.
> [!NOTE]
> This project is in preview; breaking changes can be introduced without prior notice.
## About MCP
The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context to Large Language Models (LLMs). It enables secure integration between LLMs and various data sources and tools.
For more information about MCP:
- [Official Documentation](https://modelcontextprotocol.io/)
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
## Installation
To get started, install the package from NuGet
```
dotnet new web
dotnet add package ModelContextProtocol.AspNetcore --prerelease
```
## Getting Started
```csharp
// Program.cs
using ModelContextProtocol;
using ModelContextProtocol.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(3001);
});
builder.Services.AddMcpServer().WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp();
app.Run();
[McpServerToolType]
public static class EchoTool
{
[McpServerTool, Description("Echoes the message back to the client.")]
public static string Echo(string message) => $"hello {message}";
}
```

View file

@ -79,10 +79,9 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
{
// Connect transport
_sessionTransport = await _clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
InitializeSession(_sessionTransport);
// We don't want the ConnectAsync token to cancel the session after we've successfully connected.
// The base class handles cleaning up the session in DisposeAsync without our help.
StartSession(fullSessionCancellationToken: CancellationToken.None);
StartSession(_sessionTransport, fullSessionCancellationToken: CancellationToken.None);
// Perform initialization sequence
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@ -142,13 +141,14 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
/// <inheritdoc/>
public override async ValueTask DisposeUnsynchronizedAsync()
{
if (_connectCts is not null)
{
await _connectCts.CancelAsync().ConfigureAwait(false);
}
try
{
if (_connectCts is not null)
{
await _connectCts.CancelAsync().ConfigureAwait(false);
_connectCts.Dispose();
}
await base.DisposeUnsynchronizedAsync().ConfigureAwait(false);
}
finally
@ -157,8 +157,6 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
{
await _sessionTransport.DisposeAsync().ConfigureAwait(false);
}
_connectCts?.Dispose();
}
}
}

View file

@ -337,7 +337,7 @@ public static partial class McpServerBuilderExtensions
Throw.IfNull(builder);
builder.Services.AddSingleton<ITransport, StdioServerTransport>();
builder.Services.AddHostedService<McpServerSingleSessionHostedService>();
builder.Services.AddHostedService<StdioMcpServerHostedService>();
builder.Services.AddSingleton(services =>
{
@ -350,18 +350,5 @@ public static partial class McpServerBuilderExtensions
return builder;
}
/// <summary>
/// Adds a server transport that uses SSE via a HttpListener for communication.
/// </summary>
/// <param name="builder">The builder instance.</param>
public static IMcpServerBuilder WithHttpListenerSseServerTransport(this IMcpServerBuilder builder)
{
Throw.IfNull(builder);
builder.Services.AddSingleton<IServerTransport, HttpListenerSseServerTransport>();
builder.Services.AddHostedService<McpServerMultiSessionHostedService>();
return builder;
}
#endregion
}

View file

@ -1,43 +0,0 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Hosting;
/// <summary>
/// Hosted service for a multi-session (i.e. HTTP) MCP server.
/// </summary>
internal sealed class McpServerMultiSessionHostedService : BackgroundService
{
private readonly IServerTransport _serverTransport;
private readonly McpServerOptions _serverOptions;
private readonly ILoggerFactory _loggerFactory;
private readonly IServiceProvider _serviceProvider;
public McpServerMultiSessionHostedService(
IServerTransport serverTransport,
IOptions<McpServerOptions> serverOptions,
ILoggerFactory loggerFactory,
IServiceProvider serviceProvider)
{
_serverTransport = serverTransport;
_serverOptions = serverOptions.Value;
_loggerFactory = loggerFactory;
_serviceProvider = serviceProvider;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (await AcceptSessionAsync(stoppingToken).ConfigureAwait(false) is { } server)
{
// TODO: Track all running sessions and wait for all sessions to complete for graceful shutdown.
_ = server.RunAsync(stoppingToken);
}
}
private Task<IMcpServer> AcceptSessionAsync(CancellationToken cancellationToken)
=> McpServerFactory.AcceptAsync(_serverTransport, _serverOptions, _loggerFactory, _serviceProvider, cancellationToken);
}

View file

@ -0,0 +1,13 @@
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Hosting;
/// <summary>
/// Hosted service for a single-session (i.e stdio) MCP server.
/// </summary>
internal sealed class StdioMcpServerHostedService(IMcpServer session) : BackgroundService
{
/// <inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken) => session.RunAsync(stoppingToken);
}

View file

@ -1,210 +0,0 @@
using System.Net;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// HTTP server provider using HttpListener.
/// </summary>
internal sealed class HttpListenerServerProvider : IAsyncDisposable
{
private static readonly byte[] s_accepted = "Accepted"u8.ToArray();
private const string SseEndpoint = "/sse";
private const string MessageEndpoint = "/message";
private readonly HttpListener _listener;
private readonly CancellationTokenSource _shutdownTokenSource = new();
private Task _listeningTask = Task.CompletedTask;
private readonly TaskCompletionSource<bool> _completed = new();
private int _outstandingOperations;
private int _state;
private const int StateNotStarted = 0;
private const int StateRunning = 1;
private const int StateStopped = 2;
/// <summary>
/// Creates a new instance of the HTTP server provider.
/// </summary>
/// <param name="port">The port to listen on</param>
public HttpListenerServerProvider(int port)
{
if (port < 0)
{
throw new ArgumentOutOfRangeException(nameof(port));
}
_listener = new();
_listener.Prefixes.Add($"http://localhost:{port}/");
}
public required Func<Stream, CancellationToken, Task> OnSseConnectionAsync { get; set; }
public required Func<Stream, CancellationToken, Task<bool>> OnMessageAsync { get; set; }
public void Start()
{
if (Interlocked.CompareExchange(ref _state, StateRunning, StateNotStarted) != StateNotStarted)
{
throw new ObjectDisposedException("Server may not be started twice.");
}
// Start listening for connections
_listener.Start();
OperationAdded(); // for the listening task
_listeningTask = Task.Run(async () =>
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownTokenSource.Token);
cts.Token.Register(_listener.Stop);
while (!cts.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync().ConfigureAwait(false);
// Process the request in a separate task
OperationAdded(); // for the processing task; decremented in ProcessRequestAsync
_ = Task.Run(() => ProcessRequestAsync(context, cts.Token), CancellationToken.None);
}
catch (Exception)
{
if (cts.IsCancellationRequested)
{
// Shutdown requested, exit gracefully
break;
}
}
}
}
finally
{
OperationCompleted(); // for the listening task
}
}, CancellationToken.None);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (Interlocked.CompareExchange(ref _state, StateStopped, StateRunning) != StateRunning)
{
return;
}
await _shutdownTokenSource.CancelAsync().ConfigureAwait(false);
_listener.Stop();
await _listeningTask.ConfigureAwait(false);
await _completed.Task.ConfigureAwait(false);
}
/// <summary>Gets a <see cref="Task"/> that completes when the server has finished its work.</summary>
public Task Completed => _completed.Task;
private void OperationAdded() => Interlocked.Increment(ref _outstandingOperations);
private void OperationCompleted()
{
if (Interlocked.Decrement(ref _outstandingOperations) == 0)
{
// All operations completed
_completed.TrySetResult(true);
}
}
private async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var request = context.Request;
var response = context.Response;
try
{
if (request is null || response is null)
{
return;
}
// Handle SSE connection
if (request.HttpMethod == "GET" && request.Url?.LocalPath == SseEndpoint)
{
await HandleSseConnectionAsync(context, cancellationToken).ConfigureAwait(false);
}
// Handle message POST
else if (request.HttpMethod == "POST" && request.Url?.LocalPath == MessageEndpoint)
{
await HandleMessageAsync(context, cancellationToken).ConfigureAwait(false);
}
else
{
// Not found
response.StatusCode = 404;
response.Close();
}
}
catch (Exception)
{
try
{
response.StatusCode = 500;
response.Close();
}
catch { /* Ignore errors during error handling */ }
}
finally
{
OperationCompleted();
}
}
private async Task HandleSseConnectionAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var response = context.Response;
// Set SSE headers
response.ContentType = "text/event-stream";
response.Headers.Add("Cache-Control", "no-cache");
response.Headers.Add("Connection", "keep-alive");
// Keep the connection open until cancelled
try
{
await OnSseConnectionAsync(response.OutputStream, cancellationToken).ConfigureAwait(false);
}
catch (Exception)
{
}
finally
{
// Remove client on disconnect
try
{
response.Close();
}
catch { /* Ignore errors during cleanup */ }
}
}
private async Task HandleMessageAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var request = context.Request;
var response = context.Response;
// Process the message asynchronously
if (await OnMessageAsync(request.InputStream, cancellationToken))
{
// Return 202 Accepted
response.StatusCode = 202;
// Write "accepted" response
await response.OutputStream.WriteAsync(s_accepted, cancellationToken).ConfigureAwait(false);
}
else
{
// Return 400 Bad Request
response.StatusCode = 400;
}
response.Close();
}
}

View file

@ -1,76 +0,0 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Net;
using System.Text.Json;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Implements the MCP transport protocol using <see cref="HttpListener"/>.
/// </summary>
internal sealed class HttpListenerSseServerSessionTransport : TransportBase
{
private readonly string _serverName;
private readonly ILogger<HttpListenerSseServerTransport> _logger;
private SseResponseStreamTransport _responseStreamTransport;
private string EndpointName => $"Server (SSE) ({_serverName})";
public HttpListenerSseServerSessionTransport(string serverName, SseResponseStreamTransport responseStreamTransport, ILoggerFactory loggerFactory)
: base(loggerFactory)
{
Throw.IfNull(serverName);
_serverName = serverName;
_responseStreamTransport = responseStreamTransport;
_logger = loggerFactory.CreateLogger<HttpListenerSseServerTransport>();
SetConnected(true);
}
/// <inheritdoc/>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
{
if (!IsConnected)
{
_logger.TransportNotConnected(EndpointName);
throw new McpTransportException("Transport is not connected");
}
string id = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
id = messageWithId.Id.ToString();
}
try
{
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>());
_logger.TransportSendingMessage(EndpointName, id, json);
}
await _responseStreamTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
_logger.TransportSentMessage(EndpointName, id);
}
catch (Exception ex)
{
_logger.TransportSendFailed(EndpointName, id, ex);
throw new McpTransportException("Failed to send message", ex);
}
}
public Task OnMessageReceivedAsync(IJsonRpcMessage message, CancellationToken cancellationToken)
=> WriteMessageAsync(message, cancellationToken);
/// <inheritdoc/>
public override ValueTask DisposeAsync()
{
SetConnected(false);
return default;
}
}

View file

@ -1,180 +0,0 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils.Json;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils;
using System.Threading.Channels;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Implements the MCP transport protocol using <see cref="HttpListener"/>.
/// </summary>
public sealed class HttpListenerSseServerTransport : IServerTransport, IAsyncDisposable
{
private readonly string _serverName;
private readonly HttpListenerServerProvider _httpServerProvider;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<HttpListenerSseServerTransport> _logger;
private readonly Channel<ITransport> _incomingSessions;
private HttpListenerSseServerSessionTransport? _sessionTransport;
private string EndpointName => $"Server (SSE) ({_serverName})";
/// <summary>
/// Initializes a new instance of the SseServerTransport class.
/// </summary>
/// <param name="serverOptions">The server options.</param>
/// <param name="port">The port to listen on.</param>
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
public HttpListenerSseServerTransport(McpServerOptions serverOptions, int port, ILoggerFactory loggerFactory)
: this(GetServerName(serverOptions), port, loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the SseServerTransport class.
/// </summary>
/// <param name="serverName">The name of the server.</param>
/// <param name="port">The port to listen on.</param>
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
public HttpListenerSseServerTransport(string serverName, int port, ILoggerFactory loggerFactory)
{
Throw.IfNull(serverName);
_serverName = serverName;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<HttpListenerSseServerTransport>();
_httpServerProvider = new HttpListenerServerProvider(port)
{
OnSseConnectionAsync = OnSseConnectionAsync,
OnMessageAsync = OnMessageAsync,
};
// Until we support session IDs, there's no way to support more than one concurrent session.
// Any new SSE connection overwrites the old session and any new /messages go to the new session.
_incomingSessions = Channel.CreateBounded<ITransport>(new BoundedChannelOptions(1)
{
FullMode = BoundedChannelFullMode.DropOldest,
});
// REVIEW: We could add another layer of async for binding similar to Kestrel's IConnectionListenerFactory,
// but this wouldn't play well with a static factory method to accept new sessions. Ultimately,
// ASP.NET Core is not going to hand over binding to the MCP SDK, so I decided to just bind in the transport
// constructor for now.
_httpServerProvider.Start();
}
/// <inheritdoc/>
public async Task<ITransport?> AcceptAsync(CancellationToken cancellationToken = default)
{
while (await _incomingSessions.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
if (_incomingSessions.Reader.TryRead(out var session))
{
return session;
}
}
return null;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
_logger.TransportCleaningUp(EndpointName);
await _httpServerProvider.DisposeAsync().ConfigureAwait(false);
_incomingSessions.Writer.TryComplete();
_logger.TransportCleanedUp(EndpointName);
}
private async Task OnSseConnectionAsync(Stream responseStream, CancellationToken cancellationToken)
{
var sseResponseStreamTransport = new SseResponseStreamTransport(responseStream);
var sessionTransport = new HttpListenerSseServerSessionTransport(_serverName, sseResponseStreamTransport, _loggerFactory);
await using (sseResponseStreamTransport.ConfigureAwait(false))
await using (sseResponseStreamTransport.ConfigureAwait(false))
{
_sessionTransport = sessionTransport;
await _incomingSessions.Writer.WriteAsync(sessionTransport).ConfigureAwait(false);
await sseResponseStreamTransport.RunAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Handles HTTP messages received by the HTTP server provider.
/// </summary>
/// <returns>true if the message was accepted (return 202), false otherwise (return 400)</returns>
private async Task<bool> OnMessageAsync(Stream requestStream, CancellationToken cancellationToken)
{
string request;
IJsonRpcMessage? message = null;
if (_logger.IsEnabled(LogLevel.Information))
{
using var reader = new StreamReader(requestStream);
request = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
message = JsonSerializer.Deserialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>());
_logger.TransportReceivedMessage(EndpointName, request);
}
else
{
request = "(Enable information-level logs to see the request)";
}
try
{
message ??= await JsonSerializer.DeserializeAsync(requestStream, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>()).ConfigureAwait(false);
if (message != null)
{
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
if (_sessionTransport is null)
{
return false;
}
await _sessionTransport.OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
_logger.TransportMessageWritten(EndpointName, messageId);
return true;
}
else
{
_logger.TransportMessageParseUnexpectedType(EndpointName, request);
return false;
}
}
catch (JsonException ex)
{
_logger.TransportMessageParseFailed(EndpointName, request, ex);
return false;
}
}
/// <summary>Validates the <paramref name="serverOptions"/> and extracts from it the server name to use.</summary>
private static string GetServerName(McpServerOptions serverOptions)
{
Throw.IfNull(serverOptions);
Throw.IfNull(serverOptions.ServerInfo);
Throw.IfNull(serverOptions.ServerInfo.Name);
return serverOptions.ServerInfo.Name;
}
}

View file

@ -1,14 +0,0 @@
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Represents a transport mechanism for MCP communication (from the server).
/// </summary>
public interface IServerTransport
{
/// <summary>
/// Asynchronously accepts a transport session initiated by an MCP client and returns an interface for the duplex JSON-RPC message stream.
/// </summary>
/// <param name="cancellationToken">Used to signal the cancellation of the asynchronous operation.</param>
/// <returns>Returns an interface for the duplex JSON-RPC message stream.</returns>
Task<ITransport?> AcceptAsync(CancellationToken cancellationToken = default);
}

View file

@ -15,8 +15,8 @@ namespace ModelContextProtocol.Protocol.Transport;
/// <param name="messageEndpoint">The endpoint to send JSON-RPC messages to. Defaults to "/message".</param>
public sealed class SseResponseStreamTransport(Stream sseResponseStream, string messageEndpoint = "/message") : ITransport
{
private readonly Channel<IJsonRpcMessage> _incomingChannel = CreateSingleItemChannel<IJsonRpcMessage>();
private readonly Channel<SseItem<IJsonRpcMessage?>> _outgoingSseChannel = CreateSingleItemChannel<SseItem<IJsonRpcMessage?>>();
private readonly Channel<IJsonRpcMessage> _incomingChannel = CreateBoundedChannel<IJsonRpcMessage>();
private readonly Channel<SseItem<IJsonRpcMessage?>> _outgoingSseChannel = CreateBoundedChannel<SseItem<IJsonRpcMessage?>>();
private Task? _sseWriteTask;
private Utf8JsonWriter? _jsonWriter;
@ -47,7 +47,10 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
// The very first SSE event isn't really an IJsonRpcMessage, but there's no API to write a single item of a different type,
// so we fib and special-case the "endpoint" event type in the formatter.
_outgoingSseChannel.Writer.TryWrite(new SseItem<IJsonRpcMessage?>(null, "endpoint"));
if (!_outgoingSseChannel.Writer.TryWrite(new SseItem<IJsonRpcMessage?>(null, "endpoint")))
{
throw new InvalidOperationException($"You must call ${nameof(RunAsync)} before calling ${nameof(SendMessageAsync)}.");
}
var sseItems = _outgoingSseChannel.Reader.ReadAllAsync(cancellationToken);
return _sseWriteTask = SseFormatter.WriteAsync(sseItems, sseResponseStream, WriteJsonRpcMessageToBuffer, cancellationToken);
@ -73,7 +76,7 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
throw new InvalidOperationException($"Transport is not connected. Make sure to call {nameof(RunAsync)} first.");
}
await _outgoingSseChannel.Writer.WriteAsync(new SseItem<IJsonRpcMessage?>(message), cancellationToken).AsTask();
await _outgoingSseChannel.Writer.WriteAsync(new SseItem<IJsonRpcMessage?>(message), cancellationToken);
}
/// <summary>
@ -90,11 +93,11 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
throw new InvalidOperationException($"Transport is not connected. Make sure to call {nameof(RunAsync)} first.");
}
await _incomingChannel.Writer.WriteAsync(message, cancellationToken).AsTask();
await _incomingChannel.Writer.WriteAsync(message, cancellationToken);
}
private static Channel<T> CreateSingleItemChannel<T>() =>
Channel.CreateBounded<T>(new BoundedChannelOptions(1)
private static Channel<T> CreateBoundedChannel<T>(int capacity = 1) =>
Channel.CreateBounded<T>(new BoundedChannelOptions(capacity)
{
SingleReader = true,
SingleWriter = false,

View file

@ -12,30 +12,12 @@ namespace ModelContextProtocol.Server;
/// <inheritdoc />
internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
{
private readonly IServerTransport? _serverTransport;
private readonly EventHandler? _toolsChangedDelegate;
private readonly EventHandler? _promptsChangedDelegate;
private ITransport? _sessionTransport;
private ITransport _sessionTransport;
private string _endpointName;
/// <summary>
/// Creates a new instance of <see cref="McpServer"/>.
/// </summary>
/// <param name="serverTransport">Transport to use for the server that is ready to accept new sessions asynchronously.</param>
/// <param name="options">Configuration options for this server, including capabilities.
/// Make sure to accurately reflect exactly what capabilities the server supports and does not support.</param>
/// <param name="loggerFactory">Logger factory to use for logging</param>
/// <param name="serviceProvider">Optional service provider to use for dependency injection</param>
/// <exception cref="McpServerException"></exception>
public McpServer(IServerTransport serverTransport, McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider)
: this(options, loggerFactory, serviceProvider)
{
Throw.IfNull(serverTransport);
_serverTransport = serverTransport;
}
/// <summary>
/// Creates a new instance of <see cref="McpServer"/>.
/// </summary>
@ -46,27 +28,12 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
/// <param name="serviceProvider">Optional service provider to use for dependency injection</param>
/// <exception cref="McpServerException"></exception>
public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider)
: this(options, loggerFactory, serviceProvider)
{
Throw.IfNull(transport);
_sessionTransport = transport;
InitializeSession(transport);
}
/// <summary>
/// Creates a new instance of <see cref="McpServer"/>.
/// </summary>
/// <param name="options">Configuration options for this server, including capabilities.
/// Make sure to accurately reflect exactly what capabilities the server supports and does not support.</param>
/// <param name="loggerFactory">Logger factory to use for logging</param>
/// <param name="serviceProvider">Optional service provider to use for dependency injection</param>
/// <exception cref="McpServerException"></exception>
private McpServer(McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider)
: base(loggerFactory)
{
Throw.IfNull(transport);
Throw.IfNull(options);
_sessionTransport = transport;
ServerOptions = options;
Services = serviceProvider;
_endpointName = $"Server ({options.ServerInfo.Name} {options.ServerInfo.Version})";
@ -128,36 +95,13 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
/// <inheritdoc />
public override string EndpointName => _endpointName;
public async Task AcceptSessionAsync(CancellationToken cancellationToken = default)
{
// Below is effectively an assertion. The McpServerFactory should only use this with the IServerTransport constructor.
Throw.IfNull(_serverTransport);
try
{
_sessionTransport = await _serverTransport.AcceptAsync(cancellationToken).ConfigureAwait(false);
if (_sessionTransport is null)
{
throw new McpServerException("The server transport closed before a client started a new session.");
}
InitializeSession(_sessionTransport);
}
catch (Exception e)
{
_logger.ServerInitializationError(EndpointName, e);
throw;
}
}
/// <inheritdoc />
public async Task RunAsync(CancellationToken cancellationToken = default)
{
try
{
// Start processing messages
StartSession(fullSessionCancellationToken: cancellationToken);
StartSession(_sessionTransport, fullSessionCancellationToken: cancellationToken);
await MessageProcessingTask.ConfigureAwait(false);
}
finally
@ -178,18 +122,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
prompts.Changed -= _promptsChangedDelegate;
}
try
{
await base.DisposeUnsynchronizedAsync().ConfigureAwait(false);
}
finally
{
if (_serverTransport is not null && _sessionTransport is not null)
{
// We created the _sessionTransport from the _serverTransport, so we own it.
await _sessionTransport.DisposeAsync().ConfigureAwait(false);
}
}
await base.DisposeUnsynchronizedAsync().ConfigureAwait(false);
}
private void SetPingHandler()
@ -208,7 +141,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
// Use the ClientInfo to update the session EndpointName for logging.
_endpointName = $"{_endpointName}, Client ({ClientInfo?.Name} {ClientInfo?.Version})";
GetSessionOrThrow().EndpointName = EndpointName;
GetSessionOrThrow().EndpointName = _endpointName;
return Task.FromResult(new InitializeResult()
{

View file

@ -33,42 +33,4 @@ public static class McpServerFactory
return new McpServer(transport, serverOptions, loggerFactory, serviceProvider);
}
/// <summary>
/// Waits for the client to establish a new MCP session, then initializes a new instance of the <see cref="IMcpServer"/> class.
/// </summary>
/// <param name="serverTransport">Transport to use for the server that is ready to accept new MCP sessions asynchronously.</param>
/// <param name="serverOptions">
/// Configuration options for this server, including capabilities.
/// Make sure to accurately reflect exactly what capabilities the server supports and does not support.
/// </param>
/// <param name="loggerFactory">Logger factory to use for logging</param>
/// <param name="serviceProvider">Optional service provider to create new instances.</param>
/// <param name="cancellationToken">Cancel waiting for a client to establish a new MCP session.</param>
/// <returns>An <see cref="IMcpServer"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serverTransport"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="serverOptions"/> is <see langword="null"/>.</exception>
public static async Task<IMcpServer> AcceptAsync(
IServerTransport serverTransport,
McpServerOptions serverOptions,
ILoggerFactory? loggerFactory = null,
IServiceProvider? serviceProvider = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(serverTransport);
Throw.IfNull(serverOptions);
var mcpServer = new McpServer(serverTransport, serverOptions, loggerFactory, serviceProvider);
try
{
await mcpServer.AcceptSessionAsync(cancellationToken).ConfigureAwait(false);
return mcpServer;
}
catch
{
await mcpServer.DisposeAsync().ConfigureAwait(false);
throw;
}
}
}

View file

@ -60,13 +60,8 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
/// </summary>
protected Task? MessageProcessingTask { get; set; }
protected void InitializeSession(ITransport sessionTransport)
{
_session = new McpSession(sessionTransport, EndpointName, _requestHandlers, _notificationHandlers, _logger);
}
[MemberNotNull(nameof(MessageProcessingTask))]
protected void StartSession(CancellationToken fullSessionCancellationToken = default)
protected void StartSession(ITransport sessionTransport, CancellationToken fullSessionCancellationToken = default)
{
if (Interlocked.Exchange(ref _started, 1) != 0)
{
@ -74,7 +69,8 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
}
_sessionCts = CancellationTokenSource.CreateLinkedTokenSource(fullSessionCancellationToken);
MessageProcessingTask = GetSessionOrThrow().ProcessMessagesAsync(_sessionCts.Token);
_session = new McpSession(sessionTransport, EndpointName, _requestHandlers, _notificationHandlers, _logger);
MessageProcessingTask = _session.ProcessMessagesAsync(_sessionCts.Token);
}
public async ValueTask DisposeAsync()
@ -122,5 +118,5 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
}
protected McpSession GetSessionOrThrow()
=> _session ?? throw new InvalidOperationException($"This should be unreachable from public API! Call {nameof(InitializeSession)} before sending messages.");
=> _session ?? throw new InvalidOperationException($"This should be unreachable from public API! Call {nameof(StartSession)} before sending messages.");
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
</ItemGroup>

Some files were not shown because too many files have changed in this diff Show more