SSE Transport Support (#13)

* First iteration

* Tests

* SSE support
This commit is contained in:
Peder Holdgaard Pedersen 2025-01-12 16:35:39 +01:00 • committed by GitHub
commit e51c78e5e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1200 additions and 43 deletions

View file

@ -2,7 +2,6 @@
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Types;
using Microsoft.Extensions.Logging;
namespace McpDotNet.Tests;

View file

@ -1,4 +1,3 @@
using Castle.Core.Logging;
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
@ -6,7 +5,6 @@ using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using System.Threading.Channels;
using Xunit;
namespace McpDotNet.Tests.Client;
@ -192,23 +190,177 @@ public class McpClientFactoryTests
}
[Fact]
public async Task GetClientAsync_WithHttpTransport_ThrowsNotImplementedException()
public async Task GetClientAsync_WithSseTransport_CanCreateClient()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "http",
TransportType = "sse",
Location = "http://localhost:8080"
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
// Inject the mock transport into the factory
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _, _) => mockClient.Object
);
// Act
var client = await factory.GetClientAsync("test-server");
// Assert
Assert.NotNull(client);
}
[Fact]
public void McpFactory_WithSse_CreatesCorrectTransportOptions()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080",
TransportOptions = new Dictionary<string, string>
{
["connectionTimeout"] = "10",
["maxReconnectAttempts"] = "2",
["reconnectDelay"] = "5",
["header.test"] = "the_header_value"
}
};
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
var transport = factory.TransportFactoryMethod(config) as SseTransport;
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == TimeSpan.FromSeconds(10));
Assert.True(transport.Options.MaxReconnectAttempts == 2);
Assert.True(transport.Options.ReconnectDelay == TimeSpan.FromSeconds(5));
Assert.True(transport.Options.AdditionalHeaders["test"] == "the_header_value");
}
[Fact]
public void McpFactory_WithSseAndNoOptions_CreatesDefaultTransportOptions()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
var factory = new McpClientFactory([config], _defaultOptions,
NullLoggerFactory.Instance);
var defaultOptions = new SseTransportOptions();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => factory.GetClientAsync("test-server")
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
var transport = factory.TransportFactoryMethod(config) as SseTransport;
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == defaultOptions.ConnectionTimeout);
Assert.True(transport.Options.MaxReconnectAttempts == defaultOptions.MaxReconnectAttempts);
Assert.True(transport.Options.ReconnectDelay == defaultOptions.ReconnectDelay);
Assert.True(transport.Options.AdditionalHeaders == null && defaultOptions.AdditionalHeaders == null);
}
}
[Fact]
public void McpFactory_WithSseAndMissingOptions_CreatesCorrectTransportOptions()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080",
TransportOptions = new Dictionary<string, string>
{
["connectionTimeout"] = "10",
["header.test"] = "the_header_value"
}
};
var defaultOptions = new SseTransportOptions();
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
var transport = factory.TransportFactoryMethod(config) as SseTransport;
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == TimeSpan.FromSeconds(10));
Assert.True(transport.Options.MaxReconnectAttempts == defaultOptions.MaxReconnectAttempts);
Assert.True(transport.Options.ReconnectDelay == defaultOptions.ReconnectDelay);
Assert.True(transport.Options.AdditionalHeaders["test"] == "the_header_value");
}
[Theory]
[InlineData("connectionTimeout", "not_a_number")]
[InlineData("maxReconnectAttempts", "invalid")]
[InlineData("reconnectDelay", "bad_value")]
public void McpFactory_WithInvalidTransportOptions_ThrowsFormatException(string key, string value)
{
// arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080",
TransportOptions = new Dictionary<string, string>
{
[key] = value
}
};
// Act
var factory = new McpClientFactory(
[config],
_defaultOptions,
NullLoggerFactory.Instance
);
// act & assert
Assert.Throws<FormatException>(() =>
factory.TransportFactoryMethod(config));
}
}

View file

@ -0,0 +1,164 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using McpDotNet.Tests.Utils;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace McpDotNet.Tests;
public class SseIntegrationTests
{
[Fact]
public async Task ConnectAndReceiveMessage_InMemoryServer()
{
// Arrange
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
await using TestSseServer server = new(logger:loggerFactory.CreateLogger<TestSseServer>());
await server.StartAsync();
var defaultOptions = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var defaultConfig = new McpServerConfig
{
Id = "test_server",
Name = "In-memory Test Server",
TransportType = "sse",
TransportOptions = new Dictionary<string, string>(),
Location = "http://localhost:5000/sse"
};
var factory = new McpClientFactory(
[defaultConfig],
defaultOptions,
loggerFactory
);
// Act
var client = await factory.GetClientAsync("test_server");
// Wait for SSE connection to be established
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
// Send a test message through POST endpoint
var testMessage = new JsonRpcRequest()
{
Id = RequestId.FromNumber(1),
Method = "test/message",
Params = new { message = "Hello, SSE!" }
};
using var httpClient = new HttpClient();
await httpClient.PostAsync(
"http://localhost:5000/message",
new StringContent(JsonSerializer.Serialize(testMessage))
);
// Assert
Assert.True(true);
}
[Fact]
public async Task ConnectAndReceiveNotification_InMemoryServer()
{
// Arrange
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
await using TestSseServer server = new(logger: loggerFactory.CreateLogger<TestSseServer>());
await server.StartAsync();
var defaultOptions = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var defaultConfig = new McpServerConfig
{
Id = "test_server",
Name = "In-memory Test Server",
TransportType = "sse",
TransportOptions = new Dictionary<string, string>(),
Location = "http://localhost:5000/sse"
};
var factory = new McpClientFactory(
[defaultConfig],
defaultOptions,
loggerFactory
);
// Act
var client = await factory.GetClientAsync("test_server");
// Wait for SSE connection to be established
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
var receivedNotification = new TaskCompletionSource<string>();
client.OnNotification("test/notification", async (args) =>
{
var msg = ((JsonElement)args.Params).GetProperty("message").GetString();
receivedNotification.SetResult(msg);
});
// Act
await server.SendTestNotificationAsync("Hello from server!");
// Assert
var message = await receivedNotification.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal("Hello from server!", message);
}
[Fact]
public async Task ConnectTwice_Throws()
{
// Arrange
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
await using TestSseServer server = new(logger: loggerFactory.CreateLogger<TestSseServer>());
await server.StartAsync();
var defaultOptions = new McpClientOptions
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
var defaultConfig = new McpServerConfig
{
Id = "test_server",
Name = "In-memory Test Server",
TransportType = "sse",
TransportOptions = new Dictionary<string, string>(),
Location = "http://localhost:5000/sse"
};
var factory = new McpClientFactory(
[defaultConfig],
defaultOptions,
loggerFactory
);
// Act
var client = await factory.GetClientAsync("test_server");
var mcpClient = (McpClient)client;
var transport = (SseTransport)mcpClient.Transport;
// Wait for SSE connection to be established
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
// Assert
await Assert.ThrowsAsync<McpTransportException>(async () => await transport.ConnectAsync());
}
}

View file

@ -0,0 +1,74 @@
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.Logging.Abstractions;
namespace McpDotNet.Tests.Client;
public class SseTransportTests
{
[Fact]
public void SseTransportConstructor_WithValidConfig_CreatesTransport()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
var transportOptions = new SseTransportOptions
{
ConnectionTimeout = TimeSpan.FromSeconds(10),
MaxReconnectAttempts = 2,
ReconnectDelay = TimeSpan.FromSeconds(5),
AdditionalHeaders = new Dictionary<string, string>
{
["test"] = "header"
}
};
// Act
var transport = new SseTransport(transportOptions, config, NullLoggerFactory.Instance);
// Assert
Assert.NotNull(transport);
Assert.True(transport.Options.ConnectionTimeout == TimeSpan.FromSeconds(10));
Assert.True(transport.Options.MaxReconnectAttempts == 2);
Assert.True(transport.Options.ReconnectDelay == TimeSpan.FromSeconds(5));
Assert.True(transport.Options.AdditionalHeaders["test"] == "header");
}
[Fact]
public async void SseTransportSendMessageAsync_WithMessageEndpointNotSet_ThrowsException()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "sse",
Location = "http://localhost:8080"
};
var transportOptions = new SseTransportOptions
{
ConnectionTimeout = TimeSpan.FromSeconds(10),
MaxReconnectAttempts = 2,
ReconnectDelay = TimeSpan.FromSeconds(5),
AdditionalHeaders = new Dictionary<string, string>
{
["test"] = "header"
}
};
// Act
var transport = new SseTransport(transportOptions, config, NullLoggerFactory.Instance);
// Assert
Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString()));
await Assert.ThrowsAsync<InvalidOperationException>(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test"}, CancellationToken.None));
}
}

View file

@ -0,0 +1,354 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using McpDotNet.Protocol.Messages;
namespace McpDotNet.Tests.Utils;
public sealed class TestSseServer : IAsyncDisposable
{
private readonly HttpListener _listener;
private readonly CancellationTokenSource _cts;
private readonly ILogger<TestSseServer> _logger;
private Task? _serverTask;
private readonly TaskCompletionSource _connectionEstablished = new();
// SSE endpoint for GET
private readonly string _endpointPath;
// POST endpoint
private readonly string _messagePath;
// Keep track of all open SSE connections (StreamWriters).
private readonly ConcurrentBag<StreamWriter> _sseClients = new();
public TestSseServer(int port = 5000, ILogger<TestSseServer>? logger = null)
{
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
_cts = new CancellationTokenSource();
_logger = logger ?? NullLogger<TestSseServer>.Instance;
_endpointPath = "/sse";
_messagePath = "/message";
}
/// <summary>
/// Full URL for the SSE endpoint, e.g. "http://localhost:5000/sse"
/// </summary>
public string SseEndpoint
=> $"http://localhost:{_listener.Prefixes.First().Split(':')[2].TrimEnd('/')}{_endpointPath}";
/// <summary>
/// Full URL for the message endpoint, e.g. "http://localhost:5000/message"
/// </summary>
public string MessageEndpoint
=> $"http://localhost:{_listener.Prefixes.First().Split(':')[2].TrimEnd('/')}{_messagePath}";
/// <summary>
/// Starts the server so it can accept incoming connections and POST requests.
/// </summary>
public async Task StartAsync()
{
_listener.Start();
_serverTask = HandleConnectionsAsync(_cts.Token);
_logger.LogInformation("Test SSE server started on {Endpoint}", SseEndpoint);
await Task.CompletedTask;
}
private async Task HandleConnectionsAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context, ct), ct);
}
}
catch (OperationCanceledException)
{
// Ignore, we are shutting down
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
_logger.LogError(ex, "Error in SSE server connection handling");
}
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct)
{
var request = context.Request;
var response = context.Response;
// Handle SSE endpoint
if (request.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase)
&& request.Url.AbsolutePath.Equals(_endpointPath, StringComparison.OrdinalIgnoreCase))
{
await HandleSseConnectionAsync(context, ct);
}
// Handle POST /message
else if (request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase)
&& request.Url.AbsolutePath.Equals(_messagePath, StringComparison.OrdinalIgnoreCase))
{
await HandlePostMessageAsync(context, ct);
}
else
{
response.StatusCode = 404;
response.Close();
}
}
/// <summary>
/// Handle Server-Sent Events (SSE) connection.
/// Send the initial event: endpoint with the full POST URL.
/// Keep the connection open until the server is disposed or the client disconnects.
/// </summary>
private async Task HandleSseConnectionAsync(HttpListenerContext context, CancellationToken ct)
{
var response = context.Response;
response.ContentType = "text/event-stream";
response.Headers["Cache-Control"] = "no-cache";
// Ensures the response is never chunked away by the framework
response.SendChunked = true;
response.StatusCode = (int)HttpStatusCode.OK;
using var writer = new StreamWriter(response.OutputStream);
_sseClients.Add(writer);
// Immediately send the "endpoint" event with the POST URL
var endpointData = new
{
uri = MessageEndpoint
};
var json = JsonSerializer.Serialize(endpointData);
await writer.WriteLineAsync("event: endpoint");
await writer.WriteLineAsync($"data: {json}");
await writer.WriteLineAsync(); // blank line to end an SSE message
await writer.FlushAsync();
_logger.LogInformation("New SSE client connected.");
_connectionEstablished.TrySetResult(); // Signal connection is ready
try
{
// Keep the connection open by "pinging" or just waiting
// until the client disconnects or the server is canceled.
while (!ct.IsCancellationRequested && response.OutputStream.CanWrite)
{
_logger.LogDebug("SSE connection alive check");
// Optionally do a periodic no-op to keep connection alive:
await writer.WriteLineAsync(": keep-alive");
await writer.FlushAsync();
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
catch (TaskCanceledException)
{
// This is expected on shutdown
_logger.LogInformation("SSE connection cancelled (expected on shutdown)");
}
catch (IOException)
{
// Client likely disconnected
_logger.LogInformation("SSE client disconnected");
}
finally
{
// Remove this writer from bag (we're disposing it anyway)
_sseClients.TryTake(out _);
_logger.LogInformation("SSE client disconnected.");
}
}
// Add method to wait for connection
public Task WaitForConnectionAsync(TimeSpan timeout) =>
_connectionEstablished.Task.WaitAsync(timeout);
/// <summary>
/// Handle POST /message endpoint.
/// Echo the content back to the caller and broadcast it over SSE as well.
/// </summary>
private async Task HandlePostMessageAsync(HttpListenerContext context, CancellationToken ct)
{
var request = context.Request;
var response = context.Response;
try
{
using var reader = new StreamReader(request.InputStream);
string content = await reader.ReadToEndAsync();
var jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content);
if (jsonRpcRequest != null && !jsonRpcRequest.Method.StartsWith("notification"))
{
if (jsonRpcRequest.Method == "initialize")
{
await HandleInitializationRequest(response, jsonRpcRequest);
return;
}
// Method not found - proper JSON-RPC error response
await SendJsonRpcErrorAsync(response, jsonRpcRequest.Id, -32601, $"Method '{jsonRpcRequest.Method}' not found");
return;
}
var notification = JsonSerializer.Deserialize<JsonRpcNotification>(content);
if (notification != null)
{
response.StatusCode = 200;
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0" }));
return;
}
// Invalid JSON-RPC message - proper JSON-RPC error response
await SendJsonRpcErrorAsync(response, null, -32700, "Parse error");
}
catch (Exception ex)
{
await SendJsonRpcErrorAsync(response, null, -32603, "Internal error", ex.Message);
response.StatusCode = 500;
}
finally
{
response.Close();
}
}
private async Task SendJsonRpcErrorAsync(HttpListenerResponse response, RequestId? id, int code, string message, string? data = null)
{
var errorResponse = new JsonRpcError
{
Id = id ?? RequestId.FromString("error"),
JsonRpc = "2.0",
Error = new JsonRpcErrorDetail
{
Code = code,
Message = message,
Data = data
}
};
response.StatusCode = 200; // Always 200 for JSON-RPC
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(errorResponse));
}
private async Task HandleInitializationRequest(HttpListenerResponse response, JsonRpcRequest jsonRpcRequest)
{
// We don't need to validate the client's initialization request for the test
// Just send back a valid server initialization response
var jsonResponse = new
{
protocolVersion = "2024-11-05",
capabilities = new
{
experimental = (object?)null,
roots = (object?)null,
sampling = (object?)null
}
};
var jsonRpcResponse = new JsonRpcResponse()
{
Id = jsonRpcRequest.Id,
Result = new
{
protocolVersion = "2024-11-05",
capabilities = new
{
experimental = (object?)null,
roots = (object?)null,
sampling = (object?)null
},
serverInfo = new
{
name = "ExampleServer",
version = "1.0.0"
}
}
};
// Echo back to the HTTP response
response.StatusCode = 200;
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(jsonRpcResponse));
}
/// <summary>
/// Broadcast a message to all currently connected SSE clients.
/// </summary>
/// <param name="message">The raw string to send</param>
public async Task BroadcastMessageAsync(string message)
{
foreach (var client in _sseClients.ToArray()) // ToArray to avoid mutation issues
{
try
{
// SSE requires "event: <name>" + "data: <payload>" + blank line
await client.WriteLineAsync("event: message");
await client.WriteLineAsync($"data: {message}");
await client.WriteLineAsync();
await client.FlushAsync();
}
catch (IOException)
{
// Client may have disconnected. We let them get cleaned up on next iteration.
}
catch (ObjectDisposedException)
{
// Stream is disposed, ignore.
}
}
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
if (_serverTask != null)
{
try
{
await _serverTask;
}
catch (TaskCanceledException)
{
// ignore
}
}
_listener.Close();
_cts.Dispose();
_logger.LogInformation("Test SSE server stopped");
}
/// <summary>
/// Send a test notification to all connected SSE clients.
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
public async Task SendTestNotificationAsync(string content)
{
var notification = new JsonRpcNotification
{
JsonRpc = "2.0",
Method = "test/notification",
Params = new { message = content }
};
var serialized = JsonSerializer.Serialize(notification);
await BroadcastMessageAsync(serialized);
}
}

View file

@ -315,6 +315,8 @@ internal class McpClient : IMcpClient
).ConfigureAwait(false);
}
internal IMcpTransport Transport => _transport;
private static Dictionary<string, object?> CreateParametersDictionary(string nameParameter, Dictionary<string, object>? optionalArguments = null)
{
var parameters = new Dictionary<string, object?>

View file

@ -89,6 +89,8 @@ public class McpClientFactory
return client;
}
internal Func<McpServerConfig, IMcpTransport> TransportFactoryMethod => _transportFactoryMethod;
private IMcpTransport CreateTransport(McpServerConfig config)
{
var options = string.Join(", ", config.TransportOptions?.Select(kv => $"{kv.Key}={kv.Value}") ?? Enumerable.Empty<string>());
@ -104,10 +106,31 @@ public class McpClientFactory
.Where(kv => kv.Key.StartsWith("env:"))
.ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value)
}, config, _loggerFactory),
"sse" or "http" => new SseTransport(
new SseTransportOptions
{
ConnectionTimeout = TimeSpan.FromSeconds(ParseOrDefault(config.TransportOptions, "connectionTimeout", 30)),
MaxReconnectAttempts = ParseOrDefault(config.TransportOptions, "maxReconnectAttempts", 3),
ReconnectDelay = TimeSpan.FromSeconds(ParseOrDefault(config.TransportOptions, "reconnectDelay", 5)),
AdditionalHeaders = config.TransportOptions?
.Where(kv => kv.Key.StartsWith("header."))
.ToDictionary(kv => kv.Key.Substring(7), kv => kv.Value)
}, config, _loggerFactory),
_ => throw new ArgumentException($"Unsupported transport type '{config.TransportType}'.", nameof(config))
};
}
private static int ParseOrDefault(IDictionary<string, string>? options, string key, int defaultValue)
{
if (options?.TryGetValue(key, out var value) ?? false)
{
if (!int.TryParse(value, out var result))
throw new FormatException($"Invalid value '{value}' for option '{key}'");
return result;
}
return defaultValue;
}
private string GetCommand(McpServerConfig config)
{
if (config.TransportOptions == null ||

View file

@ -277,4 +277,49 @@ internal static partial class Log
[LoggerMessage(Level = LogLevel.Information, Message = "Sending notification for {serverId} ({serverName}): {method}")]
internal static partial void SendingNotification(this ILogger logger, string serverId, string serverName, string method);
[LoggerMessage(
EventId = 7000,
Level = LogLevel.Error,
Message = "Transport connection error for server {serverId} ({serverName})"
)]
public static partial void TransportConnectionError(
this ILogger logger,
string serverId,
string serverName,
Exception exception);
[LoggerMessage(
EventId = 7001,
Level = LogLevel.Warning,
Message = "Transport message received before connected for server {serverId} ({serverName}): {data}"
)]
public static partial void TransportMessageReceivedBeforeConnected(
this ILogger logger,
string serverId,
string serverName,
string data);
[LoggerMessage(
EventId = 7002,
Level = LogLevel.Error,
Message = "Transport endpoint event received out of order for server {serverId} ({serverName}): {data}"
)]
public static partial void TransportEndpointEventInvalid(
this ILogger logger,
string serverId,
string serverName,
string data);
[LoggerMessage(
EventId = 7003,
Level = LogLevel.Error,
Message = "Transport event parse failed for server {serverId} ({serverName}): {data}"
)]
public static partial void TransportEndpointEventParseFailed(
this ILogger logger,
string serverId,
string serverName,
string data,
Exception exception);
}

View file

@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
/// <summary>
/// An error response message in the JSON-RPC protocol.
/// </summary>
internal record JsonRpcError : IJsonRpcMessageWithId
public record JsonRpcError : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".

View file

@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
/// <summary>
/// Detailed error information for JSON-RPC error responses.
/// </summary>
internal record JsonRpcErrorDetail
public record JsonRpcErrorDetail
{
/// <summary>
/// Integer error code.

View file

@ -5,7 +5,7 @@ namespace McpDotNet.Protocol.Messages;
/// <summary>
/// A successful response message in the JSON-RPC protocol.
/// </summary>
internal record JsonRpcResponse : IJsonRpcMessageWithId
public record JsonRpcResponse : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".

View file

@ -1,9 +1,7 @@
// Protocol/Transport/IMcpTransport.cs
using System.Threading.Channels;
using McpDotNet.Protocol.Messages;
namespace McpDotNet.Protocol.Transport;
using System.Threading.Channels;
using global::McpDotNet.Protocol.Messages;
/// <summary>
/// Represents a transport mechanism for MCP communication.
/// </summary>

View file

@ -0,0 +1,303 @@

using System.Diagnostics.Tracing;
using System.Text.Json;
using System.Text;
using McpDotNet.Protocol.Messages;
using McpDotNet.Configuration;
using Microsoft.Extensions.Logging;
using McpDotNet.Logging;
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
using McpDotNet.Utils.Json;
using System.Threading;
namespace McpDotNet.Protocol.Transport;
/// <summary>
///
/// </summary>
public sealed class SseTransport : TransportBase
{
private readonly HttpClient _httpClient;
private readonly SseTransportOptions _options;
private readonly Uri _sseEndpoint;
private Uri? _messageEndpoint;
private readonly CancellationTokenSource _connectionCts;
private Task? _receiveTask;
private readonly ILogger<SseTransport> _logger;
private readonly McpServerConfig _serverConfig;
private readonly JsonSerializerOptions _jsonOptions;
private TaskCompletionSource<bool> _connectionEstablished;
/// <summary>
///
/// </summary>
/// <param name="transportOptions"></param>
/// <param name="serverConfig"></param>
/// <param name="loggerFactory"></param>
public SseTransport(SseTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory loggerFactory)
{
_options = transportOptions;
_serverConfig = serverConfig;
_sseEndpoint = new Uri(serverConfig.Location!);
_httpClient = new HttpClient();
_connectionCts = new CancellationTokenSource();
_logger = loggerFactory.CreateLogger<SseTransport>();
_jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory);
_connectionEstablished = new TaskCompletionSource<bool>();
}
/// <inheritdoc/>
public override async Task ConnectAsync(CancellationToken cancellationToken = default)
{
try
{
if (IsConnected)
{
_logger.TransportAlreadyConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpTransportException("Transport is already connected");
}
// Start message receiving loop
_receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
_logger.TransportReadingMessages(_serverConfig.Id, _serverConfig.Name);
await Task.WhenAny(
_connectionEstablished.Task,
Task.Delay(_options.ConnectionTimeout, cancellationToken)
);
if (!IsConnected)
throw new TimeoutException("Failed to receive endpoint event");
}
catch (McpTransportException)
{
// Rethrow transport exceptions
throw;
}
catch (Exception ex)
{
_logger.TransportConnectFailed(_serverConfig.Id, _serverConfig.Name, ex);
await CloseAsync();
throw new McpTransportException("Failed to connect transport", ex);
}
}
public override async Task SendMessageAsync(
IJsonRpcMessage message,
CancellationToken cancellationToken = default)
{
if (_messageEndpoint == null)
throw new InvalidOperationException("Transport not connected");
var content = new StringContent(
JsonSerializer.Serialize(message, _jsonOptions),
Encoding.UTF8,
"application/json"
);
var response = await _httpClient.PostAsync(
_messageEndpoint,
content,
cancellationToken
);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
// Handle notifications, which don't have responses
if (message is JsonRpcNotification)
{
await HandleNotificationResponseAsync(message, responseContent, cancellationToken);
return;
}
var responseMessage = JsonSerializer.Deserialize<IJsonRpcMessage>(responseContent, _jsonOptions);
if (responseMessage != null)
{
string messageId = "(no id)";
if (responseMessage is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
_logger.TransportReceivedMessageParsed(_serverConfig.Id, _serverConfig.Name, messageId);
await WriteMessageAsync(responseMessage, cancellationToken);
_logger.TransportMessageWritten(_serverConfig.Id, _serverConfig.Name, messageId);
}
else
{
_logger.TransportMessageParseUnexpectedType(_serverConfig.Id, _serverConfig.Name, responseContent);
}
}
/// <inheritdoc/>
public async Task CloseAsync()
{
_connectionCts.Cancel();
if (_receiveTask != null)
await _receiveTask;
_httpClient.Dispose();
_connectionCts.Dispose();
SetConnected(false);
}
/// <inheritdoc/>
public override async ValueTask DisposeAsync()
{
await CloseAsync();
GC.SuppressFinalize(this);
}
private async Task HandleNotificationResponseAsync(
IJsonRpcMessage message,
string responseContent,
CancellationToken cancellationToken)
{
// Check for error response, but don't require one
try
{
var errorResponse = JsonSerializer.Deserialize<JsonRpcError>(responseContent, _jsonOptions);
if (errorResponse is not null)
{
await WriteMessageAsync(errorResponse, cancellationToken);
return;
}
}
catch (JsonException)
{
// Ignore deserialization errors for notifications
// This is expected as most notifications won't have responses
}
}
internal Uri? MessageEndpoint => _messageEndpoint;
internal SseTransportOptions Options => _options;
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
{
int reconnectAttempts = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
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
);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
// Reset reconnect attempts on successful connection
reconnectAttempts = 0;
string? currentEvent = null;
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event: "))
{
currentEvent = line.Substring(7);
}
else if (line.StartsWith("data: "))
{
var data = line.Substring(6);
if (currentEvent == "endpoint")
{
HandleEndpointEvent(data);
}
else
{
await ProcessSseMessage(data, cancellationToken);
}
}
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.TransportConnectionError(_serverConfig.Id, _serverConfig.Name, ex);
reconnectAttempts++;
if (reconnectAttempts >= _options.MaxReconnectAttempts)
{
throw new McpTransportException("Exceeded reconnect limit", ex);
}
await Task.Delay(_options.ReconnectDelay, cancellationToken);
}
}
}
private async Task ProcessSseMessage(string data, CancellationToken cancellationToken)
{
if (!IsConnected)
{
_logger.TransportMessageReceivedBeforeConnected(_serverConfig.Id, _serverConfig.Name, data);
return;
}
try
{
var message = JsonSerializer.Deserialize<IJsonRpcMessage>(data, _jsonOptions);
if (message == null)
{
_logger.TransportMessageParseUnexpectedType(_serverConfig.Id, _serverConfig.Name, data);
return;
}
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
_logger.TransportReceivedMessageParsed(_serverConfig.Id, _serverConfig.Name, messageId);
await WriteMessageAsync(message, cancellationToken);
_logger.TransportMessageWritten(_serverConfig.Id, _serverConfig.Name, messageId);
}
catch (JsonException ex)
{
_logger.TransportMessageParseFailed(_serverConfig.Id, _serverConfig.Name, data, ex);
}
}
private void HandleEndpointEvent(string data)
{
try
{
var endpointData = JsonSerializer.Deserialize<EndpointEventData>(data, _jsonOptions);
if (endpointData?.Uri == null)
{
_logger.TransportEndpointEventInvalid(_serverConfig.Id, _serverConfig.Name, data);
return;
}
_messageEndpoint = new Uri(endpointData.Uri);
SetConnected(true);
_connectionEstablished.TrySetResult(true);
}
catch (JsonException ex)
{
_logger.TransportEndpointEventParseFailed(_serverConfig.Id, _serverConfig.Name, data, ex);
throw new McpTransportException("Failed to parse endpoint event", ex);
}
}
private record EndpointEventData
{
[JsonPropertyName("uri")]
public string? Uri { get; init; }
}
}

View file

@ -0,0 +1,27 @@
namespace McpDotNet.Protocol.Transport;
/// <summary>
/// Options for configuring the SSE transport.
/// </summary>
public record SseTransportOptions
{
/// <summary>
/// Timeout for initial connection and endpoint event.
/// </summary>
public TimeSpan ConnectionTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Number of reconnection attempts for SSE connection.
/// </summary>
public int MaxReconnectAttempts { get; init; } = 3;
/// <summary>
/// Delay between reconnection attempts.
/// </summary>
public TimeSpan ReconnectDelay { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Headers to include in HTTP requests.
/// </summary>
public Dictionary<string, string>? AdditionalHeaders { get; init; }
}

View file

@ -9,7 +9,7 @@
<PropertyGroup>
<!-- license and package properties -->
<PackageId>mcpdotnet</PackageId>
<Version>0.5.0.1</Version>
<Version>0.6.0.1</Version>
<Authors>PederHP</Authors>
<Description>.NET client library for the Model Context Protocol (MCP)</Description>
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl>
@ -29,6 +29,10 @@
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="McpDotNet.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />