diff --git a/.gitignore b/.gitignore index 879b5d7..5e03da8 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,14 @@ Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* /mcpdotnet.v3.ncrunchsolution +/.NCrunch_mcpdotnet +*.ncrunchproject diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs index 61a80bc..5bed7b5 100644 --- a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs @@ -165,7 +165,7 @@ public static partial class McpServerBuilderExtensions Type t when t == typeof(int) || t == typeof(double) || t == typeof(float) => "number", Type t when t == typeof(bool) => "boolean", Type t when t.IsArray => "array", - Type t when t == typeof(DateTime) => "string", + Type t when t == typeof(DateTime) || t == typeof(DateTimeOffset) => "string", _ => "object" }; } diff --git a/src/mcpdotnet/Logging/Log.cs b/src/mcpdotnet/Logging/Log.cs index fe5f868..c00c959 100644 --- a/src/mcpdotnet/Logging/Log.cs +++ b/src/mcpdotnet/Logging/Log.cs @@ -122,6 +122,9 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Warning, Message = "No request found for message with ID {messageWithId} for {endpointName}")] internal static partial void NoRequestFoundForMessageWithId(this ILogger logger, string endpointName, string messageWithId); + [LoggerMessage(Level = LogLevel.Warning, Message = "The request has not valid message ID for {endpointName}")] + internal static partial void RequestHasInvalidId(this ILogger logger, string endpointName); + [LoggerMessage(Level = LogLevel.Error, Message = "Notification handler error for {endpointName} with method {method}")] internal static partial void NotificationHandlerError(this ILogger logger, string endpointName, string method, Exception exception); diff --git a/src/mcpdotnet/Protocol/Messages/RequestId.cs b/src/mcpdotnet/Protocol/Messages/RequestId.cs index f627185..3f9617e 100644 --- a/src/mcpdotnet/Protocol/Messages/RequestId.cs +++ b/src/mcpdotnet/Protocol/Messages/RequestId.cs @@ -39,6 +39,11 @@ public readonly struct RequestId : IEquatable /// public bool IsNumber => _value is long; + /// + /// Checks if the request id is valid (has a value) + /// + public bool IsValid => _value != null; + /// /// Gets the RequestId as a string. /// diff --git a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs index cb70777..549ba66 100644 --- a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs @@ -26,6 +26,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport private readonly McpServerConfig _serverConfig; private readonly JsonSerializerOptions _jsonOptions; private readonly TaskCompletionSource _connectionEstablished; + private readonly bool _ownsHttpClient; private string EndpointName => $"Client (SSE) for ({_serverConfig.Id}: {_serverConfig.Name})"; @@ -37,6 +38,20 @@ public sealed class SseClientTransport : TransportBase, IClientTransport /// The configuration object indicating which server to connect to. /// Logger factory for creating loggers. public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory? loggerFactory) + : this(transportOptions, serverConfig, new HttpClient(), loggerFactory, true) + { + } + + /// + /// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server. + /// The HTTP server can be local or remote, and must support the SSE protocol. + /// + /// Configuration options for the transport. + /// The configuration object indicating which server to connect to. + /// The HTTP client instance used for requests. + /// Logger factory for creating loggers. + /// True to dispose HTTP client on close connection. + public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory, bool ownsHttpClient = false) : base(loggerFactory) { if (transportOptions is null) @@ -49,14 +64,20 @@ public sealed class SseClientTransport : TransportBase, IClientTransport throw new ArgumentNullException(nameof(serverConfig)); } + if (httpClient is null) + { + throw new ArgumentNullException(nameof(httpClient)); + } + _options = transportOptions; _serverConfig = serverConfig; _sseEndpoint = new Uri(serverConfig.Location!); - _httpClient = new HttpClient(); + _httpClient = httpClient; _connectionCts = new CancellationTokenSource(); _logger = loggerFactory is not null ? loggerFactory.CreateLogger() : NullLogger.Instance; _jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions; _connectionEstablished = new TaskCompletionSource(); + _ownsHttpClient = ownsHttpClient; } /// @@ -161,15 +182,26 @@ public sealed class SseClientTransport : TransportBase, IClientTransport if (_receiveTask != null) await _receiveTask.ConfigureAwait(false); - _httpClient.Dispose(); - _connectionCts.Dispose(); SetConnected(false); } /// public override async ValueTask DisposeAsync() { - await CloseAsync().ConfigureAwait(false); + try + { + await CloseAsync().ConfigureAwait(false); + } + catch (Exception) + { + // Ignore exceptions on close + } + + if (_ownsHttpClient) + _httpClient?.Dispose(); + + _connectionCts?.Dispose(); + GC.SuppressFinalize(this); } diff --git a/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs index 5c42139..3b0e707 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs @@ -229,7 +229,6 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport catch (JsonException ex) { _logger.TransportMessageParseFailed(EndpointName, line, ex); - // Continue reading even if we fail to parse a message } } diff --git a/src/mcpdotnet/Server/McpServer.cs b/src/mcpdotnet/Server/McpServer.cs index 2b1aa68..4516617 100644 --- a/src/mcpdotnet/Server/McpServer.cs +++ b/src/mcpdotnet/Server/McpServer.cs @@ -30,7 +30,7 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer : base(transport, loggerFactory) { _serverTransport = transport; - _options = options; + _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = loggerFactory.CreateLogger(); ServerInstructions = options.ServerInstructions; ServiceProvider = serviceProvider; diff --git a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs index 7c4cff1..008a2b3 100644 --- a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs +++ b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs @@ -33,11 +33,14 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable /// The logger factory. protected McpJsonRpcEndpoint(ITransport transport, ILoggerFactory loggerFactory) { - _transport = transport; + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _pendingRequests = new(); _notificationHandlers = new(); _nextRequestId = 1; _jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions; + + if (loggerFactory is null) + throw new ArgumentNullException(nameof(loggerFactory)); _logger = loggerFactory.CreateLogger(); } @@ -137,7 +140,9 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable private void HandleMessageWithId(IJsonRpcMessage message, IJsonRpcMessageWithId messageWithId) { - if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) + if (!messageWithId.Id.IsValid) + _logger.RequestHasInvalidId(EndpointName); + else if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) { _logger.ResponseMatchedPendingRequest(EndpointName, messageWithId.Id.ToString()); tcs.TrySetResult(message); diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs index 57b99a9..f5388c4 100644 --- a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs @@ -1,9 +1,10 @@ -using McpDotNet.Configuration; +using System.ComponentModel; +using System.Text.Json; +using McpDotNet.Configuration; using McpDotNet.Server; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Moq; -using System.ComponentModel; namespace McpDotNet.Tests; @@ -76,6 +77,142 @@ public class McpServerBuilderExtensionsToolsTests Assert.NotEmpty(result.Content); Assert.Equal("hello Peter", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + } + + [Fact] + public async Task Can_Call_Registered_Tool_With_Array_Result() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "EchoArray", Arguments = new() { { "message", "Peter" } } }), CancellationToken.None); + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + Assert.Equal("hello Peter", result.Content[0].Text); + Assert.Equal("hello2 Peter", result.Content[1].Text); + } + + [Fact] + public async Task Can_Call_Registered_Tool_With_Null_Result() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnNull" }), CancellationToken.None); + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + Assert.Equal("null", result.Content[0].Text); + Assert.Empty(result.Content[0].Type); + } + + [Fact] + public async Task Can_Call_Registered_Tool_With_Json_Result() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnJson" }), CancellationToken.None); + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + Assert.Equal("{\"SomeProp\": false}", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + } + + [Fact] + public async Task Can_Call_Registered_Tool_With_Int_Result() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnInteger" }), CancellationToken.None); + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + Assert.Equal("5", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + } + + [Fact] + public async Task Can_Call_Registered_Tool_And_Pass_Cancellation_Token() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnCancellationToken" }), token); + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + Assert.Equal(token.GetHashCode().ToString(), result.Content[0].Text); + } + + [Fact] + public async Task Can_Call_Registered_Tool_And_Returns_Cancelled_Response() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + await cts.CancelAsync(); + + var action = async () => await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnCancellationToken" }), token); + + await Assert.ThrowsAsync(action); + } + + [Fact] + public async Task Can_Call_Registered_Tool_And_Pass_ComplexType() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "EchoComplex", Arguments = new() { { "complex", JsonDocument.Parse("{\"Name\": \"Peter\", \"Age\": 25}").RootElement } } }), CancellationToken.None); + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + Assert.Equal("Peter", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + } + + [Fact] + public async Task Throws_Exception_When_Tool_Fails() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var action = async () => await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnError" }), CancellationToken.None); + + var exception = await Assert.ThrowsAsync(action); + Assert.Equal("Test error", exception.Message); } [Fact] @@ -88,7 +225,7 @@ public class McpServerBuilderExtensionsToolsTests return Mock.Of(); }); - McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoToolWithDi)); + McpServerBuilderExtensions.WithTool(_builder.Object); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; @@ -130,7 +267,93 @@ public class McpServerBuilderExtensionsToolsTests Assert.Equal("Missing required argument 'message'.", exception.Message); } - private static class EchoTool + [Fact] + public void Throws_Exception_For_Null_Types() + { + var action = () => McpServerBuilderExtensions.WithTools(_builder.Object, toolTypes: null!); + + var exception = Assert.Throws(action); + Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message); + } + + [Fact] + public void Throws_Exception_For_Empty_Types() + { + var action = () => McpServerBuilderExtensions.WithTools(_builder.Object, toolTypes: []); + + var exception = Assert.Throws(action); + Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message); + } + + [Fact] + public async Task Register_Tools_From_Current_Assembly() + { + McpServerBuilderExtensions.WithTools(_builder.Object); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.ListToolsHandler!(new(Mock.Of(), new()), CancellationToken.None); + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + + var tool = result.Tools[0]; + Assert.Equal("Echo", tool.Name); + } + + [Fact] + public void Throws_Exception_When_No_Tools_Are_Found_In_Given_Assembly() + { + var action = () => McpServerBuilderExtensions.WithToolsFromAssembly(_builder.Object, typeof(Mock).Assembly); + + var exception = Assert.Throws(action); + Assert.Equal("No types with marked methods found in the assembly. (Parameter 'assembly')", exception.Message); + } + + [Fact] + public async Task Recognizes_Parameter_Types() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.ListToolsHandler!(new(Mock.Of(), new()), CancellationToken.None); + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + + var tool = result.Tools.First(t => t.Name == "TestTool"); + Assert.Equal("TestTool", tool.Name); + Assert.Null(tool.Description); + Assert.NotNull(tool.InputSchema); + Assert.Equal("object", tool.InputSchema.Type); + Assert.NotNull(tool.InputSchema.Properties); + Assert.NotEmpty(tool.InputSchema.Properties); + + Assert.Contains("number", tool.InputSchema.Properties); + Assert.Equal("number", tool.InputSchema.Properties["number"].Type); + + Assert.Contains("otherNumber", tool.InputSchema.Properties); + Assert.Equal("number", tool.InputSchema.Properties["otherNumber"].Type); + + Assert.Contains("someCheck", tool.InputSchema.Properties); + Assert.Equal("boolean", tool.InputSchema.Properties["someCheck"].Type); + + Assert.Contains("someDate", tool.InputSchema.Properties); + Assert.Equal("string", tool.InputSchema.Properties["someDate"].Type); + + Assert.Contains("someOtherDate", tool.InputSchema.Properties); + Assert.Equal("string", tool.InputSchema.Properties["someOtherDate"].Type); + + Assert.Contains("data", tool.InputSchema.Properties); + Assert.Equal("array", tool.InputSchema.Properties["data"].Type); + + Assert.Contains("complexObject", tool.InputSchema.Properties); + Assert.Equal("object", tool.InputSchema.Properties["complexObject"].Type); + } + + [McpToolType] + public static class EchoTool { [McpTool, Description("Echoes the input back to the client.")] public static string Echo([Description("the echoes message")] string message) @@ -143,6 +366,54 @@ public class McpServerBuilderExtensionsToolsTests { return "hello hello" + message; } + + [McpTool] + public static string TestTool(int number, double otherNumber, bool someCheck, DateTime someDate, DateTimeOffset someOtherDate, string[] data, ComplexObject complexObject) + { + return "hello hello"; + } + + [McpTool] + public static string[] EchoArray(string message) + { + return ["hello " + message, "hello2 " + message]; + } + + [McpTool] + public static string? ReturnNull() + { + return null; + } + + [McpTool] + public static JsonElement ReturnJson() + { + return JsonDocument.Parse("{\"SomeProp\": false}").RootElement; + } + + [McpTool] + public static int ReturnInteger() + { + return 5; + } + + [McpTool] + public static string ReturnError() + { + throw new InvalidOperationException("Test error"); + } + + [McpTool] + public static int ReturnCancellationToken(CancellationToken cancellationToken) + { + return cancellationToken.GetHashCode(); + } + + [McpTool] + public static string EchoComplex(ComplexObject complex) + { + return complex.Name!; + } } public interface IDependendService @@ -164,4 +435,10 @@ public class McpServerBuilderExtensionsToolsTests return Task.FromResult("hello " + message); } } + + public class ComplexObject + { + public string? Name { get; set; } + public int Age { get; set; } + } } diff --git a/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs b/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs index 3516eb5..7da27b6 100644 --- a/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs +++ b/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs @@ -2,7 +2,7 @@ using McpDotNet.Server; using Moq; -namespace mcpdotnet.Tests.Server; +namespace McpDotNet.Tests.Server; public class McpServerDelegatesTests { diff --git a/tests/mcpdotnet.Tests/Server/McpServerFactoryTests.cs b/tests/mcpdotnet.Tests/Server/McpServerFactoryTests.cs new file mode 100644 index 0000000..ff567b3 --- /dev/null +++ b/tests/mcpdotnet.Tests/Server/McpServerFactoryTests.cs @@ -0,0 +1,90 @@ +using McpDotNet.Protocol.Transport; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace McpDotNet.Tests.Server; + +public class McpServerFactoryTests +{ + private readonly Mock _serverTransport; + private readonly Mock _loggerFactory; + private readonly Mock> _serverDelegates; + private readonly McpServerOptions _options; + private readonly IServiceProvider _serviceProvider; + + public McpServerFactoryTests() + { + _serverTransport = new Mock(); + _loggerFactory = new Mock(); + _serverDelegates = new Mock>(); + _options = new McpServerOptions + { + ServerInfo = new Implementation { Name = "TestServer", Version = "1.0" }, + ProtocolVersion = "1.0", + InitializationTimeout = TimeSpan.FromSeconds(30) + }; + _serviceProvider = new Mock().Object; + } + + [Fact] + public void Constructor_Should_Initialize_With_Valid_Parameters() + { + // Arrange & Act + var factory = new McpServerFactory(_serverTransport.Object, _options, _loggerFactory.Object, _serverDelegates.Object, _serviceProvider); + + // Assert + Assert.NotNull(factory); + } + + [Fact] + public void Constructor_Throws_For_Null_ServerTransport() + { + // Arrange, Act & Assert + Assert.Throws(() => new McpServerFactory(null!, _options, _loggerFactory.Object, _serverDelegates.Object, _serviceProvider)); + } + + [Fact] + public void Constructor_Throws_For_Null_Options() + { + // Arrange, Act & Assert + Assert.Throws(() => new McpServerFactory(_serverTransport.Object, null!, _loggerFactory.Object, _serverDelegates.Object, _serviceProvider)); + } + + [Fact] + public void Constructor_Throws_For_Null_Logger() + { + // Arrange, Act & Assert + Assert.Throws(() => new McpServerFactory(_serverTransport.Object, _options, null!, _serverDelegates.Object, _serviceProvider)); + } + + [Fact] + public void Constructor_Does_Not_Throw_For_Null_ServerDelegates() + { + var factory = new McpServerFactory(_serverTransport.Object, _options, _loggerFactory.Object, null, _serviceProvider); + Assert.NotNull(factory); + } + + [Fact] + public void Constructor_Does_Not_Throw_For_Null_ServiceProvider() + { + var factory = new McpServerFactory(_serverTransport.Object, _options, _loggerFactory.Object, _serverDelegates.Object, null); + Assert.NotNull(factory); + } + + [Fact] + public void CreateServer_Return_IMcpServerInstance() + { + // Arrange + var factory = new McpServerFactory(_serverTransport.Object, _options, _loggerFactory.Object, _serverDelegates.Object, _serviceProvider); + + // Act + var server = factory.CreateServer(); + + // Assert + Assert.NotNull(server); + Assert.IsAssignableFrom(server); + } +} diff --git a/tests/mcpdotnet.Tests/Server/McpServerTests.cs b/tests/mcpdotnet.Tests/Server/McpServerTests.cs new file mode 100644 index 0000000..d331803 --- /dev/null +++ b/tests/mcpdotnet.Tests/Server/McpServerTests.cs @@ -0,0 +1,555 @@ +using McpDotNet.Client; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Transport; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using McpDotNet.Tests.Utils; +using Microsoft.Extensions.Logging; +using Moq; + +namespace McpDotNet.Tests.Server; + +public class McpServerTests +{ + private readonly Mock _serverTransport; + private readonly Mock _loggerFactory; + private readonly Mock _logger; + private readonly McpServerOptions _options; + private readonly IServiceProvider _serviceProvider; + + public McpServerTests() + { + _serverTransport = new Mock(); + _loggerFactory = new Mock(); + _logger = new Mock(); + _loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(_logger.Object); + _options = CreateOptions(); + _serviceProvider = new Mock().Object; + } + + private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = null) + { + return new McpServerOptions + { + ServerInfo = new Implementation { Name = "TestServer", Version = "1.0" }, + ProtocolVersion = "2024", + InitializationTimeout = TimeSpan.FromSeconds(30), + Capabilities = capabilities + }; + } + + [Fact] + public void Constructor_Should_Initialize_With_Valid_Parameters() + { + // Arrange & Act + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + + // Assert + Assert.NotNull(server); + } + + [Fact] + public void Constructor_Throws_For_Null_Transport() + { + // Arrange, Act & Assert + Assert.Throws(() => new McpServer(null!, _options, _loggerFactory.Object, _serviceProvider)); + } + + [Fact] + public void Constructor_Throws_For_Null_Options() + { + // Arrange, Act & Assert + Assert.Throws(() => new McpServer(_serverTransport.Object, null!, _loggerFactory.Object, _serviceProvider)); + } + + [Fact] + public void Constructor_Throws_For_Null_LoggerFactory() + { + // Arrange, Act & Assert + Assert.Throws(() => new McpServer(_serverTransport.Object, _options, null!, _serviceProvider)); + } + + [Fact] + public void Constructor_Does_Not_Throw_For_Null_ServiceProvider() + { + // Arrange & Act + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, null); + + // Assert + Assert.NotNull(server); + } + + [Fact] + public void Property_EndpointName_Return_Infos() + { + 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); + } + + [Fact] + public async Task StartAsync_Should_Throw_InvalidOperationException_If_Already_Initializing() + { + // Arrange + 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 + var exception = await Assert.ThrowsAsync(() => server.StartAsync()); + Assert.Equal("Server is already initializing", exception.Message); + } + + [Fact] + public async Task StartAsync_Should_Do_Nothing_If_Already_Initialized() + { + // Arrange + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + server.IsInitialized = true; + + await server.StartAsync(); + + // Assert + _serverTransport.Verify(t => t.StartListeningAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task StartAsync_ShouldStartListening() + { + // Arrange + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + + // Act + await server.StartAsync(); + + // Assert + _serverTransport.Verify(t => t.StartListeningAsync(It.IsAny()), Times.Once); + } + + [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 server.StartAsync(); + + // Send initialized notification + await transport.SendMessageAsync( + new JsonRpcNotification + { + Method = "notifications/initialized" + } + ); + + await Task.Delay(50); + + Assert.True(server.IsInitialized); + } + + [Fact] + public async Task RequestSamplingAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Sampling() + { + // Arrange + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + server.ClientCapabilities = new ClientCapabilities(); + + var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); + + // Act & Assert + var exception = await Assert.ThrowsAsync(action); + Assert.Equal("Client does not support sampling", exception.Message); + } + + [Fact] + public async Task RequestSamplingAsync_Should_SendRequest() + { + // Arrange + var transport = new TestServerTransport(); + await using var server = new McpServer(transport, _options, _loggerFactory.Object, _serviceProvider); + server.ClientCapabilities = new ClientCapabilities { Sampling = new SamplingCapability() }; + + await server.StartAsync(); + + // Act + var result = await server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(transport.SentMessages); + Assert.IsType(transport.SentMessages[0]); + Assert.Equal("sampling/createMessage", ((JsonRpcRequest)transport.SentMessages[0]).Method); + } + + [Fact] + public async Task RequestRootsAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Roots() + { + // Arrange + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + server.ClientCapabilities = new ClientCapabilities(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None)); + Assert.Equal("Client does not support roots", exception.Message); + } + + [Fact] + public async Task RequestRootsAsync_Should_SendRequest() + { + // Arrange + 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(); + + // Act + var result = await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(transport.SentMessages); + Assert.IsType(transport.SentMessages[0]); + Assert.Equal("roots/list", ((JsonRpcRequest)transport.SentMessages[0]).Method); + } + + [Fact] + public async Task Throws_Exception_If_Not_Connected() + { + var server = new McpServer(_serverTransport.Object, _options, _loggerFactory.Object, _serviceProvider); + server.ClientCapabilities = new ClientCapabilities { Roots = new RootsCapability() }; + _serverTransport.SetupGet(t => t.IsConnected).Returns(false); + + var action = async () => await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None); + + var exception = await Assert.ThrowsAsync(action); + Assert.Equivalent("Transport is not connected", exception.Message); + } + + [Fact] + public async Task Can_Handle_Ping_Requests() + { + await Can_Handle_Requests(null, "ping", + configureServer: server => { }, + assertResult: response => + { + Assert.IsType(response); + }); + } + + [Fact] + public async Task Can_Handle_Initialize_Requests() + { + await Can_Handle_Requests(null, "initialize", + configureServer: server => { }, + assertResult: response => + { + Assert.IsType(response); + + var result = (InitializeResult)response; + Assert.Equal("TestServer", result.ServerInfo.Name); + Assert.Equal("1.0", result.ServerInfo.Version); + Assert.Equal("2024", result.ProtocolVersion); + }); + } + + [Fact] + public async Task Can_Handle_Completion_Requests() + { + await Can_Handle_Requests(null, "completion/complete", + configureServer: server => { }, + assertResult: response => + { + Assert.IsType(response); + + var result = (CompleteResult)response; + Assert.NotNull(result.Completion); + Assert.Empty(result.Completion.Values); + Assert.Equal(0, result.Completion.Total); + Assert.False(result.Completion.HasMore); + }); + } + + [Fact] + public async Task Can_Handle_Completion_Requests_With_Handler() + { + await Can_Handle_Requests(null, "completion/complete", + configureServer: server => + { + server.GetCompletionHandler = (request, ct) => + { + return Task.FromResult(new CompleteResult + { + Completion = new() + { + Values = ["test"], + Total = 2, + HasMore = true + } + }); + }; + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (CompleteResult)response; + Assert.NotNull(result.Completion); + Assert.NotEmpty(result.Completion.Values); + Assert.Equal("test", result.Completion.Values[0]); + Assert.Equal(2, result.Completion.Total); + Assert.True(result.Completion.HasMore); + }); + } + + [Fact] + public async Task Can_Handle_Resources_List_Requests() + { + await Can_Handle_Requests(new ServerCapabilities { Resources = new() }, "resources/list", + configureServer: server => + { + server.ListResourcesHandler = (request, ct) => + { + return Task.FromResult(new ListResourcesResult + { + Resources = new() + { + new() { Uri = "test", Name = "Test Resource" } + } + }); + }; + + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (ListResourcesResult)response; + Assert.NotNull(result.Resources); + Assert.NotEmpty(result.Resources); + Assert.Equal("test", result.Resources[0].Uri); + }); + } + + [Fact] + public async Task Can_Handle_Resources_List_Requests_Throws_Exception_If_No_Handler_Assigned() + { + await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, "resources/list", "ListResources handler not configured"); + } + + [Fact] + public async Task Can_Handle_ResourcesRead_Requests() + { + await Can_Handle_Requests(new ServerCapabilities { Resources = new() }, "resources/read", + configureServer: server => + { + server.ReadResourceHandler = (request, ct) => + { + return Task.FromResult(new ReadResourceResult + { + Contents = new List { new ResourceContents { Text = "test" } } + }); + }; + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (ReadResourceResult)response; + Assert.NotNull(result.Contents); + Assert.NotEmpty(result.Contents); + Assert.Equal("test", result.Contents[0].Text); + }); + } + + [Fact] + public async Task Can_Handle_Resources_Read_Requests_Throws_Exception_If_No_Handler_Assigned() + { + await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, "resources/read", "ReadResource handler not configured"); + } + + [Fact] + public async Task Can_Handle_List_Prompts_Requests() + { + await Can_Handle_Requests(new ServerCapabilities { Prompts = new() }, "prompts/list", + configureServer: server => + { + server.ListPromptsHandler = (request, ct) => + { + return Task.FromResult(new ListPromptsResult + { + Prompts = new List { new Prompt { Name = "test" } } + }); + }; + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (ListPromptsResult)response; + Assert.NotNull(result.Prompts); + Assert.NotEmpty(result.Prompts); + Assert.Equal("test", result.Prompts[0].Name); + }); + } + + [Fact] + public async Task Can_Handle_List_Prompts_Requests_Throws_Exception_If_No_Handler_Assigned() + { + await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, "prompts/list", "ListPrompts handler not configured"); + } + + [Fact] + public async Task Can_Handle_Get_Prompts_Requests() + { + await Can_Handle_Requests(new ServerCapabilities { Prompts = new() }, "prompts/get", + configureServer: server => + { + server.GetPromptHandler = (request, ct) => + { + return Task.FromResult(new GetPromptResult + { + Description = "test" + }); + }; + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (GetPromptResult)response; + Assert.Equal("test", result.Description); + }); + } + + [Fact] + public async Task Can_Handle_Get_Prompts_Requests_Throws_Exception_If_No_Handler_Assigned() + { + await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, "prompts/get", "GetPrompt handler not configured"); + } + + [Fact] + public async Task Can_Handle_List_Tools_Requests() + { + await Can_Handle_Requests(new ServerCapabilities { Tools = new() }, "tools/list", + configureServer: server => + { + server.ListToolsHandler = (request, ct) => + { + return Task.FromResult(new ListToolsResult + { + Tools = new List { new Tool { Name = "test" } } + }); + }; + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (ListToolsResult)response; + Assert.NotEmpty(result.Tools); + Assert.Equal("test", result.Tools[0].Name); + }); + } + + [Fact] + public async Task Can_Handle_List_Tools_Requests_Throws_Exception_If_No_Handler_Assigned() + { + await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, "tools/list", "ListTools handler not configured"); + } + + [Fact] + public async Task Can_Handle_Call_Tool_Requests() + { + await Can_Handle_Requests(new ServerCapabilities { Tools = new() }, "tools/call", + configureServer: server => + { + server.CallToolHandler = (request, ct) => + { + return Task.FromResult(new CallToolResponse + { + Content = [new Content { Text = "test" }] + }); + }; + }, + assertResult: response => + { + Assert.IsType(response); + + var result = (CallToolResponse)response; + Assert.NotEmpty(result.Content); + Assert.Equal("test", result.Content[0].Text); + }); + } + + [Fact] + public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_Assigned() + { + await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, "tools/call", "CallTool handler not configured"); + } + + private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action configureServer, Action assertResult) + { + var transport = new TestServerTransport(); + var options = serverCapabilities == null ? _options : CreateOptions(serverCapabilities); + + var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); + + await server.StartAsync(); + + configureServer(server); + + var receivedMessage = new TaskCompletionSource(); + + transport.OnMessageSent = (message) => + { + if (message is JsonRpcResponse response && response.Id.AsNumber == 55) + receivedMessage.SetResult(response); + }; + + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = method, + Id = RequestId.FromNumber(55) + } + ); + + var response = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.NotNull(response); + Assert.NotNull(response.Result); + + assertResult(response.Result); + } + + private async Task Throws_Exception_If_No_Handler_Assigned(ServerCapabilities serverCapabilities, string method, string expectedError) + { + var transport = new TestServerTransport(); + var options = CreateOptions(serverCapabilities); + + var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); + + await server.StartAsync(); + + var receivedMessage = new TaskCompletionSource(); + + transport.OnMessageSent = (message) => + { + if (message is JsonRpcError response && response.Id.AsNumber == 55) + receivedMessage.SetResult(response); + }; + + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = method, + Id = RequestId.FromNumber(55) + } + ); + + var response = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.NotNull(response); + Assert.IsType(response); + + var result = (JsonRpcError)response; + Assert.NotNull(result.Error); + Assert.Equal(expectedError, result.Error.Message); + } +} diff --git a/tests/mcpdotnet.Tests/SseClientTransportTests.cs b/tests/mcpdotnet.Tests/SseClientTransportTests.cs new file mode 100644 index 0000000..92e33dc --- /dev/null +++ b/tests/mcpdotnet.Tests/SseClientTransportTests.cs @@ -0,0 +1,173 @@ +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/SseTransportTests.cs b/tests/mcpdotnet.Tests/SseTransportTests.cs deleted file mode 100644 index df759c9..0000000 --- a/tests/mcpdotnet.Tests/SseTransportTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using McpDotNet.Configuration; -using McpDotNet.Protocol.Messages; -using McpDotNet.Protocol.Transport; -using Microsoft.Extensions.Logging.Abstractions; - -namespace McpDotNet.Tests.Client; - -public class SseTransportTests -{ - [Fact] - public void SseTransportConstructor_WithValidConfig_CreatesTransport() - { - // Arrange - var config = new McpServerConfig - { - Id = "test-server", - Name = "Test Server", - TransportType = TransportTypes.Sse, - Location = "http://localhost:8080" - }; - - var transportOptions = new SseClientTransportOptions - { - ConnectionTimeout = TimeSpan.FromSeconds(10), - MaxReconnectAttempts = 2, - ReconnectDelay = TimeSpan.FromSeconds(5), - AdditionalHeaders = new Dictionary - { - ["test"] = "header" - } - }; - - // Act - var transport = new SseClientTransport(transportOptions, config, NullLoggerFactory.Instance); - - // Assert - Assert.NotNull(transport); - Assert.Equal(TimeSpan.FromSeconds(10), transport.Options.ConnectionTimeout); - Assert.Equal(2, transport.Options.MaxReconnectAttempts); - Assert.Equal(TimeSpan.FromSeconds(5), transport.Options.ReconnectDelay); - Assert.NotNull(transport.Options.AdditionalHeaders); - Assert.Equal("header", transport.Options.AdditionalHeaders["test"]); - } - - [Fact] - public async Task SseTransportSendMessageAsync_WithMessageEndpointNotSet_ThrowsException() - { - // Arrange - var config = new McpServerConfig - { - Id = "test-server", - Name = "Test Server", - TransportType = TransportTypes.Sse, - Location = "http://localhost:8080" - }; - - var transportOptions = new SseClientTransportOptions - { - ConnectionTimeout = TimeSpan.FromSeconds(10), - MaxReconnectAttempts = 2, - ReconnectDelay = TimeSpan.FromSeconds(5), - AdditionalHeaders = new Dictionary - { - ["test"] = "header" - } - }; - - // Act - var transport = new SseClientTransport(transportOptions, config, NullLoggerFactory.Instance); - - // Assert - Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString())); - await Assert.ThrowsAsync(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test" }, CancellationToken.None)); - } -} \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/Utils/MockHttpHandler.cs b/tests/mcpdotnet.Tests/Utils/MockHttpHandler.cs new file mode 100644 index 0000000..5c0fb40 --- /dev/null +++ b/tests/mcpdotnet.Tests/Utils/MockHttpHandler.cs @@ -0,0 +1,22 @@ +namespace McpDotNet.Tests.Utils; + +public class MockHttpHandler : HttpMessageHandler +{ + public Func>? RequestHandler { get; set; } + + protected async override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (RequestHandler == null) + throw new InvalidOperationException($"No {nameof(RequestHandler)} was set! Please set handler first and make request afterwards."); + + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + var result = await RequestHandler.Invoke(request); + + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + return result; + } +} diff --git a/tests/mcpdotnet.Tests/Utils/TestServerTransport.cs b/tests/mcpdotnet.Tests/Utils/TestServerTransport.cs new file mode 100644 index 0000000..41ace3b --- /dev/null +++ b/tests/mcpdotnet.Tests/Utils/TestServerTransport.cs @@ -0,0 +1,91 @@ +using System.Threading.Channels; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Transport; + +namespace McpDotNet.Tests.Utils; + +public class TestServerTransport : IServerTransport +{ + private readonly Channel _messageChannel; + private bool _isStarted; + + public bool IsConnected => _isStarted; + + public ChannelReader MessageReader => _messageChannel; + + public List SentMessages { get; } = []; + + public Action? OnMessageSent { get; set; } + + public TestServerTransport() + { + _messageChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) + { + SentMessages.Add(message); + if (message is JsonRpcRequest request) + { + if (request.Method == "roots/list") + await ListRoots(request, cancellationToken); + else if (request.Method == "sampling/createMessage") + await Sampling(request, cancellationToken); + else + await WriteMessageAsync(request, cancellationToken); + } + else if (message is JsonRpcNotification notification) + { + await WriteMessageAsync(notification, cancellationToken); + } + + OnMessageSent?.Invoke(message); + } + + public Task StartListeningAsync(CancellationToken cancellationToken = default) + { + _isStarted = true; + return Task.CompletedTask; + } + + private async Task ListRoots(JsonRpcRequest request, CancellationToken cancellationToken) + { + await WriteMessageAsync(new JsonRpcResponse + { + Id = request.Id, + Result = new McpDotNet.Protocol.Types.ListRootsResult + { + Roots = [] + } + }, cancellationToken); + } + + private async Task Sampling(JsonRpcRequest request, CancellationToken cancellationToken) + { + await WriteMessageAsync(new JsonRpcResponse + { + Id = request.Id, + Result = new Protocol.Types.CreateMessageResult { Content = new(), Model = "model", Role = "role" } + }, cancellationToken); + } + + private async Task Error(JsonRpcRequest request, CancellationToken cancellationToken) + { + await WriteMessageAsync(new JsonRpcError + { + Id = request.Id, + Error = new JsonRpcErrorDetail() { Code = -32601, Message = $"Method '{request.Method}' not supported" } + }, cancellationToken); + } + + protected async Task WriteMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) + { + await _messageChannel.Writer.WriteAsync(message, cancellationToken); + } +}