Improve code coverage (#40)
* Add tests for tool generation * Add couple of MCPServer tests * Fix merge issues * More tests for McpServer * Some more tests * Fix merge issues
This commit is contained in:
parent
709b2e0a74
commit
24c706b4ff
16 changed files with 1276 additions and 89 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ public readonly struct RequestId : IEquatable<RequestId>
|
|||
/// </summary>
|
||||
public bool IsNumber => _value is long;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the request id is valid (has a value)
|
||||
/// </summary>
|
||||
public bool IsValid => _value != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the RequestId as a string.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
private readonly McpServerConfig _serverConfig;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TaskCompletionSource<bool> _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
|
|||
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
|
||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
||||
: this(transportOptions, serverConfig, new HttpClient(), loggerFactory, true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
|
||||
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||
/// <param name="ownsHttpClient">True to dispose HTTP client on close connection.</param>
|
||||
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<SseClientTransport>() : NullLogger.Instance;
|
||||
_jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions;
|
||||
_connectionEstablished = new TaskCompletionSource<bool>();
|
||||
_ownsHttpClient = ownsHttpClient;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
|
@ -161,15 +182,26 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
if (_receiveTask != null)
|
||||
await _receiveTask.ConfigureAwait(false);
|
||||
|
||||
_httpClient.Dispose();
|
||||
_connectionCts.Dispose();
|
||||
SetConnected(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<McpServer>();
|
||||
ServerInstructions = options.ServerInstructions;
|
||||
ServiceProvider = serviceProvider;
|
||||
|
|
|
|||
|
|
@ -33,11 +33,14 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
|
|||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
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<McpClient>();
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), 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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), 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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), 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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), 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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
|
||||
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), 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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
await cts.CancelAsync();
|
||||
|
||||
var action = async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "ReturnCancellationToken" }), token);
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), 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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var action = async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "ReturnError" }), CancellationToken.None);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(action);
|
||||
Assert.Equal("Test error", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -88,7 +225,7 @@ public class McpServerBuilderExtensionsToolsTests
|
|||
return Mock.Of<IDependendService>();
|
||||
});
|
||||
|
||||
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoToolWithDi));
|
||||
McpServerBuilderExtensions.WithTool<EchoToolWithDi>(_builder.Object);
|
||||
|
||||
var serviceProvider = _services.BuildServiceProvider();
|
||||
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().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<ArgumentException>(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<ArgumentException>(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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.ListToolsHandler!(new(Mock.Of<IMcpServer>(), 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<ArgumentException>(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<IOptions<McpServerDelegates>>().Value;
|
||||
|
||||
var result = await options.ListToolsHandler!(new(Mock.Of<IMcpServer>(), 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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
using McpDotNet.Server;
|
||||
using Moq;
|
||||
|
||||
namespace mcpdotnet.Tests.Server;
|
||||
namespace McpDotNet.Tests.Server;
|
||||
|
||||
public class McpServerDelegatesTests
|
||||
{
|
||||
|
|
|
|||
90
tests/mcpdotnet.Tests/Server/McpServerFactoryTests.cs
Normal file
90
tests/mcpdotnet.Tests/Server/McpServerFactoryTests.cs
Normal file
|
|
@ -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<IServerTransport> _serverTransport;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactory;
|
||||
private readonly Mock<IOptions<McpServerDelegates>> _serverDelegates;
|
||||
private readonly McpServerOptions _options;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public McpServerFactoryTests()
|
||||
{
|
||||
_serverTransport = new Mock<IServerTransport>();
|
||||
_loggerFactory = new Mock<ILoggerFactory>();
|
||||
_serverDelegates = new Mock<IOptions<McpServerDelegates>>();
|
||||
_options = new McpServerOptions
|
||||
{
|
||||
ServerInfo = new Implementation { Name = "TestServer", Version = "1.0" },
|
||||
ProtocolVersion = "1.0",
|
||||
InitializationTimeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_serviceProvider = new Mock<IServiceProvider>().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<ArgumentNullException>(() => new McpServerFactory(null!, _options, _loggerFactory.Object, _serverDelegates.Object, _serviceProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Options()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new McpServerFactory(_serverTransport.Object, null!, _loggerFactory.Object, _serverDelegates.Object, _serviceProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Logger()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => 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<IMcpServer>(server);
|
||||
}
|
||||
}
|
||||
555
tests/mcpdotnet.Tests/Server/McpServerTests.cs
Normal file
555
tests/mcpdotnet.Tests/Server/McpServerTests.cs
Normal file
|
|
@ -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<IServerTransport> _serverTransport;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactory;
|
||||
private readonly Mock<ILogger> _logger;
|
||||
private readonly McpServerOptions _options;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public McpServerTests()
|
||||
{
|
||||
_serverTransport = new Mock<IServerTransport>();
|
||||
_loggerFactory = new Mock<ILoggerFactory>();
|
||||
_logger = new Mock<ILogger>();
|
||||
_loggerFactory.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(_logger.Object);
|
||||
_options = CreateOptions();
|
||||
_serviceProvider = new Mock<IServiceProvider>().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<ArgumentNullException>(() => new McpServer(null!, _options, _loggerFactory.Object, _serviceProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Options()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new McpServer(_serverTransport.Object, null!, _loggerFactory.Object, _serviceProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_LoggerFactory()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => 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<InvalidOperationException>(() => 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<CancellationToken>()), 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<CancellationToken>()), 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<McpServerException>(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<JsonRpcRequest>(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<McpServerException>(() => 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<JsonRpcRequest>(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<McpClientException>(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<PingResult>(response);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Handle_Initialize_Requests()
|
||||
{
|
||||
await Can_Handle_Requests(null, "initialize",
|
||||
configureServer: server => { },
|
||||
assertResult: response =>
|
||||
{
|
||||
Assert.IsType<InitializeResult>(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<CompleteResult>(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<CompleteResult>(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<ListResourcesResult>(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<ResourceContents> { new ResourceContents { Text = "test" } }
|
||||
});
|
||||
};
|
||||
},
|
||||
assertResult: response =>
|
||||
{
|
||||
Assert.IsType<ReadResourceResult>(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<Prompt> { new Prompt { Name = "test" } }
|
||||
});
|
||||
};
|
||||
},
|
||||
assertResult: response =>
|
||||
{
|
||||
Assert.IsType<ListPromptsResult>(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<GetPromptResult>(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<Tool> { new Tool { Name = "test" } }
|
||||
});
|
||||
};
|
||||
},
|
||||
assertResult: response =>
|
||||
{
|
||||
Assert.IsType<ListToolsResult>(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<CallToolResponse>(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<IMcpServer> configureServer, Action<object> 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<JsonRpcResponse>();
|
||||
|
||||
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<IJsonRpcMessage>();
|
||||
|
||||
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<JsonRpcError>(response);
|
||||
|
||||
var result = (JsonRpcError)response;
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.Equal(expectedError, result.Error.Message);
|
||||
}
|
||||
}
|
||||
173
tests/mcpdotnet.Tests/SseClientTransportTests.cs
Normal file
173
tests/mcpdotnet.Tests/SseClientTransportTests.cs
Normal file
|
|
@ -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<string, string>
|
||||
{
|
||||
["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<ArgumentNullException>(() => new SseClientTransport(null!, _serverConfig, NullLoggerFactory.Instance));
|
||||
Assert.Equal("transportOptions", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Config()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new SseClientTransport(_transportOptions, null!, NullLoggerFactory.Instance));
|
||||
Assert.Equal("serverConfig", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_HttpClientg()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => 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<McpTransportException>(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<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["test"] = "header"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var transport = new SseClientTransport(transportOptions, config, NullLoggerFactory.Instance);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString()));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test" }, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
22
tests/mcpdotnet.Tests/Utils/MockHttpHandler.cs
Normal file
22
tests/mcpdotnet.Tests/Utils/MockHttpHandler.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
namespace McpDotNet.Tests.Utils;
|
||||
|
||||
public class MockHttpHandler : HttpMessageHandler
|
||||
{
|
||||
public Func<HttpRequestMessage, Task<HttpResponseMessage>>? RequestHandler { get; set; }
|
||||
|
||||
protected async override Task<HttpResponseMessage> 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;
|
||||
}
|
||||
}
|
||||
91
tests/mcpdotnet.Tests/Utils/TestServerTransport.cs
Normal file
91
tests/mcpdotnet.Tests/Utils/TestServerTransport.cs
Normal file
|
|
@ -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<IJsonRpcMessage> _messageChannel;
|
||||
private bool _isStarted;
|
||||
|
||||
public bool IsConnected => _isStarted;
|
||||
|
||||
public ChannelReader<IJsonRpcMessage> MessageReader => _messageChannel;
|
||||
|
||||
public List<IJsonRpcMessage> SentMessages { get; } = [];
|
||||
|
||||
public Action<IJsonRpcMessage>? OnMessageSent { get; set; }
|
||||
|
||||
public TestServerTransport()
|
||||
{
|
||||
_messageChannel = Channel.CreateUnbounded<IJsonRpcMessage>(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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue