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
5 changed files with 332 additions and 831 deletions

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,