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:
parent
330e526446
commit
973c7c093f
5 changed files with 332 additions and 831 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue