From b5a26e4c336abbb3634e28c992590e4a0f64025b Mon Sep 17 00:00:00 2001 From: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> Date: Mon, 17 Mar 2025 22:56:03 +0100 Subject: [PATCH] HttpListener based SSE Server Support (#73) --- mcpdotnet.sln | 6 + .../McpServerBuilderExtensions.Transports.cs | 15 + .../Transport/HttpListenerServerProvider.cs | 267 +++++++++++++ .../HttpListenerSseServerTransport.cs | 175 +++++++++ .../Protocol/Transport/SseClientTransport.cs | 11 +- tests/mcpdotnet.TestSseServer/Program.cs | 358 ++++++++++++++++++ .../mcpdotnet.TestSseServer.csproj | 21 + ...pServerBuilderExtensionsTransportsTests.cs | 14 + tests/mcpdotnet.Tests/SseIntegrationTests.cs | 8 +- .../SseServerIntegrationTestFixture.cs | 83 ++++ .../SseServerIntegrationTests.cs | 235 ++++++++++++ ...tSseServer.cs => InMemoryTestSseServer.cs} | 8 +- tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj | 7 + 13 files changed, 1197 insertions(+), 11 deletions(-) create mode 100644 src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs create mode 100644 src/mcpdotnet/Protocol/Transport/HttpListenerSseServerTransport.cs create mode 100644 tests/mcpdotnet.TestSseServer/Program.cs create mode 100644 tests/mcpdotnet.TestSseServer/mcpdotnet.TestSseServer.csproj create mode 100644 tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs create mode 100644 tests/mcpdotnet.Tests/SseServerIntegrationTests.cs rename tests/mcpdotnet.Tests/Utils/{TestSseServer.cs => InMemoryTestSseServer.cs} (97%) diff --git a/mcpdotnet.sln b/mcpdotnet.sln index 4685b29..09efe66 100644 --- a/mcpdotnet.sln +++ b/mcpdotnet.sln @@ -21,6 +21,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{02EA EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{2A77AF5C-138A-4EBB-9A13-9205DCD67928}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet.TestSseServer", "tests\mcpdotnet.TestSseServer\mcpdotnet.TestSseServer.csproj", "{79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,10 @@ Global {6499876E-2F76-44A8-B6EB-5B889C6E9B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU {6499876E-2F76-44A8-B6EB-5B889C6E9B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU {6499876E-2F76-44A8-B6EB-5B889C6E9B7F}.Release|Any CPU.Build.0 = Release|Any CPU + {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs index 2eeae4e..2a44d40 100644 --- a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs @@ -21,4 +21,19 @@ public static partial class McpServerBuilderExtensions builder.Services.AddSingleton(); return builder; } + + /// + /// Adds a server transport that uses SSE via a HttpListener for communication. + /// + /// The builder instance. + public static IMcpServerBuilder WithHttpListenerSseServerTransport(this IMcpServerBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddSingleton(); + return builder; + } } diff --git a/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs b/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs new file mode 100644 index 0000000..a9fc8d2 --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs @@ -0,0 +1,267 @@ +using McpDotNet.Server; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Text; + +namespace McpDotNet.Protocol.Transport; + +/// +/// HTTP server provider using HttpListener. +/// +[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? _messageHandler; + private StreamWriter? _streamWriter; + private bool _isRunning; + + /// + /// Creates a new instance of the HTTP server provider. + /// + /// The port to listen on + public HttpListenerServerProvider(int port) + { + _port = port; + } + + public Task GetSseEndpointUri() + { + return Task.FromResult($"http://localhost:{_port}{_sseEndpoint}"); + } + + public Task InitializeMessageHandler(Func 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); + } + + /// + 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; + } + + /// + 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?) +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods + byte[] buffer = Encoding.UTF8.GetBytes("Accepted"); + response.OutputStream.Write(buffer, 0, buffer.Length); +#pragma warning restore CA2016 // Forward the 'CancellationToken' parameter to methods + } + else + { + // Return 400 Bad Request + response.StatusCode = 400; + } + + response.Close(); + } + + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + protected virtual void Dispose(bool disposing) + { + StopAsync().GetAwaiter().GetResult(); + _cts?.Dispose(); + _listener?.Close(); + } +} diff --git a/src/mcpdotnet/Protocol/Transport/HttpListenerSseServerTransport.cs b/src/mcpdotnet/Protocol/Transport/HttpListenerSseServerTransport.cs new file mode 100644 index 0000000..52e6b96 --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/HttpListenerSseServerTransport.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using McpDotNet.Protocol.Messages; +using McpDotNet.Utils.Json; +using McpDotNet.Logging; +using McpDotNet.Server; + +namespace McpDotNet.Protocol.Transport; + +/// +/// Implements the MCP transport protocol over standard input/output streams. +/// +public sealed class HttpListenerSseServerTransport : TransportBase, IServerTransport +{ + private readonly string _serverName; + private readonly HttpListenerServerProvider _httpServerProvider; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + private CancellationTokenSource? _shutdownCts; + + private string EndpointName => $"Server (SSE) ({_serverName})"; + + /// + /// Initializes a new instance of the SseServerTransport class. + /// + /// The server options. + /// The port to listen on. + /// A logger factory for creating loggers. + public HttpListenerSseServerTransport(McpServerOptions serverOptions, int port, ILoggerFactory loggerFactory) + : this(GetServerName(serverOptions), port, loggerFactory) + { + } + + /// + /// Initializes a new instance of the SseServerTransport class. + /// + /// The name of the server. + /// The port to listen on. + /// A logger factory for creating loggers. + public HttpListenerSseServerTransport(string serverName, int port, ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _serverName = serverName; + _logger = loggerFactory.CreateLogger(); + _jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions; + _httpServerProvider = new HttpListenerServerProvider(port); + } + + /// + public Task StartListeningAsync(CancellationToken cancellationToken = default) + { + _shutdownCts = new CancellationTokenSource(); + + _httpServerProvider.InitializeMessageHandler(HttpMessageHandler); + _httpServerProvider.StartAsync(cancellationToken); + + SetConnected(true); + + return Task.CompletedTask; + } + + + /// + 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); + _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); + } + } + + /// + 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); + } + + /// + /// Handles HTTP messages received by the HTTP server provider. + /// + /// true if the message was accepted (return 202), false otherwise (return 400) + private bool HttpMessageHandler(string request, CancellationToken cancellationToken) + { + _logger.TransportReceivedMessage(EndpointName, request); + + try + { + var message = JsonSerializer.Deserialize(request, _jsonOptions); + 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; + } + } + + /// Validates the and extracts from it the server name to use. + 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; + } +} diff --git a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs index 94e9d28..9759f3e 100644 --- a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs @@ -21,7 +21,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport private readonly SseClientTransportOptions _options; private readonly Uri _sseEndpoint; private Uri? _messageEndpoint; - private readonly CancellationTokenSource _connectionCts; + private CancellationTokenSource? _connectionCts; private Task? _receiveTask; private readonly ILogger _logger; private readonly McpServerConfig _serverConfig; @@ -82,7 +82,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport } // Start message receiving loop - _receiveTask = ReceiveMessagesAsync(_connectionCts.Token); + _receiveTask = ReceiveMessagesAsync(_connectionCts!.Token); _logger.TransportReadingMessages(EndpointName); @@ -168,7 +168,12 @@ public sealed class SseClientTransport : TransportBase, IClientTransport /// public async Task CloseAsync() { - await _connectionCts.CancelAsync().ConfigureAwait(false); + if (_connectionCts != null) + { + await _connectionCts.CancelAsync().ConfigureAwait(false); + _connectionCts.Dispose(); + _connectionCts = null; + } if (_receiveTask != null) await _receiveTask.ConfigureAwait(false); diff --git a/tests/mcpdotnet.TestSseServer/Program.cs b/tests/mcpdotnet.TestSseServer/Program.cs new file mode 100644 index 0000000..438a3a8 --- /dev/null +++ b/tests/mcpdotnet.TestSseServer/Program.cs @@ -0,0 +1,358 @@ +using McpDotNet.Protocol.Transport; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Serilog; +using System.Text; + +internal class Program +{ + private static ILoggerFactory CreateLoggerFactory() + { + // Use serilog + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() // Capture all log levels + .WriteTo.File(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "TestServer_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + var logsPath = Path.Combine(AppContext.BaseDirectory, "testserver.log"); + return LoggerFactory.Create(builder => + { + builder.AddSerilog(); + }); + } + + private static async Task Main(string[] args) + { + Console.WriteLine("Starting server..."); + + McpServerOptions options = new McpServerOptions() + { + ServerInfo = new Implementation() { Name = "TestServer", Version = "1.0.0" }, + Capabilities = new ServerCapabilities() + { + Tools = new(), + Resources = new(), + Prompts = new(), + }, + ProtocolVersion = "2024-11-05", + ServerInstructions = "This is a test server with only stub functionality" + }; + var loggerFactory = CreateLoggerFactory(); + + McpServerFactory factory = new McpServerFactory(new HttpListenerSseServerTransport("TestServer", 3001, loggerFactory), options, loggerFactory); + IMcpServer server = factory.CreateServer(); + + Console.WriteLine("Server object created, registering handlers."); + + #region Helped method + static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) + { + return new CreateMessageRequestParams() + { + Messages = [new SamplingMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = $"Resource {uri} context: {context}" + } + }], + SystemPrompt = "You are a helpful test server.", + MaxTokens = maxTokens, + Temperature = 0.7f, + IncludeContext = ContextInclusion.ThisServer + }; + } + #endregion + + #region Tools + server.SetListToolsHandler((request, cancellationToken) => + { + return Task.FromResult(new ListToolsResult() + { + Tools = + [ + new Tool() + { + Name = "echo", + Description = "Echoes the input back to the client.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." } + } + }, + }, + new Tool() + { + Name = "sampleLLM", + Description = "Samples from an LLM using MCP's sampling feature.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["prompt"] = new JsonSchemaProperty() { Type = "string", Description = "The prompt to send to the LLM" }, + ["maxTokens"] = new JsonSchemaProperty() { Type = "number", Description = "Maximum number of tokens to generate" } + } + }, + } + ] + }); + }); + + server.SetCallToolHandler(async (request, cancellationToken) => + { + if (request.Params is null) + { + throw new McpServerException("Missing required parameter 'name'"); + } + if (request.Params.Name == "echo") + { + if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message)) + { + throw new McpServerException("Missing required argument 'message'"); + } + return new CallToolResponse() + { + Content = [new Content() { Text = "Echo: " + message.ToString(), Type = "text" }] + }; + } + else if (request.Params.Name == "sampleLLM") + { + if (request.Params.Arguments is null || + !request.Params.Arguments.TryGetValue("prompt", out var prompt) || + !request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens)) + { + throw new McpServerException("Missing required arguments 'prompt' and 'maxTokens'"); + } + var sampleResult = await server.RequestSamplingAsync(CreateRequestSamplingParams(prompt?.ToString() ?? "", "sampleLLM", Convert.ToInt32(maxTokens?.ToString())), + cancellationToken); + + return new CallToolResponse() + { + Content = [new Content() { Text = $"LLM sampling result: {sampleResult.Content.Text}", Type = "text" }] + }; + } + else + { + throw new McpServerException($"Unknown tool: {request.Params.Name}"); + } + }); + #endregion + + #region Resources + List resources = new(); + List resourceContents = new(); + for (int i = 0; i < 100; ++i) + { + string uri = $"test://static/resource/{i + 1}"; + if (i % 2 == 0) + { + resources.Add(new Resource() + { + Uri = uri, + Name = $"Resource {i + 1}", + MimeType = "text/plain" + }); + resourceContents.Add(new ResourceContents() + { + Uri = uri, + MimeType = "text/plain", + Text = $"Resource {i + 1}: This is a plaintext resource" + }); + } + else + { + var buffer = Encoding.UTF8.GetBytes($"Resource {i + 1}: This is a base64 blob"); + resources.Add(new Resource() + { + Uri = uri, + Name = $"Resource {i + 1}", + MimeType = "application/octet-stream" + }); + resourceContents.Add(new ResourceContents() + { + Uri = uri, + MimeType = "application/octet-stream", + Blob = Convert.ToBase64String(buffer) + }); + } + } + + const int pageSize = 10; + + server.SetListResourcesHandler((request, cancellationToken) => + { + int startIndex = 0; + var requestParams = request.Params ?? new(); + if (requestParams.Cursor is not null) + { + try + { + var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(requestParams.Cursor)); + startIndex = Convert.ToInt32(startIndexAsString); + } + catch + { + throw new McpServerException("Invalid cursor"); + } + } + + int endIndex = Math.Min(startIndex + pageSize, resources.Count); + string? nextCursor = null; + + if (endIndex < resources.Count) + { + nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString())); + } + return Task.FromResult(new ListResourcesResult() + { + NextCursor = nextCursor, + Resources = resources.GetRange(startIndex, endIndex - startIndex) + }); + }); + + server.SetReadResourceHandler((request, cancellationToken) => + { + if (request.Params?.Uri is null) + { + throw new McpServerException("Missing required argument 'uri'"); + } + ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri); + if (contents is null) + { + throw new McpServerException("Resource not found"); + } + return Task.FromResult(new ReadResourceResult() + { + Contents = [contents] + }); + }); + #endregion + + #region Prompts + server.SetListPromptsHandler((request, cancellationToken) => + { + return Task.FromResult(new ListPromptsResult() + { + Prompts = [ + new Prompt() + { + Name = "simple_prompt", + Description = "A prompt without arguments" + }, + new Prompt() + { + Name = "complex_prompt", + Description = "A prompt with arguments", + Arguments = new() + { + new PromptArgument() + { + Name = "temperature", + Description = "Temperature setting", + Required = true + }, + new PromptArgument() + { + Name = "style", + Description = "Output style", + Required = false + } + } + } + ] + }); + }); + + server.SetGetPromptHandler((request, cancellationToken) => + { + if (request.Params is null) + { + throw new McpServerException("Missing required parameter 'name'"); + } + List messages = new(); + if (request.Params.Name == "simple_prompt") + { + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = "This is a simple prompt without arguments." + } + }); + } + else if (request.Params.Name == "complex_prompt") + { + string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown"; + string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown"; + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}" + } + }); + messages.Add(new PromptMessage() + { + Role = Role.Assistant, + Content = new Content() + { + Type = "text", + Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?" + } + }); + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "image", + Data = MCP_TINY_IMAGE, + MimeType = "image/png" + } + }); + } + else + { + throw new McpServerException($"Unknown prompt: {request.Params.Name}"); + } + + return Task.FromResult(new GetPromptResult() + { + Messages = messages + }); + }); + #endregion + + #region Sampling + #endregion + + Console.WriteLine("Server initialized."); + + await server.StartAsync(); + + Console.WriteLine("Server started."); + + // Run until process is stopped by the client (parent process) + while (true) + { + await Task.Delay(1000); + } + } + + const string MCP_TINY_IMAGE = + "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"; +} \ No newline at end of file diff --git a/tests/mcpdotnet.TestSseServer/mcpdotnet.TestSseServer.csproj b/tests/mcpdotnet.TestSseServer/mcpdotnet.TestSseServer.csproj new file mode 100644 index 0000000..2004fad --- /dev/null +++ b/tests/mcpdotnet.TestSseServer/mcpdotnet.TestSseServer.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + TestSseServer + + + + + + + + + + + + + diff --git a/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs index 1218be5..4e4ac7b 100644 --- a/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs +++ b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs @@ -20,4 +20,18 @@ public class McpServerBuilderExtensionsTransportsTests Assert.NotNull(transportType); Assert.Equal(typeof(StdioServerTransport), transportType.ImplementationType); } + + [Fact] + public void WithHttpListenerSseServerTransport_Sets_Transport() + { + var services = new ServiceCollection(); + var builder = new Mock(); + builder.SetupGet(b => b.Services).Returns(services); + + builder.Object.WithHttpListenerSseServerTransport(); + + var transportType = services.FirstOrDefault(s => s.ServiceType == typeof(IServerTransport)); + Assert.NotNull(transportType); + Assert.Equal(typeof(HttpListenerSseServerTransport), transportType.ImplementationType); + } } diff --git a/tests/mcpdotnet.Tests/SseIntegrationTests.cs b/tests/mcpdotnet.Tests/SseIntegrationTests.cs index 27748f5..6638073 100644 --- a/tests/mcpdotnet.Tests/SseIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/SseIntegrationTests.cs @@ -18,7 +18,7 @@ public class SseIntegrationTests builder.AddConsole() .SetMinimumLevel(LogLevel.Debug)); - await using TestSseServer server = new(logger: loggerFactory.CreateLogger()); + await using InMemoryTestSseServer server = new(logger: loggerFactory.CreateLogger()); await server.StartAsync(); @@ -164,7 +164,7 @@ public class SseIntegrationTests builder.AddConsole() .SetMinimumLevel(LogLevel.Debug)); - await using TestSseServer server = new(logger: loggerFactory.CreateLogger()); + await using InMemoryTestSseServer server = new(logger: loggerFactory.CreateLogger()); server.UseFullUrlForEndpointEvent = true; await server.StartAsync(); @@ -204,7 +204,7 @@ public class SseIntegrationTests builder.AddConsole() .SetMinimumLevel(LogLevel.Debug)); - await using TestSseServer server = new(logger: loggerFactory.CreateLogger()); + await using InMemoryTestSseServer server = new(logger: loggerFactory.CreateLogger()); await server.StartAsync(); @@ -253,7 +253,7 @@ public class SseIntegrationTests builder.AddConsole() .SetMinimumLevel(LogLevel.Debug)); - await using TestSseServer server = new(logger: loggerFactory.CreateLogger()); + await using InMemoryTestSseServer server = new(logger: loggerFactory.CreateLogger()); await server.StartAsync(); diff --git a/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs new file mode 100644 index 0000000..05fd988 --- /dev/null +++ b/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs @@ -0,0 +1,83 @@ +using McpDotNet.Client; +using McpDotNet.Configuration; +using McpDotNet.Protocol.Transport; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace McpDotNet.Tests; + +public class SseServerIntegrationTestFixture : IDisposable +{ + private Process _process; + + public ILoggerFactory LoggerFactory { get; } + public McpClientFactory Factory { get; } + public McpClientOptions DefaultOptions { get; } + public McpServerConfig DefaultConfig { get; } + + public SseServerIntegrationTestFixture() + { + LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => + builder.AddConsole() + .SetMinimumLevel(LogLevel.Debug)); + + DefaultOptions = new() + { + ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, + Capabilities = new() { Sampling = new(), Roots = new() } + }; + + DefaultConfig = new McpServerConfig + { + Id = "test_server", + Name = "TestServer", + TransportType = TransportTypes.Sse, + TransportOptions = [], + Location = "http://localhost:3001/sse" + }; + + // Inject the mock transport into the factory + Factory = new McpClientFactory( + [DefaultConfig], + DefaultOptions, + LoggerFactory + ); + + Start(); + } + + public void Start() + { + // Start the server (which is at TestSseServer.exe on windows and "dotnet TestSseServer.dll" on linux) + var processStartInfo = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "TestSseServer.exe" : "dotnet", + Arguments = "TestSseServer.dll", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + _process = Process.Start(processStartInfo) + ?? throw new InvalidOperationException($"Could not start process for {processStartInfo.FileName} with '{processStartInfo.Arguments}'."); + + // Wait 1 second + Thread.Sleep(1000); + } + + public void Dispose() + { + try + { + var client = Factory.GetClientAsync("test_server").Result; + client.DisposeAsync().AsTask().Wait(); + LoggerFactory?.Dispose(); + } + finally + { + // Kill the server process + _process.Kill(); + } + } +} \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs b/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs new file mode 100644 index 0000000..76b7e53 --- /dev/null +++ b/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs @@ -0,0 +1,235 @@ +using McpDotNet.Client; +using McpDotNet.Protocol.Types; + +namespace McpDotNet.Tests; + +public class SseServerIntegrationTests : IClassFixture +{ + private readonly SseServerIntegrationTestFixture _fixture; + + public SseServerIntegrationTests(SseServerIntegrationTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task ConnectAndPing_Sse_TestServer() + { + // Arrange + + // Act + var client = await _fixture.Factory.GetClientAsync("test_server"); + await client.PingAsync(CancellationToken.None); + + // Assert + Assert.NotNull(client); + } + + [Fact] + public async Task Connect_TestServer_ShouldProvideServerFields() + { + // Arrange + + // Act + var client = await _fixture.Factory.GetClientAsync("test_server"); + + // Assert + Assert.NotNull(client.ServerCapabilities); + Assert.NotNull(client.ServerInfo); + } + + [Fact] + public async Task ListTools_Sse_TestServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var tools = await client.ListToolsAsync().ToListAsync(); + + // assert + Assert.NotNull(tools); + } + + [Fact] + public async Task CallTool_Sse_EchoServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var result = await client.CallToolAsync( + "echo", + new Dictionary + { + ["message"] = "Hello MCP!" + }, + CancellationToken.None + ); + + // assert + Assert.NotNull(result); + Assert.False(result.IsError); + var textContent = Assert.Single(result.Content, c => c.Type == "text"); + Assert.Equal("Echo: Hello MCP!", textContent.Text); + } + + [Fact] + public async Task ListResources_Sse_TestServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + + List allResources = []; + string? cursor = null; + do + { + var resources = await client.ListResourcesAsync(cursor, CancellationToken.None); + allResources.AddRange(resources.Resources); + cursor = resources.NextCursor; + } + while (cursor != null); + + // The everything server provides 100 test resources + Assert.Equal(100, allResources.Count); + } + + [Fact] + public async Task ReadResource_Sse_TextResource() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + // Odd numbered resources are text in the everything server (despite the docs saying otherwise) + // 1 is index 0, which is "even" in the 0-based index + // We copied this oddity to the test server + var result = await client.ReadResourceAsync("test://static/resource/1", CancellationToken.None); + + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.NotNull(result.Contents[0].Text); + } + + [Fact] + public async Task ReadResource_Sse_BinaryResource() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + // Even numbered resources are binary in the everything server (despite the docs saying otherwise) + // 2 is index 1, which is "odd" in the 0-based index + // We copied this oddity to the test server + var result = await client.ReadResourceAsync("test://static/resource/2", CancellationToken.None); + + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.NotNull(result.Contents[0].Blob); + } + + [Fact] + public async Task ListPrompts_Sse_TestServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var prompts = await client.ListPromptsAsync().ToListAsync(); + + // assert + Assert.NotNull(prompts); + Assert.NotEmpty(prompts); + // We could add specific assertions for the known prompts + Assert.Contains(prompts, p => p.Name == "simple_prompt"); + Assert.Contains(prompts, p => p.Name == "complex_prompt"); + } + + [Fact] + public async Task GetPrompt_Sse_SimplePrompt() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var result = await client.GetPromptAsync("simple_prompt", null, CancellationToken.None); + + // assert + Assert.NotNull(result); + Assert.NotEmpty(result.Messages); + } + + [Fact] + public async Task GetPrompt_Sse_ComplexPrompt() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var arguments = new Dictionary + { + { "temperature", "0.7" }, + { "style", "formal" } + }; + var result = await client.GetPromptAsync("complex_prompt", arguments, CancellationToken.None); + + // assert + Assert.NotNull(result); + Assert.NotEmpty(result.Messages); + } + + [Fact] + public async Task GetPrompt_Sse_NonExistent_ThrowsException() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + await Assert.ThrowsAsync(() => + client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None)); + } + + [Fact] + public async Task Sampling_Sse_TestServer() + { + // arrange + var client = await _fixture.Factory.GetClientAsync("test_server"); + + // Set up the sampling handler + int samplingHandlerCalls = 0; +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + client.SetSamplingHandler(async (_, _) => + { + samplingHandlerCalls++; + return new CreateMessageResult + { + Model = "test-model", + Role = "assistant", + Content = new Content + { + Type = "text", + Text = "Test response" + } + }; + }); +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + + // Call the server's sampleLLM tool which should trigger our sampling handler + var result = await client.CallToolAsync( + "sampleLLM", + new Dictionary + { + ["prompt"] = "Test prompt", + ["maxTokens"] = 100 + } + ); + + // assert + Assert.NotNull(result); + var textContent = Assert.Single(result.Content); + Assert.Equal("text", textContent.Type); + Assert.False(string.IsNullOrEmpty(textContent.Text)); + } +} diff --git a/tests/mcpdotnet.Tests/Utils/TestSseServer.cs b/tests/mcpdotnet.Tests/Utils/InMemoryTestSseServer.cs similarity index 97% rename from tests/mcpdotnet.Tests/Utils/TestSseServer.cs rename to tests/mcpdotnet.Tests/Utils/InMemoryTestSseServer.cs index 38b34c3..3848f81 100644 --- a/tests/mcpdotnet.Tests/Utils/TestSseServer.cs +++ b/tests/mcpdotnet.Tests/Utils/InMemoryTestSseServer.cs @@ -7,11 +7,11 @@ using Microsoft.Extensions.Logging.Abstractions; namespace McpDotNet.Tests.Utils; -public sealed class TestSseServer : IAsyncDisposable +public sealed class InMemoryTestSseServer : IAsyncDisposable { private readonly HttpListener _listener; private readonly CancellationTokenSource _cts; - private readonly ILogger _logger; + private readonly ILogger _logger; private Task? _serverTask; private readonly TaskCompletionSource _connectionEstablished = new(); @@ -23,12 +23,12 @@ public sealed class TestSseServer : IAsyncDisposable // Keep track of all open SSE connections (StreamWriters). private readonly ConcurrentBag _sseClients = []; - public TestSseServer(int port = 5000, ILogger? logger = null) + public InMemoryTestSseServer(int port = 5000, ILogger? logger = null) { _listener = new HttpListener(); _listener.Prefixes.Add($"http://localhost:{port}/"); _cts = new CancellationTokenSource(); - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; _endpointPath = "/sse"; _messagePath = "/message"; diff --git a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj index fb8c869..db1b3fd 100644 --- a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj +++ b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj @@ -32,6 +32,7 @@ + @@ -41,6 +42,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest +