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
17 changed files with 1200 additions and 43 deletions

View file

@ -1,5 +1,5 @@
# mcpdotnet
[![NuGet version](https://img.shields.io/nuget/v/mcpdotnet.svg?style=flat)](https://www.nuget.org/packages/mcpdotnet/)
[![NuGet version](https://img.shields.io/nuget/v/mcpdotnet.svg)](https://www.nuget.org/packages/mcpdotnet/)
A .NET client implementation of the Model Context Protocol (MCP), enabling .NET applications to connect to and interact with MCP servers.
@ -12,23 +12,15 @@ For more information about MCP:
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
## Status
## Design Goals
This project is in early development. While functional, it may undergo significant changes as the protocol and implementation evolve.
### Current Limitations
- Only stdio transport is currently supported (HTTP/SSE transport coming soon)
- Client library only - server implementation support may be added in the future
- Provider-agnostic implementation focused purely on the MCP protocol
- Applications using this library need to implement their own integration with LLM APIs/clients
The library's current focus is on implementing the core MCP protocol, with the main utility being the ability to launch and connect to MCP servers based on configuration objects.
This library aims to provide a clean, specification-compliant implementation of the MCP protocol, with minimal additional abstraction. While transport implementations necessarily include additional code, they follow patterns established by the official SDKs where possible.
## Features
- MCP client implementation for .NET applications
- Support for stdio transport
- Support for all capabilities: Tool, Resource, Prompt, Sampling, Roots
- Support for stdio and SSE transports
- Support for all MCP capabilities: Tool, Resource, Prompt, Sampling, Roots
- Support for pagination and notifications
- Async/await pattern throughout
- Comprehensive logging support
@ -36,7 +28,13 @@ The library's current focus is on implementing the core MCP protocol, with the m
## Getting Started
To create a client you need to instantiate a factory with configuration objects:
To use mcpdotnet, first install it via NuGet:
```powershell
dotnet add package mcpdotnet
```
Then create a client and start using tools, or other capabilities, from the servers you configure:
```csharp
var options = new McpClientOptions()
{ ClientInfo = new() { Name = "TestClient", Version = "1.0.0" } };
@ -58,14 +56,12 @@ var factory = new McpClientFactory(
options,
NullLoggerFactory.Instance
);
```
You can then retrieve an IMcpClient reference using the `id` of a server from the configuration, and use `ListToolsAsync` and `CallToolAsync` to integrate with your LLM-based code.
```csharp
var client = await factory.GetClientAsync("everything");
// Get the list of tools, for passing to an LLM
var tools = await client.ListToolsAsync();
// Execute a tool, in practice this would normally be driven by LLM tool invocations
var result = await client.CallToolAsync(
"echo",
@ -75,6 +71,7 @@ var result = await client.CallToolAsync(
},
CancellationToken.None
);
// echo always returns one and only one text content object
Console.WriteLine(result.Content.FirstOrDefault(c => c.Type == "text").Text);
```
@ -85,6 +82,16 @@ It is also highly recommended that you pass a proper LoggerFactory instance to t
Additional examples and documentation will be added as in the near future.
## Roadmap
- Expand documentation with detailed guides for:
- Advanced scenarios (Sampling, Resources, Prompts)
- Transport configuration
- Error handling and recovery
- Increase test coverage
- Add example applications
- Performance optimization and profiling
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

15
TODO.md
View file

@ -1,13 +1,18 @@
# TODOs for mcpdotnet
## Integration Testing
- [ ] Add comprehensive Roots feature testing once reference servers implement support
- (Contribute PR to everything server adding `listRoots` tool)
- Add integration tests similar to Sampling tests
- Verify roots notification handling
- Add comprehensive Roots feature testing once reference servers implement support
- Consider using everything server adding `listRoots` tool)
- Add integration tests similar to Sampling tests
- Verify roots notification handling
- Expand SSE test server to support all features or use a reference SSE server if one becomes available
## Future Improvements
- [ ] Add HTTPS/SSE transport support
- [X] Add HTTPS/SSE transport support
- [ ] Add more example applications showing different capabilities
- [ ] Add comprehensive documentation for advanced scenarios
- [ ] Profile and optimize performance
- [ ] Linux support in stdio transport
## Code Improvements
- [ ] Consolidate notification handling in McpClient to reduce duplication between SendNotificationAsync overloads

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" />