Rename project and namespaces

This commit is contained in:
Eirik Tsarpalis 2025-03-19 10:51:27 +02:00
commit c9ed453745
74 changed files with 426 additions and 918 deletions

View file

@ -0,0 +1,32 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Standard JSON-RPC error codes as defined in the MCP specification.
/// </summary>
internal static class ErrorCodes
{
/// <summary>
/// Invalid JSON was received by the server.
/// </summary>
public const int ParseError = -32700;
/// <summary>
/// The JSON sent is not a valid Request object.
/// </summary>
public const int InvalidRequest = -32600;
/// <summary>
/// The method does not exist / is not available.
/// </summary>
public const int MethodNotFound = -32601;
/// <summary>
/// Invalid method parameter(s).
/// </summary>
public const int InvalidParams = -32602;
/// <summary>
/// Internal JSON-RPC error.
/// </summary>
public const int InternalError = -32603;
}

View file

@ -0,0 +1,16 @@
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Base interface for all JSON-RPC messages in the MCP protocol.
/// </summary>
[JsonConverter(typeof(JsonRpcMessageConverter))]
public interface IJsonRpcMessage
{
/// <summary>
/// JSON-RPC protocol version. Must be "2.0".
/// </summary>
string JsonRpc { get; }
}

View file

@ -0,0 +1,12 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Base interface for JSON-RPC messages that include an ID.
/// </summary>
public interface IJsonRpcMessageWithId : IJsonRpcMessage
{
/// <summary>
/// The message identifier.
/// </summary>
RequestId Id { get; }
}

View file

@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// An error response message in the JSON-RPC protocol.
/// </summary>
public record JsonRpcError : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Request identifier matching the original request.
/// </summary>
[JsonPropertyName("id")]
public required RequestId Id { get; init; }
/// <summary>
/// Error information.
/// </summary>
[JsonPropertyName("error")]
public required JsonRpcErrorDetail Error { get; init; }
}

View file

@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Detailed error information for JSON-RPC error responses.
/// </summary>
public record JsonRpcErrorDetail
{
/// <summary>
/// Integer error code.
/// </summary>
[JsonPropertyName("code")]
public required int Code { get; init; }
/// <summary>
/// Short description of the error.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>
/// Optional additional error data.
/// </summary>
[JsonPropertyName("data")]
public object? Data { get; init; }
}

View file

@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// A request message in the JSON-RPC protocol.
/// </summary>
public record JsonRpcRequest : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Request identifier. Must be a string or number and unique within the session.
/// </summary>
[JsonPropertyName("id")]
public RequestId Id { get; set; }
/// <summary>
/// Name of the method to invoke.
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; init; }
/// <summary>
/// Optional parameters for the method.
/// </summary>
[JsonPropertyName("params")]
public object? Params { get; init; }
}

View file

@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// A notification message in the JSON-RPC protocol (a request that doesn't expect a response).
/// </summary>
public record JsonRpcNotification : IJsonRpcMessage
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Name of the notification method.
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; init; }
/// <summary>
/// Optional parameters for the notification.
/// </summary>
[JsonPropertyName("params")]
public object? Params { get; init; }
}

View file

@ -0,0 +1,27 @@

using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// A successful response message in the JSON-RPC protocol.
/// </summary>
public record JsonRpcResponse : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Request identifier matching the original request.
/// </summary>
[JsonPropertyName("id")]
public required RequestId Id { get; init; }
/// <summary>
/// The result of the method invocation.
/// </summary>
[JsonPropertyName("result")]
public required object? Result { get; init; }
}

View file

@ -0,0 +1,32 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// A class containing constants for notification methods.
/// </summary>
public static class NotificationMethods
{
/// <summary>
/// Sent by the server when the list of tools changes.
/// </summary>
public const string ToolListChangedNotification = "notifications/tools/list_changed";
/// <summary>
/// Sent by the server when the list of prompts changes.
/// </summary>
public const string PromptsListChanged = "notifications/prompts/list_changed";
/// <summary>
/// Sent by the server when the list of resources changes.
/// </summary>
public const string ResourceListChangedNotification = "notifications/resources/list_changed";
/// <summary>
/// Sent by the server when a resource is updated.
/// </summary>
public const string ResourceUpdatedNotification = "notifications/resources/updated";
/// <summary>
/// Sent by the client when roots have been updated.
/// </summary>
public const string RootsUpdatedNotification = "notifications/roots/list_changed";
}

View file

@ -0,0 +1,43 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>Provides names of standard operations for use with registering handlers.</summary>
/// <remarks>
/// These values should not be inspected or relied on for their exact values.
/// They serve only as opaque keys. They will be stable for the lifetime of a process
/// but may change between versions of this library.
/// </remarks>
public static class OperationNames
{
/// <summary>Gets the name of the sampling operation.</summary>
public static string Sampling { get; } = "operation/sampling";
/// <summary>Gets the name of the roots operation.</summary>
public static string Roots { get; } = "operation/roots";
/// <summary>Gets the name of the list tools operation.</summary>
public static string ListTools { get; } = "operation/listTools";
/// <summary>Gets the name of the call tool operation.</summary>
public static string CallTool { get; } = "operation/callTool";
/// <summary>Gets the name of the list prompts operation.</summary>
public static string ListPrompts { get; } = "operation/listPrompts";
/// <summary>Gets the name of the get prompt operation.</summary>
public static string GetPrompt { get; } = "operation/getPrompt";
/// <summary>Gets the name of the list resources operation.</summary>
public static string ListResources { get; } = "operation/listResources";
/// <summary>Gets the name of the read resource operation.</summary>
public static string ReadResource { get; } = "operation/readResource";
/// <summary>Gets the name of the get completion operation.</summary>
public static string GetCompletion { get; } = "operation/getCompletion";
/// <summary>Gets the name of the subscribe to resources operation.</summary>
public static string SubscribeToResources { get; } = "operation/subscribeToResources";
/// <summary>Gets the name of the subscribe to resources operation.</summary>
public static string UnsubscribeFromResources { get; } = "operation/unsubscribeFromResources";
}

View file

@ -0,0 +1,12 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// A paginated result payload.
/// </summary>
public class PaginatedResult
{
/// <summary>
/// An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.
/// </summary>
public string? NextCursor { get; set; }
}

View file

@ -0,0 +1,84 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Represents a JSON-RPC request identifier which can be either a string or a number.
/// </summary>
[JsonConverter(typeof(RequestIdConverter))]
public readonly struct RequestId : IEquatable<RequestId>
{
private readonly object _value;
private RequestId(object value)
{
_value = value;
}
/// <summary>
/// Creates a new RequestId from a string.
/// </summary>
/// <param name="value">The Id</param>
/// <returns>Wrapped Id object</returns>
public static RequestId FromString(string value) => new(value);
/// <summary>
/// Creates a new RequestId from a number.
/// </summary>
/// <param name="value">The Id</param>
/// <returns>Wrapped Id object</returns>
public static RequestId FromNumber(long value) => new(value);
/// <summary>
/// Checks if the RequestId is a string.
/// </summary>
public bool IsString => _value is string;
/// <summary>
/// Checks if the RequestId is a number.
/// </summary>
public bool IsNumber => _value is long;
/// <summary>
/// Checks if the request id is valid (has a value)
/// </summary>
public bool IsValid => _value != null;
/// <summary>
/// Gets the RequestId as a string.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the RequestId is not a string</exception>"
public string AsString => _value as string ?? throw new InvalidOperationException("RequestId is not a string");
/// <summary>
/// Gets the RequestId as a number.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the RequestId is not a number</exception>""
public long AsNumber => _value is long number ? number : throw new InvalidOperationException("RequestId is not a number");
/// <summary>
/// Returns the string representation of the RequestId. Will box the value if it is a number.
/// </summary>
public override string ToString() => _value.ToString() ?? "";
/// <summary>
/// Compares this RequestId to another RequestId.
/// </summary>
public bool Equals(RequestId other) => _value.Equals(other._value);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is RequestId other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => _value.GetHashCode();
/// <summary>
/// Compares two RequestIds for equality.
/// </summary>
public static bool operator ==(RequestId left, RequestId right) => left.Equals(right);
/// <summary>
/// Compares two RequestIds for inequality.
/// </summary>
public static bool operator !=(RequestId left, RequestId right) => !left.Equals(right);
}

View file

@ -0,0 +1,37 @@
using ModelContextProtocol.Utils;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// JSON converter for RequestId that handles both string and number values.
/// </summary>
public class RequestIdConverter : JsonConverter<RequestId>
{
/// <inheritdoc />
public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.String => RequestId.FromString(reader.GetString()!),
JsonTokenType.Number => RequestId.FromNumber(reader.GetInt64()),
_ => throw new JsonException("RequestId must be either a string or a number"),
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options)
{
Throw.IfNull(writer);
if (value.IsString)
{
writer.WriteStringValue(value.AsString);
}
else
{
writer.WriteNumberValue(value.AsNumber);
}
}
}

View file

@ -0,0 +1,267 @@
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// HTTP server provider using HttpListener.
/// </summary>
[ExcludeFromCodeCoverage]
internal class HttpListenerServerProvider : IDisposable
{
private readonly int _port;
private readonly string _sseEndpoint = "/sse";
private readonly string _messageEndpoint = "/message";
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Func<string, CancellationToken, bool>? _messageHandler;
private StreamWriter? _streamWriter;
private bool _isRunning;
/// <summary>
/// Creates a new instance of the HTTP server provider.
/// </summary>
/// <param name="port">The port to listen on</param>
public HttpListenerServerProvider(int port)
{
_port = port;
}
public Task<string> GetSseEndpointUri()
{
return Task.FromResult($"http://localhost:{_port}{_sseEndpoint}");
}
public Task InitializeMessageHandler(Func<string, CancellationToken, bool> messageHandler)
{
_messageHandler = messageHandler;
return Task.CompletedTask;
}
public async Task SendEvent(string data, string eventId)
{
if (_streamWriter == null)
{
throw new McpServerException("Stream writer not initialized");
}
if (eventId != null)
{
await _streamWriter.WriteLineAsync($"id: {eventId}").ConfigureAwait(false);
}
await _streamWriter.WriteLineAsync($"data: {data}").ConfigureAwait(false);
await _streamWriter.WriteLineAsync().ConfigureAwait(false); // Empty line to finish the event
await _streamWriter.FlushAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken = default)
{
if (_isRunning)
return Task.CompletedTask;
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{_port}/");
_listener.Start();
_isRunning = true;
// Start listening for connections
_ = Task.Run(() => ListenForConnectionsAsync(_cts.Token), cancellationToken).ConfigureAwait(false);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken = default)
{
if (!_isRunning)
return Task.CompletedTask;
_cts?.Cancel();
_listener?.Stop();
_streamWriter?.Close();
_isRunning = false;
return Task.CompletedTask;
}
private async Task ListenForConnectionsAsync(CancellationToken cancellationToken)
{
if (_listener == null)
{
throw new McpServerException("Listener not initialized");
}
while (!cancellationToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync().ConfigureAwait(false);
// Process the request in a separate task
_ = Task.Run(() => ProcessRequestAsync(context, cancellationToken), cancellationToken);
}
catch (Exception) when (cancellationToken.IsCancellationRequested)
{
// Shutdown requested, exit gracefully
break;
}
catch (Exception)
{
// Log error but continue listening
if (!cancellationToken.IsCancellationRequested)
{
// Continue listening if not shutting down
continue;
}
}
}
}
private async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
try
{
var request = context.Request;
var response = context.Response;
if (request == null)
throw new McpServerException("Request is null");
// 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
{
context.Response.StatusCode = 500;
context.Response.Close();
}
catch { /* Ignore errors during error handling */ }
}
}
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");
// Create a unique ID for this client
var clientId = Guid.NewGuid().ToString();
// Get the output stream and create a StreamWriter
var outputStream = response.OutputStream;
_streamWriter = new StreamWriter(outputStream, Encoding.UTF8) { AutoFlush = true };
// Keep the connection open until cancelled
try
{
// Immediately send the "endpoint" event with the POST URL
await _streamWriter.WriteLineAsync("event: endpoint").ConfigureAwait(false);
await _streamWriter.WriteLineAsync($"data: {_messageEndpoint}").ConfigureAwait(false);
await _streamWriter.WriteLineAsync().ConfigureAwait(false); // blank line to end an SSE message
await _streamWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
// Keep the connection open by "pinging" or just waiting
// until the client disconnects or the server is canceled.
while (!cancellationToken.IsCancellationRequested && response.OutputStream.CanWrite)
{
// Do a periodic no-op to keep connection alive:
await _streamWriter.WriteLineAsync(": keep-alive").ConfigureAwait(false);
await _streamWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
}
}
catch (TaskCanceledException)
{
// Normal shutdown
}
catch (Exception)
{
// Client disconnected or other error
}
finally
{
// Remove client on disconnect
try
{
_streamWriter.Close();
response.Close();
}
catch { /* Ignore errors during cleanup */ }
}
}
private async Task HandleMessageAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var request = context.Request;
var response = context.Response;
// Read the request body
string requestBody;
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
// TODO: Add cancellation token and netstandard2.0 support (polyfill?)
#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods
requestBody = await reader.ReadToEndAsync().ConfigureAwait(false);
#pragma warning restore CA2016 // Forward the 'CancellationToken' parameter to methods
}
// Process the message asynchronously
if (_messageHandler != null && _messageHandler(requestBody, cancellationToken))
{
// Return 202 Accepted
response.StatusCode = 202;
// Write "accepted" response
// TODO: Use WriteAsync, add cancellation token and netstandard2.0 support (polyfill?)
byte[] buffer = Encoding.UTF8.GetBytes("Accepted");
#pragma warning disable CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync'
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
#pragma warning restore CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync'
}
else
{
// Return 400 Bad Request
response.StatusCode = 400;
}
response.Close();
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <inheritdoc/>
protected virtual void Dispose(bool disposing)
{
StopAsync().GetAwaiter().GetResult();
_cts?.Dispose();
_listener?.Close();
}
}

View file

@ -0,0 +1,175 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils.Json;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Implements the MCP transport protocol over standard input/output streams.
/// </summary>
public sealed class HttpListenerSseServerTransport : TransportBase, IServerTransport
{
private readonly string _serverName;
private readonly HttpListenerServerProvider _httpServerProvider;
private readonly ILogger<HttpListenerSseServerTransport> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private CancellationTokenSource? _shutdownCts;
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)
: base(loggerFactory)
{
_serverName = serverName;
_logger = loggerFactory.CreateLogger<HttpListenerSseServerTransport>();
_jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions;
_httpServerProvider = new HttpListenerServerProvider(port);
}
/// <inheritdoc/>
public Task StartListeningAsync(CancellationToken cancellationToken = default)
{
_shutdownCts = new CancellationTokenSource();
_httpServerProvider.InitializeMessageHandler(HttpMessageHandler);
_httpServerProvider.StartAsync(cancellationToken);
SetConnected(true);
return Task.CompletedTask;
}
/// <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
{
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
_logger.TransportSendingMessage(EndpointName, id, json);
await _httpServerProvider.SendEvent(json, "message").ConfigureAwait(false);
_logger.TransportSentMessage(EndpointName, id);
}
catch (Exception ex)
{
_logger.TransportSendFailed(EndpointName, id, ex);
throw new McpTransportException("Failed to send message", ex);
}
}
/// <inheritdoc/>
public override async ValueTask DisposeAsync()
{
await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
GC.SuppressFinalize(this);
}
private async Task CleanupAsync(CancellationToken cancellationToken)
{
_logger.TransportCleaningUp(EndpointName);
if (_shutdownCts != null)
{
await _shutdownCts.CancelAsync().ConfigureAwait(false);
_shutdownCts.Dispose();
_shutdownCts = null;
}
_httpServerProvider.Dispose();
SetConnected(false);
_logger.TransportCleanedUp(EndpointName);
}
/// <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 bool HttpMessageHandler(string request, CancellationToken cancellationToken)
{
_logger.TransportReceivedMessage(EndpointName, request);
try
{
var message = JsonSerializer.Deserialize(request, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
if (message != null)
{
// Fire-and-forget the message to the message channel
Task.Run(async () =>
{
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
_logger.TransportMessageWritten(EndpointName, messageId);
}, cancellationToken);
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)
{
if (serverOptions is null)
{
throw new ArgumentNullException(nameof(serverOptions));
}
if (serverOptions.ServerInfo is null)
{
throw new ArgumentNullException($"{nameof(serverOptions)}.{nameof(serverOptions.ServerInfo)}");
}
return serverOptions.ServerInfo.Name;
}
}

View file

@ -0,0 +1,13 @@
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Represents a transport mechanism for MCP communication (from the client).
/// </summary>
public interface IClientTransport : ITransport
{
/// <summary>
/// Establishes the transport connection.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task ConnectAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,13 @@
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Represents a transport mechanism for MCP communication (from the server).
/// </summary>
public interface IServerTransport : ITransport
{
/// <summary>
/// Starts listening for incoming messages.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task StartListeningAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,27 @@
using System.Threading.Channels;
using ModelContextProtocol.Protocol.Messages;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// Represents a transport mechanism for MCP communication.
/// </summary>
public interface ITransport : IAsyncDisposable
{
/// <summary>
/// Gets whether the transport is currently connected.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Channel for receiving messages from the transport.
/// </summary>
ChannelReader<IJsonRpcMessage> MessageReader { get; }
/// <summary>
/// Sends a message through the transport.
/// </summary>
/// <param name="message">The message to send.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,37 @@
// Protocol/Transport/IMcpTransport.cs
namespace ModelContextProtocol.Protocol.Transport;
// Protocol/Transport/McpTransportException.cs
/// <summary>
/// Represents errors that occur in MCP transport operations.
/// </summary>
public class McpTransportException : Exception
{
/// <summary>
/// Initializes a new instance of the McpTransportException class.
/// </summary>
public McpTransportException()
{
}
/// <summary>
/// Initializes a new instance of the McpTransportException class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public McpTransportException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the McpTransportException class with a specified error message
/// and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public McpTransportException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,341 @@
using System.Net.Http.Headers;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>
/// The ServerSideEvents client transport implementation
/// </summary>
public sealed class SseClientTransport : TransportBase, IClientTransport
{
private readonly HttpClient _httpClient;
private readonly SseClientTransportOptions _options;
private readonly Uri _sseEndpoint;
private Uri? _messageEndpoint;
private CancellationTokenSource? _connectionCts;
private Task? _receiveTask;
private readonly ILogger _logger;
private readonly McpServerConfig _serverConfig;
private readonly JsonSerializerOptions _jsonOptions;
private readonly TaskCompletionSource<bool> _connectionEstablished;
private readonly bool _ownsHttpClient;
private string EndpointName => $"Client (SSE) for ({_serverConfig.Id}: {_serverConfig.Name})";
/// <summary>
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
/// The HTTP server can be local or remote, and must support the SSE protocol.
/// </summary>
/// <param name="transportOptions">Configuration options for the transport.</param>
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
: this(transportOptions, serverConfig, new HttpClient(), loggerFactory, true)
{
}
/// <summary>
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
/// The HTTP server can be local or remote, and must support the SSE protocol.
/// </summary>
/// <param name="transportOptions">Configuration options for the transport.</param>
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
/// <param name="httpClient">The HTTP client instance used for requests.</param>
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
/// <param name="ownsHttpClient">True to dispose HTTP client on close connection.</param>
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory, bool ownsHttpClient = false)
: base(loggerFactory)
{
Throw.IfNull(transportOptions);
Throw.IfNull(serverConfig);
Throw.IfNull(httpClient);
_options = transportOptions;
_serverConfig = serverConfig;
_sseEndpoint = new Uri(serverConfig.Location!);
_httpClient = httpClient;
_connectionCts = new CancellationTokenSource();
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
_jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions;
_connectionEstablished = new TaskCompletionSource<bool>();
_ownsHttpClient = ownsHttpClient;
}
/// <inheritdoc/>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
try
{
if (IsConnected)
{
_logger.TransportAlreadyConnected(EndpointName);
throw new McpTransportException("Transport is already connected");
}
// Start message receiving loop
_receiveTask = ReceiveMessagesAsync(_connectionCts!.Token);
_logger.TransportReadingMessages(EndpointName);
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
}
catch (McpTransportException)
{
// Rethrow transport exceptions
throw;
}
catch (Exception ex)
{
_logger.TransportConnectFailed(EndpointName, ex);
await CloseAsync().ConfigureAwait(false);
throw new McpTransportException("Failed to connect transport", ex);
}
}
/// <inheritdoc/>
public override async Task SendMessageAsync(
IJsonRpcMessage message,
CancellationToken cancellationToken = default)
{
if (_messageEndpoint == null)
throw new InvalidOperationException("Transport not connected");
using var content = new StringContent(
JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()),
Encoding.UTF8,
"application/json"
);
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
var response = await _httpClient.PostAsync(
_messageEndpoint,
content,
cancellationToken
).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// Check if the message was an initialize request
if (message is JsonRpcRequest request && request.Method == "initialize")
{
// If the response is not a JSON-RPC response, it is an SSE message
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
{
_logger.SSETransportPostAccepted(EndpointName, messageId);
// The response will arrive as an SSE message
}
else
{
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, _jsonOptions.GetTypeInfo<JsonRpcResponse>()) ??
throw new McpTransportException("Failed to initialize client");
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
_logger.TransportMessageWritten(EndpointName, messageId);
}
return;
}
// Otherwise, check if the response was accepted (the response will come as an SSE message)
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
{
_logger.SSETransportPostAccepted(EndpointName, messageId);
}
else
{
_logger.SSETransportPostNotAccepted(EndpointName, messageId, responseContent);
throw new McpTransportException("Failed to send message");
}
}
/// <inheritdoc/>
public async Task CloseAsync()
{
if (_connectionCts != null)
{
await _connectionCts.CancelAsync().ConfigureAwait(false);
_connectionCts.Dispose();
_connectionCts = null;
}
if (_receiveTask != null)
await _receiveTask.ConfigureAwait(false);
SetConnected(false);
}
/// <inheritdoc/>
public override async ValueTask DisposeAsync()
{
try
{
await CloseAsync().ConfigureAwait(false);
}
catch (Exception)
{
// Ignore exceptions on close
}
if (_ownsHttpClient)
_httpClient?.Dispose();
_connectionCts?.Dispose();
GC.SuppressFinalize(this);
}
internal Uri? MessageEndpoint => _messageEndpoint;
internal SseClientTransportOptions Options => _options;
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
{
int reconnectAttempts = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
// Reset reconnect attempts on successful connection
reconnectAttempts = 0;
await foreach (SseItem<string> sseEvent in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
{
switch (sseEvent.EventType)
{
case "endpoint":
HandleEndpointEvent(sseEvent.Data);
break;
case "message":
await ProcessSseMessage(sseEvent.Data, cancellationToken).ConfigureAwait(false);
break;
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.TransportReadMessagesCancelled(EndpointName);
// Normal shutdown
}
catch (IOException) when (cancellationToken.IsCancellationRequested)
{
_logger.TransportReadMessagesCancelled(EndpointName);
// Normal shutdown
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.TransportConnectionError(EndpointName, ex);
reconnectAttempts++;
if (reconnectAttempts >= _options.MaxReconnectAttempts)
{
throw new McpTransportException("Exceeded reconnect limit", ex);
}
await Task.Delay(_options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task ProcessSseMessage(string data, CancellationToken cancellationToken)
{
if (!IsConnected)
{
_logger.TransportMessageReceivedBeforeConnected(EndpointName, data);
return;
}
try
{
var message = JsonSerializer.Deserialize(data, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
if (message == null)
{
_logger.TransportMessageParseUnexpectedType(EndpointName, data);
return;
}
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
_logger.TransportMessageWritten(EndpointName, messageId);
}
catch (JsonException ex)
{
_logger.TransportMessageParseFailed(EndpointName, data, ex);
}
}
private void HandleEndpointEvent(string data)
{
try
{
if (string.IsNullOrEmpty(data))
{
_logger.TransportEndpointEventInvalid(EndpointName, data);
return;
}
// Check if data is absolute URI
if (data.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || data.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
// Since the endpoint is an absolute URI, we can use it directly
_messageEndpoint = new Uri(data);
}
else
{
// If the endpoint is a relative URI, we need to combine it with the relative path of the SSE endpoint
var hostUrl = _sseEndpoint.AbsoluteUri;
if (hostUrl.EndsWith("/sse", StringComparison.Ordinal))
hostUrl = hostUrl[..^4];
var endpointUri = $"{hostUrl.TrimEnd('/')}/{data.TrimStart('/')}";
_messageEndpoint = new Uri(endpointUri);
}
// Set connected state
SetConnected(true);
_connectionEstablished.TrySetResult(true);
}
catch (JsonException ex)
{
_logger.TransportEndpointEventParseFailed(EndpointName, data, ex);
throw new McpTransportException("Failed to parse endpoint event", ex);
}
}
}

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