From b5076263d13035c7c1eac728506414437d187927 Mon Sep 17 00:00:00 2001 From: Toni Wenzel Date: Wed, 12 Mar 2025 18:55:52 +0100 Subject: [PATCH] Add more tests (#51) * Add more tests * Fix some code smells * Remove more code smells * Combine "ServerIntegrationTests" and "ClientIntegrationTests" because both are client integration tests just using different servers * Clean up resources after test * Improve kill logic for spawned child processes * Improved testability for Stdio * Fix SseIntegration tests --- .../McpSessionScope.cs | 3 +- src/mcpdotnet/Client/McpClientFactory.cs | 47 ++- .../McpServerBuilderExtensions.Tools.cs | 6 +- .../McpServerServiceCollectionExtension.cs | 2 + .../Protocol/Transport/InputOutputStreams.cs | 26 ++ .../Protocol/Transport/SseClientTransport.cs | 6 +- .../Transport/StdioClientTransport.cs | 17 +- .../Transport/StdioClientTransportOptions.cs | 7 +- .../Transport/StdioServerTransport.cs | 34 ++- src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs | 6 + src/mcpdotnet/Utils/ProcessHelper.cs | 108 +++++++ tests/mcpdotnet.TestServer/Program.cs | 38 +++ .../{ => Client}/McpClientFactoryTests.cs | 20 +- .../ClientIntegrationTestFixture.cs | 26 +- .../mcpdotnet.Tests/ClientIntegrationTests.cs | 288 +++++++----------- .../McpServerBuilderExtensionsHandlerTests.cs | 2 +- .../McpServerBuilderExtensionsToolsTests.cs | 42 +-- ...pServerBuilderExtensionsTransportsTests.cs | 2 +- .../mcpdotnet.Tests/Server/McpServerTests.cs | 51 ++-- .../ServerIntegrationTestFixture.cs | 57 ---- .../mcpdotnet.Tests/ServerIntegrationTests.cs | 237 -------------- .../SseClientTransportTests.cs | 173 ----------- tests/mcpdotnet.Tests/SseIntegrationTests.cs | 21 +- .../Transport/SseClientTransportTests.cs | 280 +++++++++++++++++ .../Transport/StdioServerTransportTests.cs | 133 ++++++++ tests/mcpdotnet.Tests/Utils/TestSseServer.cs | 2 +- 26 files changed, 883 insertions(+), 751 deletions(-) create mode 100644 src/mcpdotnet/Protocol/Transport/InputOutputStreams.cs create mode 100644 src/mcpdotnet/Utils/ProcessHelper.cs rename tests/mcpdotnet.Tests/{ => Client}/McpClientFactoryTests.cs (95%) rename tests/mcpdotnet.Tests/{ => Configuration}/McpServerBuilderExtensionsHandlerTests.cs (99%) rename tests/mcpdotnet.Tests/{ => Configuration}/McpServerBuilderExtensionsToolsTests.cs (90%) rename tests/mcpdotnet.Tests/{ => Configuration}/McpServerBuilderExtensionsTransportsTests.cs (94%) delete mode 100644 tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs delete mode 100644 tests/mcpdotnet.Tests/ServerIntegrationTests.cs delete mode 100644 tests/mcpdotnet.Tests/SseClientTransportTests.cs create mode 100644 tests/mcpdotnet.Tests/Transport/SseClientTransportTests.cs create mode 100644 tests/mcpdotnet.Tests/Transport/StdioServerTransportTests.cs diff --git a/src/McpDotNet.Extensions.AI/McpSessionScope.cs b/src/McpDotNet.Extensions.AI/McpSessionScope.cs index 5982b22..c448c92 100644 --- a/src/McpDotNet.Extensions.AI/McpSessionScope.cs +++ b/src/McpDotNet.Extensions.AI/McpSessionScope.cs @@ -86,9 +86,10 @@ public sealed class McpSessionScope : IAsyncDisposable McpClientOptions? options, ILoggerFactory? loggerFactory = null) { - var factory = new McpClientFactory([config], + using var factory = new McpClientFactory([config], options ?? new() { ClientInfo = new() { Name = "AnonymousClient", Version = "1.0.0.0" } }, loggerFactory ?? NullLoggerFactory.Instance); + factory.DisposeClientsOnDispose = false; var client = await factory.GetClientAsync(config.Id).ConfigureAwait(false); _clients.Add(client); return client; diff --git a/src/mcpdotnet/Client/McpClientFactory.cs b/src/mcpdotnet/Client/McpClientFactory.cs index 6f5e7da..c4247b1 100644 --- a/src/mcpdotnet/Client/McpClientFactory.cs +++ b/src/mcpdotnet/Client/McpClientFactory.cs @@ -1,4 +1,5 @@ -using System.Runtime.InteropServices; +using System.Globalization; +using System.Runtime.InteropServices; using McpDotNet.Configuration; using McpDotNet.Logging; using McpDotNet.Protocol.Transport; @@ -11,7 +12,7 @@ namespace McpDotNet.Client; /// All server configurations must be passed in the constructor. /// Capabilities (as defined in client options) are shared across all clients, as the client host can always decide not to use certain capabilities. /// -public class McpClientFactory +public class McpClientFactory : IDisposable { private const string ARGUMENTS_OPTIONS_KEY = "arguments"; private const string COMMAND_OPTIONS_KEY = "command"; @@ -22,6 +23,12 @@ public class McpClientFactory private readonly Func _clientFactoryMethod; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; + private bool _isDisposed; + + /// + /// Gets or sets a value indicating whether clients should be disposed when the factory is disposed. + /// + public bool DisposeClientsOnDispose { get; set; } = true; /// /// Initializes a new instance of the class. @@ -109,7 +116,8 @@ public class McpClientFactory WorkingDirectory = config.TransportOptions?.GetValueOrDefault("workingDirectory"), EnvironmentVariables = config.TransportOptions? .Where(kv => kv.Key.StartsWith("env:", StringComparison.Ordinal)) - .ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value) + .ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value), + ShutdownTimeout = TimeSpan.TryParse(config.TransportOptions?.GetValueOrDefault("shutdownTimeout"), CultureInfo.InvariantCulture, out var timespan) ? timespan : StdioClientTransportOptions.DefaultShutdownTimeout }, config, _loggerFactory); } @@ -181,4 +189,37 @@ public class McpClientFactory ? $"/c {command} {args}" : $"/c {command}"; } + + /// + /// Disposes all clients created by the factory. + /// + /// + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing && DisposeClientsOnDispose) + DisposeClients(); + + _isDisposed = true; + } + } + + private void DisposeClients() + { + foreach (var client in _clients.Values) + { + client?.DisposeAsync().AsTask().Wait(); + } + + _clients.Clear(); + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs index 5bed7b5..bbc030f 100644 --- a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs @@ -116,7 +116,7 @@ public static partial class McpServerBuilderExtensions if (toolTypes.Count == 0) throw new ArgumentException("No types with marked methods found in the assembly.", nameof(assembly)); - return WithTools(builder, toolTypes.ToArray()); + return WithTools(builder, [.. toolTypes]); } private static Tool CreateTool(MethodInfo method, McpToolAttribute mcpAttribute) @@ -187,7 +187,7 @@ public static partial class McpServerBuilderExtensions BindingFlags.DoNotWrapExceptions | #endif BindingFlags.Default; - result = method.Invoke(objectInstance, InvokeFlags, binder: null, parameters.ToArray(), culture: null); + result = method.Invoke(objectInstance, InvokeFlags, binder: null, [.. parameters], culture: null); } catch (TargetInvocationException e) when (e.InnerException is not null) { @@ -206,7 +206,7 @@ public static partial class McpServerBuilderExtensions return new CallToolResponse { Content = [new Content() { Text = resultString, Type = "text" }] }; if (result is string[] resultStringArray) - return new CallToolResponse { Content = resultStringArray.Select(s => new Content() { Text = s, Type = "text" }).ToList() }; + return new CallToolResponse { Content = [.. resultStringArray.Select(s => new Content() { Text = s, Type = "text" })] }; if (result is null) return new CallToolResponse { Content = [new Content() { Text = "null" }] }; diff --git a/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs b/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs index 476efbd..c0aa8fc 100644 --- a/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs +++ b/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs @@ -1,6 +1,7 @@ using System.Reflection; using McpDotNet.Configuration; using McpDotNet.Hosting; +using McpDotNet.Protocol.Transport; using McpDotNet.Protocol.Types; using McpDotNet.Server; using Microsoft.Extensions.DependencyInjection; @@ -38,6 +39,7 @@ public static class McpServerServiceCollectionExtension services.AddSingleton(); services.AddHostedService(); services.AddOptions(); + services.AddSingleton(InputOutputStreams.Default); services.AddSingleton(sp => sp.GetRequiredService().CreateServer()); diff --git a/src/mcpdotnet/Protocol/Transport/InputOutputStreams.cs b/src/mcpdotnet/Protocol/Transport/InputOutputStreams.cs new file mode 100644 index 0000000..deccafe --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/InputOutputStreams.cs @@ -0,0 +1,26 @@ +namespace McpDotNet.Protocol.Transport; + +/// +/// Container for the input and output streams. +/// +public class InputOutputStreams +{ + /// + /// Gets the default input and output streams. + /// + public static readonly InputOutputStreams Default = new() + { + Output = Console.Out, + Input = Console.In + }; + + /// + /// Gets or sets the output stream. + /// + public required TextWriter Output { get; init; } + + /// + /// Gets or sets the input stream. + /// + public required TextReader Input { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs index 549ba66..9cc4c7a 100644 --- a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs @@ -119,7 +119,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport if (_messageEndpoint == null) throw new InvalidOperationException("Transport not connected"); - var content = new StringContent( + using var content = new StringContent( JsonSerializer.Serialize(message, _jsonOptions), Encoding.UTF8, "application/json" @@ -217,7 +217,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport { try { - var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint); + using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var response = await _httpClient.SendAsync( @@ -328,7 +328,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport if (hostUrl.EndsWith("/sse", StringComparison.Ordinal)) hostUrl = hostUrl[..^4]; - var endpointUri = hostUrl + data; + var endpointUri = $"{hostUrl.TrimEnd('/')}/{data.TrimStart('/')}"; _messageEndpoint = new Uri(endpointUri); } diff --git a/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs index 3b0e707..0aa4293 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs @@ -5,6 +5,7 @@ using System.Text.Json; using McpDotNet.Configuration; using McpDotNet.Logging; using McpDotNet.Protocol.Messages; +using McpDotNet.Utils; using McpDotNet.Utils.Json; using Microsoft.Extensions.Logging; @@ -245,12 +246,10 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport // Wait for the process to exit _logger.TransportWaitingForShutdown(EndpointName); - if (!_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds)) - { - // If it doesn't exit gracefully, terminate it - _logger.TransportKillingProcess(EndpointName); - _process.Kill(true); - } + + // Kill the while process tree because the process may spawn child processes + // and Node.js does not kill its children when it exits properly + _process.KillTree(_options.ShutdownTimeout); } catch (Exception ex) { @@ -262,9 +261,11 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport } if (_shutdownCts != null) + { await _shutdownCts.CancelAsync().ConfigureAwait(false); - _shutdownCts?.Dispose(); - _shutdownCts = null; + _shutdownCts.Dispose(); + _shutdownCts = null; + } if (_readTask != null) { diff --git a/src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs b/src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs index 926e635..35f303d 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs @@ -6,6 +6,11 @@ namespace McpDotNet.Protocol.Transport; /// public record StdioClientTransportOptions { + /// + /// The default timeout to wait for the server to shut down gracefully. + /// + public static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(5); + /// /// The command to execute to start the server process. /// @@ -29,5 +34,5 @@ public record StdioClientTransportOptions /// /// The timeout to wait for the server to shut down gracefully. /// - public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(5); + public TimeSpan ShutdownTimeout { get; init; } = DefaultShutdownTimeout; } diff --git a/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs index 6832eb9..d202eae 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs @@ -18,6 +18,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport private readonly JsonSerializerOptions _jsonOptions; private Task? _readTask; private CancellationTokenSource? _shutdownCts; + private readonly TextReader _input; + private readonly TextWriter _output; private string EndpointName => $"Server (stdio) ({_serverName})"; @@ -26,8 +28,14 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport /// /// The server options. /// A logger factory for creating loggers. - public StdioServerTransport(McpServerOptions serverOptions, ILoggerFactory? loggerFactory) - : this(serverOptions is not null ? serverOptions.ServerInfo.Name : throw new ArgumentNullException(nameof(serverOptions)), loggerFactory) + /// Container for the input and output streams + public StdioServerTransport(McpServerOptions serverOptions, ILoggerFactory? loggerFactory, InputOutputStreams inputOutputStreams) + : this( + serverOptions is not null ? serverOptions.ServerInfo.Name : throw new ArgumentNullException(nameof(serverOptions)), + loggerFactory, + inputOutputStreams is not null ? inputOutputStreams.Output : throw new ArgumentNullException(nameof(inputOutputStreams)), + inputOutputStreams.Input + ) { } @@ -37,11 +45,26 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport /// The name of the server. /// A logger factory for creating loggers. public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory) + : this(serverName, loggerFactory, Console.Out, Console.In) + { + + } + + /// + /// Initializes a new instance of the StdioServerTransport class. + /// + /// The name of the server. + /// A logger factory for creating loggers. + /// The output stream writer + /// The input stream reader + public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory, TextWriter output, TextReader input) : base(loggerFactory) { _serverName = serverName; _logger = loggerFactory is not null ? loggerFactory.CreateLogger() : NullLogger.Instance; _jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions; + _input = input ?? throw new ArgumentNullException(nameof(input)); + _output = output ?? throw new ArgumentNullException(nameof(output)); } /// @@ -56,7 +79,6 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport return Task.CompletedTask; } - /// public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) { @@ -77,8 +99,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport var json = JsonSerializer.Serialize(message, _jsonOptions); _logger.TransportSendingMessage(EndpointName, id, json); - await Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); - await Console.Out.FlushAsync(cancellationToken).ConfigureAwait(false); + await _output.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + await _output.FlushAsync(cancellationToken).ConfigureAwait(false); _logger.TransportSentMessage(EndpointName, id); } @@ -106,7 +128,7 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport { _logger.TransportWaitingForMessage(EndpointName); - var reader = Console.In; + var reader = _input; var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); if (line == null) { diff --git a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs index 008a2b3..d9a5e19 100644 --- a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs +++ b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs @@ -25,6 +25,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable private int _nextRequestId; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; + private bool _isDisposed; /// /// Initializes a new instance of the class. @@ -398,6 +399,11 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable /// protected async Task CleanupAsync() { + if (_isDisposed) + return; + + _isDisposed = true; + _logger.CleaningUpEndpoint(EndpointName); if (CancellationTokenSource != null) diff --git a/src/mcpdotnet/Utils/ProcessHelper.cs b/src/mcpdotnet/Utils/ProcessHelper.cs new file mode 100644 index 0000000..463157d --- /dev/null +++ b/src/mcpdotnet/Utils/ProcessHelper.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace McpDotNet.Utils; + +/// +/// Helper class for working with processes. +/// +internal static class ProcessHelper +{ + private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); + + public static void KillTree(this Process process) => process.KillTree(_defaultTimeout); + + public static void KillTree(this Process process, TimeSpan timeout) + { + var pid = process.Id; + if (_isWindows) + { + RunProcessAndWaitForExit( + "taskkill", + $"/T /F /PID {pid}", + timeout, + out var _); + } + else + { + var children = new HashSet(); + GetAllChildIdsUnix(pid, children, timeout); + foreach (var childId in children) + { + KillProcessUnix(childId, timeout); + } + KillProcessUnix(pid, timeout); + } + + // wait until the process finishes exiting/getting killed. + // We don't want to wait forever here because the task is already supposed to be dieing, we just want to give it long enough + // to try and flush what it can and stop. If it cannot do that in a reasonable time frame then we will just ignore it. + process.WaitForExit((int)timeout.TotalMilliseconds); + } + + private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout) + { + var exitcode = RunProcessAndWaitForExit( + "pgrep", + $"-P {parentId}", + timeout, + out var stdout); + + if (exitcode == 0 && !string.IsNullOrEmpty(stdout)) + { + using var reader = new StringReader(stdout); + while (true) + { + var text = reader.ReadLine(); + if (text == null) + return; + + if (int.TryParse(text, out var id)) + { + children.Add(id); + // Recursively get the children + GetAllChildIdsUnix(id, children, timeout); + } + } + } + } + + private static void KillProcessUnix(int processId, TimeSpan timeout) + { + RunProcessAndWaitForExit( + "kill", + $"-TERM {processId}", + timeout, + out var _); + } + + private static int RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string? stdout) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + stdout = null; + + var process = Process.Start(startInfo); + if (process == null) + return -1; + + if (process.WaitForExit((int)timeout.TotalMilliseconds)) + { + stdout = process.StandardOutput.ReadToEnd(); + } + else + { + process.Kill(); + } + + return process.ExitCode; + } +} diff --git a/tests/mcpdotnet.TestServer/Program.cs b/tests/mcpdotnet.TestServer/Program.cs index 38b6e94..ef65228 100644 --- a/tests/mcpdotnet.TestServer/Program.cs +++ b/tests/mcpdotnet.TestServer/Program.cs @@ -51,6 +51,7 @@ internal static class Program ConfigureTools(server); ConfigureResources(server); ConfigurePrompts(server); + ConfigureCompletion(server); Log.Logger.Information("Server initialized."); @@ -325,6 +326,43 @@ internal static class Program }; } + private static void ConfigureCompletion(IMcpServer server) + { + List sampleResourceIds = ["1", "2", "3", "4", "5"]; + Dictionary> exampleCompletions = new() + { + {"style", ["casual", "formal", "technical", "friendly"]}, + {"temperature", ["0", "0.5", "0.7", "1.0"]}, + }; + + server.GetCompletionHandler = (request, cancellationToken) => + { + if (request.Params?.Ref?.Type == "ref/resource") + { + var resourceId = request.Params?.Ref?.Uri?.Split('/').LastOrDefault(); + if (string.IsNullOrEmpty(resourceId)) + return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } }); + + // Filter resource IDs that start with the input value + var values = sampleResourceIds.Where(id => id.StartsWith(request.Params.Argument.Value)).ToArray(); + return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } }); + + } + + if (request.Params?.Ref?.Type == "ref/prompt") + { + // Handle completion for prompt arguments + if (!exampleCompletions.TryGetValue(request.Params.Argument.Name, out var completions)) + return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } }); + + var values = completions.Where(value => value.StartsWith(request.Params.Argument.Value)).ToArray(); + return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } }); + } + + throw new McpServerException($"Unknown reference type: {request.Params?.Ref.Type}"); + }; + } + static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) { return new CreateMessageRequestParams() diff --git a/tests/mcpdotnet.Tests/McpClientFactoryTests.cs b/tests/mcpdotnet.Tests/Client/McpClientFactoryTests.cs similarity index 95% rename from tests/mcpdotnet.Tests/McpClientFactoryTests.cs rename to tests/mcpdotnet.Tests/Client/McpClientFactoryTests.cs index 75532e9..3c15020 100644 --- a/tests/mcpdotnet.Tests/McpClientFactoryTests.cs +++ b/tests/mcpdotnet.Tests/Client/McpClientFactoryTests.cs @@ -46,7 +46,7 @@ public class McpClientFactoryTests mockClient.Setup(c => c.IsInitialized).Returns(true); // Inject the mock transport into the factory - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [config], _defaultOptions, NullLoggerFactory.Instance, @@ -87,7 +87,7 @@ public class McpClientFactoryTests .Returns(Task.CompletedTask); mockClient.Setup(c => c.IsInitialized).Returns(true); - var factory = new McpClientFactory([config], + using var factory = new McpClientFactory([config], _defaultOptions, NullLoggerFactory.Instance, transportFactoryMethod: _ => mockTransport.Object, @@ -105,7 +105,7 @@ public class McpClientFactoryTests public async Task GetClientAsync_WithInvalidServerId_ThrowsArgumentException() { // Arrange - var factory = new McpClientFactory(Array.Empty(), + using var factory = new McpClientFactory([], _defaultOptions, NullLoggerFactory.Instance); @@ -127,7 +127,7 @@ public class McpClientFactoryTests Location = "/path/to/server" }; - var factory = new McpClientFactory([config], _defaultOptions, + using var factory = new McpClientFactory([config], _defaultOptions, NullLoggerFactory.Instance); // Act & Assert @@ -161,7 +161,7 @@ public class McpClientFactoryTests .Returns(Task.CompletedTask); mockClient.Setup(c => c.IsInitialized).Returns(true); - var factory = new McpClientFactory([config], + using var factory = new McpClientFactory([config], _defaultOptions, NullLoggerFactory.Instance, transportFactoryMethod: _ => mockTransport.Object, @@ -215,7 +215,7 @@ public class McpClientFactoryTests mockClient.Setup(c => c.IsInitialized).Returns(true); // Inject the mock transport into the factory - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [config], _defaultOptions, NullLoggerFactory.Instance, @@ -250,7 +250,7 @@ public class McpClientFactoryTests }; // Act - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [config], _defaultOptions, NullLoggerFactory.Instance @@ -282,7 +282,7 @@ public class McpClientFactoryTests var defaultOptions = new SseClientTransportOptions(); // Act - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [config], _defaultOptions, NullLoggerFactory.Instance @@ -318,7 +318,7 @@ public class McpClientFactoryTests var defaultOptions = new SseClientTransportOptions(); // Act - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [config], _defaultOptions, NullLoggerFactory.Instance @@ -355,7 +355,7 @@ public class McpClientFactoryTests }; // Act - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [config], _defaultOptions, NullLoggerFactory.Instance diff --git a/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs index 3c35fb7..1ec22ff 100644 --- a/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs @@ -10,7 +10,6 @@ public class ClientIntegrationTestFixture : IDisposable public ILoggerFactory LoggerFactory { get; } public McpClientFactory Factory { get; } public McpClientOptions DefaultOptions { get; } - public McpServerConfig DefaultConfig { get; } public ClientIntegrationTestFixture() { @@ -20,10 +19,11 @@ public class ClientIntegrationTestFixture : IDisposable DefaultOptions = new() { - ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" } + ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, + Capabilities = new() { Sampling = new(), Roots = new() } }; - DefaultConfig = new McpServerConfig + var everythingServerConfig = new McpServerConfig { Id = "everything", Name = "Everything", @@ -32,13 +32,28 @@ public class ClientIntegrationTestFixture : IDisposable { ["command"] = "npx", // Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink - ["arguments"] = "-y @modelcontextprotocol/server-everything", + ["arguments"] = "-y --verbose @modelcontextprotocol/server-everything" } }; + var testServerConfig = new McpServerConfig + { + Id = "test_server", + Name = "TestServer", + TransportType = TransportTypes.StdIo, + TransportOptions = new Dictionary + { + ["command"] = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet", + // Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink + } + }; + + if (!OperatingSystem.IsWindows()) + testServerConfig.TransportOptions["arguments"] = "TestServer.dll"; + // Inject the mock transport into the factory Factory = new McpClientFactory( - [DefaultConfig], + [everythingServerConfig, testServerConfig], DefaultOptions, LoggerFactory ); @@ -46,6 +61,7 @@ public class ClientIntegrationTestFixture : IDisposable public void Dispose() { + Factory?.Dispose(); LoggerFactory?.Dispose(); GC.SuppressFinalize(this); } diff --git a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs index 1316300..a7a584f 100644 --- a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs @@ -15,42 +15,50 @@ public class ClientIntegrationTests : IClassFixture GetClients() + { + yield return ["everything"]; + yield return ["test_server"]; + } + + [Theory] + [MemberData(nameof(GetClients))] + public async Task ConnectAndPing_Stdio(string clientId) { // Arrange // Act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); await client.PingAsync(CancellationToken.None); // Assert Assert.NotNull(client); } - [Fact] - public async Task Connect_EverythingServer_ShouldProvideServerFields() + [Theory] + [MemberData(nameof(GetClients))] + public async Task Connect_ShouldProvideServerFields(string clientId) { // Arrange // Act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); // Assert Assert.NotNull(client.ServerCapabilities); Assert.NotNull(client.ServerInfo); - - // Note: Comment the below assertion back when the everything server is updated to provide instructions - // Assert.NotNull(client.ServerInstructions); + if (clientId != "everything") // Note: Comment the below assertion back when the everything server is updated to provide instructions + Assert.NotNull(client.ServerInstructions); } - [Fact] - public async Task ListTools_Stdio_EverythingServer() + [Theory] + [MemberData(nameof(GetClients))] + public async Task ListTools_Stdio(string clientId) { // arrange // act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); var tools = await client.ListToolsAsync(); // assert @@ -59,13 +67,14 @@ public class ClientIntegrationTests : IClassFixture @@ -82,13 +91,14 @@ public class ClientIntegrationTests : IClassFixture p.Name == "complex_prompt"); } - [Fact] - public async Task GetPrompt_Stdio_SimplePrompt() + [Theory] + [MemberData(nameof(GetClients))] + public async Task GetPrompt_Stdio_SimplePrompt(string clientId) { // arrange // act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); var result = await client.GetPromptAsync("simple_prompt", null, CancellationToken.None); // assert @@ -113,13 +124,14 @@ public class ClientIntegrationTests : IClassFixture { { "temperature", "0.7" }, @@ -132,24 +144,26 @@ public class ClientIntegrationTests : IClassFixture(() => client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None)); } - [Fact] - public async Task ListResources_Stdio_EverythingServer() + [Theory] + [MemberData(nameof(GetClients))] + public async Task ListResources_Stdio(string clientId) { // arrange // act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); List allResources = []; string? cursor = null; @@ -161,17 +175,18 @@ public class ClientIntegrationTests : IClassFixture - /// Note that as of 19th January 2025, the everything server published to npx does not support prompt completion. - /// However, the prompt completion is implemented in the github.com/modelcontextprotocol/servers repository. - /// You can clone this repo, and build locally then change the config to use a local symlink instead of npx until this is fixed, if you wish to run this test. - /// TransportOptions = new Dictionary - /// { - /// ["command"] = "npx", - /// ["arguments"] = "mcp-server-everything" // changed from "-y @modelcontextprotocol/server-everything" - /// } - /// - [Fact] - public async Task GetCompletion_Stdio_ResourceReference() + [Theory] + [MemberData(nameof(GetClients))] + public async Task GetCompletion_Stdio_ResourceReference(string clientId) { // arrange // act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); var result = await client.GetCompletionAsync(new Reference { Type = "ref/resource", @@ -228,23 +235,14 @@ public class ClientIntegrationTests : IClassFixture - /// Note that as of 19th January 2025, the everything server published to npx does not support prompt completion. - /// However, the prompt completion is implemented in the github.com/modelcontextprotocol/servers repository. - /// You can clone this repo, and build locally then change the config to use a local symlink instead of npx until this is fixed, if you wish to run this test. - /// TransportOptions = new Dictionary - /// { - /// ["command"] = "npx", - /// ["arguments"] = "mcp-server-everything" // changed from "-y @modelcontextprotocol/server-everything" - /// } - /// - [Fact] - public async Task GetCompletion_Stdio_PromptReference() + [Theory] + [MemberData(nameof(GetClients))] + public async Task GetCompletion_Stdio_PromptReference(string clientId) { // arrange // act - var client = await _fixture.Factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); var result = await client.GetCompletionAsync(new Reference { Type = "ref/prompt", @@ -259,45 +257,18 @@ public class ClientIntegrationTests : IClassFixture - { - ["command"] = "npx", - ["arguments"] = "-y @modelcontextprotocol/server-everything", - } - }; - - var options = new McpClientOptions - { - ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, - Capabilities = new ClientCapabilities - { - Sampling = new() - } - }; - - var factory = new McpClientFactory( - [config], - options, - _fixture.LoggerFactory - ); - var client = await factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); // Set up the sampling handler int samplingHandlerCalls = 0; -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - client.SamplingHandler = async (_, _) => + client.SamplingHandler = (_, _) => { samplingHandlerCalls++; - return new CreateMessageResult + return Task.FromResult(new CreateMessageResult { Model = "test-model", Role = "assistant", @@ -306,9 +277,8 @@ public class ClientIntegrationTests : IClassFixture + // { + // new() { Uri = "file:///test/root1", Name = "Test Root 1" }, + // new() { Uri = "file:///test/root2", Name = "Test Root 2" } + // }; + + // var client = await _fixture.Factory.GetClientAsync(clientId); + + // // Set up the roots handler + // client.RootsHandler = (request, ct) => + // { + // rootsHandlerCalls++; + // return Task.FromResult(new ListRootsResult + // { + // Roots = testRoots + // }); + // }; + + // // Connect + // await client.ConnectAsync(CancellationToken.None); + + // // assert + // // nothing to assert, no servers implement roots, so we if no exception is thrown, it's a success + // Assert.True(true); + //} + + [Theory] + [MemberData(nameof(GetClients))] + public async Task Notifications_Stdio(string clientId) { - // arrange - var config = new McpServerConfig - { - Id = "everything", - Name = "everything", - TransportType = TransportTypes.StdIo, - TransportOptions = new Dictionary - { - ["command"] = "npx", - ["arguments"] = "-y @modelcontextprotocol/server-everything" - } - }; - - var options = new McpClientOptions - { - ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, - Capabilities = new ClientCapabilities - { - Roots = new() - } - }; - - var rootsHandlerCalls = 0; - var testRoots = new List - { - new() { Uri = "file:///test/root1", Name = "Test Root 1" }, - new() { Uri = "file:///test/root2", Name = "Test Root 2" } - }; - - var factory = new McpClientFactory( - [config], - options, - _fixture.LoggerFactory - ); - var client = await factory.GetClientAsync("everything"); - - // Set up the roots handler -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - client.RootsHandler = async (request, ct) => - { - rootsHandlerCalls++; - return new ListRootsResult - { - Roots = testRoots - }; - }; -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - - // Connect - await client.ConnectAsync(CancellationToken.None); - - // assert - // nothing to assert, no servers implement roots, so we if no exception is thrown, it's a success - Assert.True(true); - } - - [Fact] - public async Task Notifications_Stdio_EverythingServer() - { - // arrange - var config = new McpServerConfig - { - Id = "everything", - Name = "everything", - TransportType = TransportTypes.StdIo, - TransportOptions = new Dictionary - { - ["command"] = "npx", - ["arguments"] = "-y @modelcontextprotocol/server-everything" - } - }; - - var options = new McpClientOptions - { - ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" } - }; - - var factory = new McpClientFactory([config], options, _fixture.LoggerFactory); - var client = await factory.GetClientAsync("everything"); + var client = await _fixture.Factory.GetClientAsync(clientId); await client.ConnectAsync(); @@ -442,7 +366,7 @@ public class ClientIntegrationTests : IClassFixture c.Type == "text"); + Assert.Single(result.Content, c => c.Type == "text"); + + await client.DisposeAsync(); } } diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs similarity index 99% rename from tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs rename to tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs index 262e32d..bce2560 100644 --- a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs +++ b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Moq; -namespace McpDotNet.Tests; +namespace McpDotNet.Tests.Configuration; public class McpServerBuilderExtensionsHandlerTests { diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs similarity index 90% rename from tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs rename to tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index f5388c4..6d8b4f9 100644 --- a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Moq; -namespace McpDotNet.Tests; +namespace McpDotNet.Tests.Configuration; public class McpServerBuilderExtensionsToolsTests { @@ -23,7 +23,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public void Adds_Tools_To_Server() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -35,7 +35,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_List_Registered_Tool() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -66,7 +66,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -83,7 +83,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_With_Array_Result() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -100,7 +100,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_With_Null_Result() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -117,7 +117,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_With_Json_Result() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -134,7 +134,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_With_Int_Result() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -151,7 +151,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_And_Pass_Cancellation_Token() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -170,7 +170,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_And_Returns_Cancelled_Response() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -187,7 +187,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Can_Call_Registered_Tool_And_Pass_ComplexType() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -204,7 +204,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Throws_Exception_When_Tool_Fails() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -219,13 +219,13 @@ public class McpServerBuilderExtensionsToolsTests public async Task Can_Call_Registered_Tool_With_Dependency_Injection() { bool dependencyInjectionCalled = false; - _services.AddTransient(sp => + _services.AddTransient(sp => { dependencyInjectionCalled = true; return Mock.Of(); }); - McpServerBuilderExtensions.WithTool(_builder.Object); + _builder.Object.WithTool(); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -246,7 +246,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Throws_Exception_On_Unknown_Tool() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -258,7 +258,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Throws_Exception_Missing_Parameter() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -270,7 +270,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public void Throws_Exception_For_Null_Types() { - var action = () => McpServerBuilderExtensions.WithTools(_builder.Object, toolTypes: null!); + var action = () => _builder.Object.WithTools(toolTypes: null!); var exception = Assert.Throws(action); Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message); @@ -279,7 +279,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public void Throws_Exception_For_Empty_Types() { - var action = () => McpServerBuilderExtensions.WithTools(_builder.Object, toolTypes: []); + var action = () => _builder.Object.WithTools(toolTypes: []); var exception = Assert.Throws(action); Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message); @@ -288,7 +288,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Register_Tools_From_Current_Assembly() { - McpServerBuilderExtensions.WithTools(_builder.Object); + _builder.Object.WithTools(); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -304,7 +304,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public void Throws_Exception_When_No_Tools_Are_Found_In_Given_Assembly() { - var action = () => McpServerBuilderExtensions.WithToolsFromAssembly(_builder.Object, typeof(Mock).Assembly); + var action = () => _builder.Object.WithToolsFromAssembly(typeof(Mock).Assembly); var exception = Assert.Throws(action); Assert.Equal("No types with marked methods found in the assembly. (Parameter 'assembly')", exception.Message); @@ -313,7 +313,7 @@ public class McpServerBuilderExtensionsToolsTests [Fact] public async Task Recognizes_Parameter_Types() { - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs similarity index 94% rename from tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs rename to tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs index 2ea4cca..1218be5 100644 --- a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs +++ b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsTransportsTests.cs @@ -3,7 +3,7 @@ using McpDotNet.Protocol.Transport; using Microsoft.Extensions.DependencyInjection; using Moq; -namespace McpDotNet.Tests; +namespace McpDotNet.Tests.Configuration; public class McpServerBuilderExtensionsTransportsTests { diff --git a/tests/mcpdotnet.Tests/Server/McpServerTests.cs b/tests/mcpdotnet.Tests/Server/McpServerTests.cs index d331803..0bf228b 100644 --- a/tests/mcpdotnet.Tests/Server/McpServerTests.cs +++ b/tests/mcpdotnet.Tests/Server/McpServerTests.cs @@ -39,10 +39,10 @@ public class McpServerTests } [Fact] - public void Constructor_Should_Initialize_With_Valid_Parameters() + public async Task Constructor_Should_Initialize_With_Valid_Parameters() { // Arrange & Act - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); // Assert Assert.NotNull(server); @@ -70,19 +70,19 @@ public class McpServerTests } [Fact] - public void Constructor_Does_Not_Throw_For_Null_ServiceProvider() + public async Task Constructor_Does_Not_Throw_For_Null_ServiceProvider() { // Arrange & Act - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, null); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, null); // Assert Assert.NotNull(server); } [Fact] - public void Property_EndpointName_Return_Infos() + public async Task Property_EndpointName_Return_Infos() { - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); server.ClientInfo = new Implementation { Name = "TestClient", Version = "1.1" }; Assert.Equal("Server (TestServer 1.0), Client (TestClient 1.1)", server.EndpointName); } @@ -91,7 +91,7 @@ public class McpServerTests public async Task StartAsync_Should_Throw_InvalidOperationException_If_Already_Initializing() { // Arrange - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); server.GetType().GetField("_isInitializing", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.SetValue(server, true); // Act & Assert @@ -103,7 +103,7 @@ public class McpServerTests public async Task StartAsync_Should_Do_Nothing_If_Already_Initialized() { // Arrange - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); server.IsInitialized = true; await server.StartAsync(); @@ -116,7 +116,7 @@ public class McpServerTests public async Task StartAsync_ShouldStartListening() { // Arrange - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); // Act await server.StartAsync(); @@ -128,8 +128,8 @@ public class McpServerTests [Fact] public async Task StartAsync_Sets_Initialized_After_Transport_Responses_Initialized_Notification() { - var transport = new TestServerTransport(); - var server = new McpServer(transport, _options, _loggerFactory.Object, _serviceProvider); + await using var transport = new TestServerTransport(); + await using var server = new McpServer(transport, _options, _loggerFactory.Object, _serviceProvider); await server.StartAsync(); @@ -150,7 +150,7 @@ public class McpServerTests public async Task RequestSamplingAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Sampling() { // Arrange - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); server.ClientCapabilities = new ClientCapabilities(); var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); @@ -164,7 +164,7 @@ public class McpServerTests public async Task RequestSamplingAsync_Should_SendRequest() { // Arrange - var transport = new TestServerTransport(); + await using var transport = new TestServerTransport(); await using var server = new McpServer(transport, _options, _loggerFactory.Object, _serviceProvider); server.ClientCapabilities = new ClientCapabilities { Sampling = new SamplingCapability() }; @@ -183,7 +183,7 @@ public class McpServerTests public async Task RequestRootsAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Roots() { // Arrange - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); server.ClientCapabilities = new ClientCapabilities(); // Act & Assert @@ -195,7 +195,7 @@ public class McpServerTests public async Task RequestRootsAsync_Should_SendRequest() { // Arrange - var transport = new TestServerTransport(); + await using var transport = new TestServerTransport(); await using var server = new McpServer(transport, _options, _loggerFactory.Object, _serviceProvider); server.ClientCapabilities = new ClientCapabilities { Roots = new RootsCapability() }; await server.StartAsync(); @@ -213,7 +213,7 @@ public class McpServerTests [Fact] public async Task Throws_Exception_If_Not_Connected() { - var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); server.ClientCapabilities = new ClientCapabilities { Roots = new RootsCapability() }; _serverTransport.SetupGet(t => t.IsConnected).Returns(false); @@ -309,10 +309,7 @@ public class McpServerTests { return Task.FromResult(new ListResourcesResult { - Resources = new() - { - new() { Uri = "test", Name = "Test Resource" } - } + Resources = [new() { Uri = "test", Name = "Test Resource" }] }); }; @@ -344,7 +341,7 @@ public class McpServerTests { return Task.FromResult(new ReadResourceResult { - Contents = new List { new ResourceContents { Text = "test" } } + Contents = [new() { Text = "test" }] }); }; }, @@ -375,7 +372,7 @@ public class McpServerTests { return Task.FromResult(new ListPromptsResult { - Prompts = new List { new Prompt { Name = "test" } } + Prompts = [new() { Name = "test" }] }); }; }, @@ -435,7 +432,7 @@ public class McpServerTests { return Task.FromResult(new ListToolsResult { - Tools = new List { new Tool { Name = "test" } } + Tools = [new() { Name = "test" }] }); }; }, @@ -487,10 +484,10 @@ public class McpServerTests private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action configureServer, Action assertResult) { - var transport = new TestServerTransport(); + await using var transport = new TestServerTransport(); var options = serverCapabilities == null ? _options : CreateOptions(serverCapabilities); - var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); await server.StartAsync(); @@ -521,10 +518,10 @@ public class McpServerTests private async Task Throws_Exception_If_No_Handler_Assigned(ServerCapabilities serverCapabilities, string method, string expectedError) { - var transport = new TestServerTransport(); + await using var transport = new TestServerTransport(); var options = CreateOptions(serverCapabilities); - var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); + await using var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); await server.StartAsync(); diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs deleted file mode 100644 index 2030373..0000000 --- a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs +++ /dev/null @@ -1,57 +0,0 @@ -using McpDotNet.Client; -using McpDotNet.Configuration; -using McpDotNet.Protocol.Transport; -using Microsoft.Extensions.Logging; - -namespace McpDotNet.Tests; - -public class ServerIntegrationTestFixture : IDisposable -{ - public ILoggerFactory LoggerFactory { get; } - public McpClientFactory Factory { get; } - public McpClientOptions DefaultOptions { get; } - public McpServerConfig DefaultConfig { get; } - - public ServerIntegrationTestFixture() - { - 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.StdIo, - TransportOptions = new Dictionary - { - ["command"] = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet", - // Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink - } - }; - - if (!OperatingSystem.IsWindows()) - DefaultConfig.TransportOptions["arguments"] = "TestServer.dll"; - - // Inject the mock transport into the factory - Factory = new McpClientFactory( - [DefaultConfig], - DefaultOptions, - LoggerFactory - ); - } - - public void Dispose() - { - var client = Factory.GetClientAsync("test_server").Result; - client.DisposeAsync().AsTask().Wait(); - LoggerFactory?.Dispose(); - GC.SuppressFinalize(this); - } -} \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTests.cs b/tests/mcpdotnet.Tests/ServerIntegrationTests.cs deleted file mode 100644 index 496a790..0000000 --- a/tests/mcpdotnet.Tests/ServerIntegrationTests.cs +++ /dev/null @@ -1,237 +0,0 @@ -using McpDotNet.Client; -using McpDotNet.Protocol.Types; - -namespace McpDotNet.Tests; - -public class ServerIntegrationTests : IClassFixture -{ - private readonly ServerIntegrationTestFixture _fixture; - - public ServerIntegrationTests(ServerIntegrationTestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task ConnectAndPing_Stdio_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); - Assert.NotNull(client.ServerInstructions); - } - - [Fact] - public async Task ListTools_Stdio_TestServer() - { - // arrange - - // act - var client = await _fixture.Factory.GetClientAsync("test_server"); - var tools = await client.ListToolsAsync(); - - // assert - Assert.NotNull(tools); - Assert.NotEmpty(tools.Tools); - } - - [Fact] - public async Task CallTool_Stdio_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_Stdio_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_Stdio_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_Stdio_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_Stdio_TestServer() - { - // arrange - - // act - var client = await _fixture.Factory.GetClientAsync("test_server"); - var prompts = await client.ListPromptsAsync(); - - // assert - Assert.NotNull(prompts); - Assert.NotEmpty(prompts.Prompts); - // We could add specific assertions for the known prompts - Assert.Contains(prompts.Prompts, p => p.Name == "simple_prompt"); - Assert.Contains(prompts.Prompts, p => p.Name == "complex_prompt"); - } - - [Fact] - public async Task GetPrompt_Stdio_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_Stdio_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_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_Stdio_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.SamplingHandler = 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/SseClientTransportTests.cs b/tests/mcpdotnet.Tests/SseClientTransportTests.cs deleted file mode 100644 index 92e33dc..0000000 --- a/tests/mcpdotnet.Tests/SseClientTransportTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Net; -using McpDotNet.Configuration; -using McpDotNet.Protocol.Messages; -using McpDotNet.Protocol.Transport; -using McpDotNet.Tests.Utils; -using Microsoft.Extensions.Logging.Abstractions; - -namespace McpDotNet.Tests.Client; - -public class SseClientTransportTests -{ - private readonly McpServerConfig _serverConfig; - private readonly SseClientTransportOptions _transportOptions; - - public SseClientTransportTests() - { - _serverConfig = new McpServerConfig - { - Id = "test-server", - Name = "Test Server", - TransportType = TransportTypes.Sse, - Location = "http://localhost:8080" - }; - - _transportOptions = new SseClientTransportOptions - { - ConnectionTimeout = TimeSpan.FromSeconds(2), - MaxReconnectAttempts = 3, - ReconnectDelay = TimeSpan.FromMilliseconds(50), - AdditionalHeaders = new Dictionary - { - ["test"] = "header" - } - }; - } - - [Fact] - public void Constructor_Should_Initialize_With_Valid_Parameters() - { - // Act - var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); - - // Assert - Assert.NotNull(transport); - Assert.Equal(TimeSpan.FromSeconds(2), transport.Options.ConnectionTimeout); - Assert.Equal(3, transport.Options.MaxReconnectAttempts); - Assert.Equal(TimeSpan.FromMilliseconds(50), transport.Options.ReconnectDelay); - Assert.NotNull(transport.Options.AdditionalHeaders); - Assert.Equal("header", transport.Options.AdditionalHeaders["test"]); - } - - [Fact] - public void Constructor_Throws_For_Null_Options() - { - var exception = Assert.Throws(() => new SseClientTransport(null!, _serverConfig, NullLoggerFactory.Instance)); - Assert.Equal("transportOptions", exception.ParamName); - } - - [Fact] - public void Constructor_Throws_For_Null_Config() - { - var exception = Assert.Throws(() => new SseClientTransport(_transportOptions, null!, NullLoggerFactory.Instance)); - Assert.Equal("serverConfig", exception.ParamName); - } - - [Fact] - public void Constructor_Throws_For_Null_HttpClientg() - { - var exception = Assert.Throws(() => new SseClientTransport(_transportOptions, _serverConfig, null!, NullLoggerFactory.Instance)); - Assert.Equal("httpClient", exception.ParamName); - } - - [Fact] - public async Task ConnectAsync_Should_Connect_Successfully() - { - var mockHttpHandler = new MockHttpHandler(); - var httpClient = new HttpClient(mockHttpHandler); - await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); - - bool firstCall = true; - - mockHttpHandler.RequestHandler = async (request) => - { - if (!firstCall) - { - Assert.True(transport.IsConnected); - await transport.CloseAsync(); - } - - firstCall = false; - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n") - }; - }; - - await transport.ConnectAsync(); - } - - [Fact] - public async Task ConnectAsync_Throws_Exception_On_Failure() - { - var mockHttpHandler = new MockHttpHandler(); - var httpClient = new HttpClient(mockHttpHandler); - await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); - - var retries = 0; - mockHttpHandler.RequestHandler = (request) => - { - retries++; - throw new InvalidOperationException("Test exception"); - }; - - var action = async () => await transport.ConnectAsync(); - - var exception = await Assert.ThrowsAsync(action); - Assert.Equal("Exceeded reconnect limit", exception.Message); - - Assert.Equal(_transportOptions.MaxReconnectAttempts, retries); - } - - [Fact] - public async Task SendMessageAsync_Throws_Exception_If_MessageEndpoint_Not_Set() - { - await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); - - // Assert - Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString())); - await Assert.ThrowsAsync(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test" }, CancellationToken.None)); - } - - //[Fact] - //public async Task SendMessageAsync_Sends_Message() - //{ - // var httpClient = new HttpClient(_mockHttpHandler); - // await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); - - // _mockHttpHandler.RequestHandler = async (request) => - // { - - // return new HttpResponseMessage - // { - // StatusCode = HttpStatusCode.OK, - // Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n") - // }; - // }; - - // await transport.ConnectAsync(); - - // await transport.SendMessageAsync(new JsonRpcRequest() { Method = "test" }, CancellationToken.None); - //} - - [Fact] - public async Task CloseAsync_Should_Dispose_Resources() - { - var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); - - await transport.CloseAsync(); - - Assert.False(transport.IsConnected); - } - - [Fact] - public async Task DisposeAsync_Should_Dispose_Resources() - { - var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); - - await transport.DisposeAsync(); - - Assert.False(transport.IsConnected); - } -} \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/SseIntegrationTests.cs b/tests/mcpdotnet.Tests/SseIntegrationTests.cs index 8655bc8..5f961fef 100644 --- a/tests/mcpdotnet.Tests/SseIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/SseIntegrationTests.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.Logging; namespace McpDotNet.Tests; -[Trait("Execution", "Manual")] public class SseIntegrationTests { [Fact] @@ -37,7 +36,7 @@ public class SseIntegrationTests Location = "http://localhost:5000/sse" }; - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [defaultConfig], defaultOptions, loggerFactory @@ -80,7 +79,7 @@ public class SseIntegrationTests Location = "http://localhost:3001/sse" }; - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [defaultConfig], defaultOptions, loggerFactory @@ -128,7 +127,7 @@ public class SseIntegrationTests Location = "http://localhost:3001/sse" }; - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [defaultConfig], defaultOptions, loggerFactory @@ -137,11 +136,10 @@ public class SseIntegrationTests // Set up the sampling handler int samplingHandlerCalls = 0; -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - client.SamplingHandler = async (_, _) => + client.SamplingHandler = (_, _) => { samplingHandlerCalls++; - return new CreateMessageResult + return Task.FromResult(new CreateMessageResult { Model = "test-model", Role = "assistant", @@ -150,9 +148,8 @@ public class SseIntegrationTests 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( @@ -198,7 +195,7 @@ public class SseIntegrationTests Location = "http://localhost:5000/sse" }; - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [defaultConfig], defaultOptions, loggerFactory @@ -243,7 +240,7 @@ public class SseIntegrationTests Location = "http://localhost:5000/sse" }; - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [defaultConfig], defaultOptions, loggerFactory @@ -298,7 +295,7 @@ public class SseIntegrationTests Location = "http://localhost:5000/sse" }; - var factory = new McpClientFactory( + using var factory = new McpClientFactory( [defaultConfig], defaultOptions, loggerFactory diff --git a/tests/mcpdotnet.Tests/Transport/SseClientTransportTests.cs b/tests/mcpdotnet.Tests/Transport/SseClientTransportTests.cs new file mode 100644 index 0000000..cf52403 --- /dev/null +++ b/tests/mcpdotnet.Tests/Transport/SseClientTransportTests.cs @@ -0,0 +1,280 @@ +using System.Net; +using McpDotNet.Configuration; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Transport; +using McpDotNet.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace McpDotNet.Tests.Transport; + +public class SseClientTransportTests +{ + private readonly McpServerConfig _serverConfig; + private readonly SseClientTransportOptions _transportOptions; + + public SseClientTransportTests() + { + _serverConfig = new McpServerConfig + { + Id = "test-server", + Name = "Test Server", + TransportType = TransportTypes.Sse, + Location = "http://localhost:8080" + }; + + _transportOptions = new SseClientTransportOptions + { + ConnectionTimeout = TimeSpan.FromSeconds(2), + MaxReconnectAttempts = 3, + ReconnectDelay = TimeSpan.FromMilliseconds(50), + AdditionalHeaders = new Dictionary + { + ["test"] = "header" + } + }; + } + + [Fact] + public async Task Constructor_Should_Initialize_With_Valid_Parameters() + { + // Act + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); + + // Assert + Assert.NotNull(transport); + Assert.Equal(TimeSpan.FromSeconds(2), transport.Options.ConnectionTimeout); + Assert.Equal(3, transport.Options.MaxReconnectAttempts); + Assert.Equal(TimeSpan.FromMilliseconds(50), transport.Options.ReconnectDelay); + Assert.NotNull(transport.Options.AdditionalHeaders); + Assert.Equal("header", transport.Options.AdditionalHeaders["test"]); + } + + [Fact] + public void Constructor_Throws_For_Null_Options() + { + var exception = Assert.Throws(() => new SseClientTransport(null!, _serverConfig, NullLoggerFactory.Instance)); + Assert.Equal("transportOptions", exception.ParamName); + } + + [Fact] + public void Constructor_Throws_For_Null_Config() + { + var exception = Assert.Throws(() => new SseClientTransport(_transportOptions, null!, NullLoggerFactory.Instance)); + Assert.Equal("serverConfig", exception.ParamName); + } + + [Fact] + public void Constructor_Throws_For_Null_HttpClientg() + { + var exception = Assert.Throws(() => new SseClientTransport(_transportOptions, _serverConfig, null!, NullLoggerFactory.Instance)); + Assert.Equal("httpClient", exception.ParamName); + } + + [Fact] + public async Task ConnectAsync_Should_Connect_Successfully() + { + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); + + bool firstCall = true; + + mockHttpHandler.RequestHandler = async (request) => + { + if (!firstCall) + { + Assert.True(transport.IsConnected); + await transport.CloseAsync(); + } + + firstCall = false; + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n") + }; + }; + + await transport.ConnectAsync(); + } + + [Fact] + public async Task ConnectAsync_Throws_If_Already_Connected() + { + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); + transport.GetType().BaseType!.GetField("_isConnected", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.SetValue(transport, true); + + var action = async () => await transport.ConnectAsync(); + var exception = await Assert.ThrowsAsync(action); + Assert.Equal("Transport is already connected", exception.Message); + } + + [Fact] + public async Task ConnectAsync_Throws_Exception_On_Failure() + { + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); + + var retries = 0; + mockHttpHandler.RequestHandler = (request) => + { + retries++; + throw new InvalidOperationException("Test exception"); + }; + + var action = async () => await transport.ConnectAsync(); + + var exception = await Assert.ThrowsAsync(action); + Assert.Equal("Exceeded reconnect limit", exception.Message); + + Assert.Equal(_transportOptions.MaxReconnectAttempts, retries); + } + + [Fact] + public async Task SendMessageAsync_Throws_Exception_If_MessageEndpoint_Not_Set() + { + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); + + // Assert + Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString())); + await Assert.ThrowsAsync(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test" }, CancellationToken.None)); + } + + [Fact] + public async Task SendMessageAsync_Handles_Accepted_Response() + { + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); + + var firstCall = true; + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint") + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("accepted") + }); + } + else + { + if (!firstCall) + throw new IOException("Abort"); + else + firstCall = false; + + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n") + }); + } + }; + + await transport.ConnectAsync(); + + await transport.SendMessageAsync(new JsonRpcRequest() { Method = "initialize", Id = RequestId.FromNumber(44) }, CancellationToken.None); + Assert.True(true); + } + + [Fact] + public async Task SendMessageAsync_Handles_Accepted_Json_RPC_Response() + { + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); + + var firstCall = true; + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint") + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"jsonrpc\":\"2.0\", \"id\": \"44\", \"result\": null}") + }); + } + else + { + if (!firstCall) + throw new IOException("Abort"); + else + firstCall = false; + + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n") + }); + } + }; + + await transport.ConnectAsync(); + + await transport.SendMessageAsync(new JsonRpcRequest() { Method = "initialize", Id = RequestId.FromNumber(44) }, CancellationToken.None); + Assert.True(true); + } + + [Fact] + public async Task ReceiveMessagesAsync_Handles_Messages() + { + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance); + + var callIndex = 0; + mockHttpHandler.RequestHandler = (request) => + { + callIndex++; + + if (callIndex == 1) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n") + }); + } + else if (callIndex == 2) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: message\r\ndata: {\"jsonrpc\":\"2.0\", \"id\": \"44\", \"method\": \"test\", \"params\": null}\r\n\r\n") + }); + } + + throw new IOException("Abort"); + }; + + await transport.ConnectAsync(); + Assert.True(transport.MessageReader.TryRead(out var message)); + Assert.NotNull(message); + Assert.IsType(message); + Assert.Equal("44", ((JsonRpcRequest)message).Id.AsString); + } + + [Fact] + public async Task CloseAsync_Should_Dispose_Resources() + { + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); + + await transport.CloseAsync(); + + Assert.False(transport.IsConnected); + } + + [Fact] + public async Task DisposeAsync_Should_Dispose_Resources() + { + await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance); + + await transport.DisposeAsync(); + + Assert.False(transport.IsConnected); + } +} \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/Transport/StdioServerTransportTests.cs b/tests/mcpdotnet.Tests/Transport/StdioServerTransportTests.cs new file mode 100644 index 0000000..25a05ea --- /dev/null +++ b/tests/mcpdotnet.Tests/Transport/StdioServerTransportTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Transport; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using McpDotNet.Utils.Json; +using Microsoft.Extensions.Logging.Abstractions; + +namespace McpDotNet.Tests.Transport; + +public class StdioServerTransportTests +{ + private readonly McpServerOptions _serverOptions; + + public StdioServerTransportTests() + { + _serverOptions = new McpServerOptions + { + ServerInfo = new Implementation + { + Name = "Test Server", + Version = "1.0" + }, + ProtocolVersion = "2.0", + InitializationTimeout = TimeSpan.FromSeconds(10), + ServerInstructions = "Test Instructions" + }; + } + + [Fact] + public async Task Constructor_Should_Initialize_With_Valid_Parameters() + { + // Act + await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default); + + // Assert + Assert.NotNull(transport); + } + + [Fact] + public void Constructor_Throws_For_Null_Options() + { + var exception = Assert.Throws(() => new StdioServerTransport(serverOptions: null!, NullLoggerFactory.Instance, InputOutputStreams.Default)); + Assert.Equal("serverOptions", exception.ParamName); + } + + [Fact] + public void Constructor_Throws_For_Null_Streams() + { + var exception = Assert.Throws(() => new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, null!)); + Assert.Equal("inputOutputStreams", exception.ParamName); + } + + [Fact] + public async Task StartListeningAsync_Should_Set_Connected_State() + { + await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default); + + await transport.StartListeningAsync(); + + Assert.True(transport.IsConnected); + } + + [Fact] + public async Task SendMessageAsync_Should_Send_Message() + { + var streams = new InputOutputStreams { Output = new StringWriter(), Input = new StringReader("") }; + await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, streams); + await transport.StartListeningAsync(); + + var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) }; + + + await transport.SendMessageAsync(message); + + var result = streams.Output.ToString()?.Trim(); + var expected = JsonSerializer.Serialize(message, JsonSerializerOptionsExtensions.DefaultOptions); + + Assert.Equal(expected, result); + } + + [Fact] + public async Task SendMessageAsync_Throws_Exception_If_Not_Connected() + { + await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default); + + var message = new JsonRpcRequest { Method = "test" }; + + await Assert.ThrowsAsync(() => transport.SendMessageAsync(message)); + } + + [Fact] + public async Task DisposeAsync_Should_Dispose_Resources() + { + await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default); + + await transport.DisposeAsync(); + + Assert.False(transport.IsConnected); + } + + [Fact] + public async Task ReadMessagesAsync_Should_Read_Messages() + { + var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) }; + var json = JsonSerializer.Serialize(message, JsonSerializerOptionsExtensions.DefaultOptions); + + using var sr = new StringReader(json); + + var streams = new InputOutputStreams { Output = new StringWriter(), Input = new StringReader(json) }; + await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, streams); + await transport.StartListeningAsync(); + + var canRead = await transport.MessageReader.WaitToReadAsync(); + + Assert.True(canRead, "Nothing to read here from transport message reader"); + Assert.True(transport.MessageReader.TryPeek(out var readMessage)); + Assert.NotNull(readMessage); + Assert.IsType(readMessage); + Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber); + } + + [Fact] + public async Task CleanupAsync_Should_Cleanup_Resources() + { + var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default); + await transport.StartListeningAsync(); + + await transport.DisposeAsync(); + + Assert.False(transport.IsConnected); + } +} diff --git a/tests/mcpdotnet.Tests/Utils/TestSseServer.cs b/tests/mcpdotnet.Tests/Utils/TestSseServer.cs index df421af..38b34c3 100644 --- a/tests/mcpdotnet.Tests/Utils/TestSseServer.cs +++ b/tests/mcpdotnet.Tests/Utils/TestSseServer.cs @@ -348,7 +348,7 @@ public sealed class TestSseServer : IAsyncDisposable { try { - await _serverTask; + await Task.WhenAny(_serverTask, Task.Delay(2000)); } catch (TaskCanceledException) {