* Move notification handler registrations to capabilities Currently request handlers are set on the capability objects, but notification handlers are set after construction via an AddNotificationHandler method on the IMcpEndpoint interface. This moves handler specification to be at construction as well. This makes it more consistent with request handlers, simplifies the IMcpEndpoint interface to just be about message sending, and avoids a concurrency bug that could occur if someone tried to add a handler while the endpoint was processing notifications. * Address more feedback and further cleanup
286 lines
9.7 KiB
C#
286 lines
9.7 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ModelContextProtocol.Client;
|
|
using ModelContextProtocol.Protocol.Messages;
|
|
using ModelContextProtocol.Protocol.Transport;
|
|
using ModelContextProtocol.Protocol.Types;
|
|
using Moq;
|
|
using System.Text.Json;
|
|
using System.Threading.Channels;
|
|
|
|
namespace ModelContextProtocol.Tests.Client;
|
|
|
|
public class McpClientFactoryTests
|
|
{
|
|
[Fact]
|
|
public async Task CreateAsync_WithInvalidArgs_Throws()
|
|
{
|
|
await Assert.ThrowsAsync<ArgumentNullException>("serverConfig", () => McpClientFactory.CreateAsync((McpServerConfig)null!, cancellationToken: TestContext.Current.CancellationToken));
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>("serverConfig", () => McpClientFactory.CreateAsync(new McpServerConfig()
|
|
{
|
|
Name = "name",
|
|
Id = "id",
|
|
TransportType = "somethingunsupported",
|
|
}, cancellationToken: TestContext.Current.CancellationToken));
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => McpClientFactory.CreateAsync(new McpServerConfig()
|
|
{
|
|
Name = "name",
|
|
Id = "id",
|
|
TransportType = TransportTypes.StdIo,
|
|
}, createTransportFunc: (_, __) => null!, cancellationToken: TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_NullOptions_EntryAssemblyInferred()
|
|
{
|
|
// Arrange
|
|
var serverConfig = new McpServerConfig
|
|
{
|
|
Id = "test-server",
|
|
Name = "Test Server",
|
|
TransportType = TransportTypes.StdIo,
|
|
Location = "/path/to/server",
|
|
};
|
|
|
|
// Act
|
|
await using var client = await McpClientFactory.CreateAsync(
|
|
serverConfig,
|
|
null,
|
|
(_, __) => new NopTransport(),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
Assert.NotNull(client);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WithValidStdioConfig_CreatesNewClient()
|
|
{
|
|
// Arrange
|
|
var serverConfig = new McpServerConfig
|
|
{
|
|
Id = "test-server",
|
|
Name = "Test Server",
|
|
TransportType = TransportTypes.StdIo,
|
|
Location = "/path/to/server",
|
|
TransportOptions = new Dictionary<string, string>
|
|
{
|
|
["arguments"] = "--test arg",
|
|
["workingDirectory"] = "/working/dir"
|
|
}
|
|
};
|
|
|
|
// Act
|
|
await using var client = await McpClientFactory.CreateAsync(
|
|
serverConfig,
|
|
createTransportFunc: (_, __) => new NopTransport(),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(client);
|
|
// We could add more assertions here about the client's configuration
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WithNoTransportOptions_CreatesNewClient()
|
|
{
|
|
// Arrange
|
|
var serverConfig = new McpServerConfig
|
|
{
|
|
Id = "test-server",
|
|
Name = "Test Server",
|
|
TransportType = TransportTypes.StdIo,
|
|
Location = "/path/to/server",
|
|
};
|
|
|
|
// Act
|
|
await using var client = await McpClientFactory.CreateAsync(
|
|
serverConfig,
|
|
createTransportFunc: (_, __) => new NopTransport(),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(client);
|
|
// We could add more assertions here about the client's configuration
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WithValidSseConfig_CreatesNewClient()
|
|
{
|
|
// Arrange
|
|
var serverConfig = new McpServerConfig
|
|
{
|
|
Id = "test-server",
|
|
Name = "Test Server",
|
|
TransportType = TransportTypes.Sse,
|
|
Location = "http://localhost:8080"
|
|
};
|
|
|
|
// Act
|
|
await using var client = await McpClientFactory.CreateAsync(
|
|
serverConfig,
|
|
createTransportFunc: (_, __) => new NopTransport(),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(client);
|
|
// We could add more assertions here about the client's configuration
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WithSse_CreatesCorrectTransportOptions()
|
|
{
|
|
// Arrange
|
|
var serverConfig = new McpServerConfig
|
|
{
|
|
Id = "test-server",
|
|
Name = "Test Server",
|
|
TransportType = TransportTypes.Sse,
|
|
Location = "http://localhost:8080",
|
|
TransportOptions = new Dictionary<string, string>
|
|
{
|
|
["connectionTimeout"] = "10",
|
|
["maxReconnectAttempts"] = "2",
|
|
["reconnectDelay"] = "5",
|
|
["header.test"] = "the_header_value"
|
|
}
|
|
};
|
|
|
|
// Act
|
|
await using var client = await McpClientFactory.CreateAsync(
|
|
serverConfig,
|
|
createTransportFunc: (_, __) => new NopTransport(),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(client);
|
|
// We could add more assertions here about the client's configuration
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("connectionTimeout", "not_a_number")]
|
|
[InlineData("maxReconnectAttempts", "invalid")]
|
|
[InlineData("reconnectDelay", "bad_value")]
|
|
public async Task McpFactory_WithInvalidTransportOptions_ThrowsFormatException(string key, string value)
|
|
{
|
|
// arrange
|
|
var config = new McpServerConfig
|
|
{
|
|
Id = "test-server",
|
|
Name = "Test Server",
|
|
TransportType = TransportTypes.Sse,
|
|
Location = "http://localhost:8080",
|
|
TransportOptions = new Dictionary<string, string>
|
|
{
|
|
[key] = value
|
|
}
|
|
};
|
|
|
|
// act & assert
|
|
await Assert.ThrowsAsync<ArgumentException>(() => McpClientFactory.CreateAsync(config, cancellationToken: TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(typeof(NopTransport))]
|
|
[InlineData(typeof(FailureTransport))]
|
|
public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
|
|
{
|
|
// Arrange
|
|
var serverConfig = new McpServerConfig
|
|
{
|
|
Id = "TestServer",
|
|
Name = "TestServer",
|
|
TransportType = "stdio",
|
|
Location = "test-location"
|
|
};
|
|
|
|
var clientOptions = new McpClientOptions
|
|
{
|
|
Capabilities = new ClientCapabilities
|
|
{
|
|
Sampling = new SamplingCapability
|
|
{
|
|
SamplingHandler = (c, p, t) => Task.FromResult(
|
|
new CreateMessageResult {
|
|
Content = new Content { Text = "result" },
|
|
Model = "test-model",
|
|
Role = "test-role",
|
|
StopReason = "endTurn"
|
|
}),
|
|
},
|
|
Roots = new RootsCapability
|
|
{
|
|
ListChanged = true,
|
|
RootsHandler = (t, r) => Task.FromResult(new ListRootsResult { Roots = [] }),
|
|
}
|
|
}
|
|
};
|
|
|
|
var clientTransport = (IClientTransport?)Activator.CreateInstance(transportType);
|
|
IMcpClient? client = null;
|
|
|
|
var actionTask = McpClientFactory.CreateAsync(serverConfig, clientOptions, (config, logger) => clientTransport ?? new NopTransport(), new Mock<ILoggerFactory>().Object, CancellationToken.None);
|
|
|
|
// Act
|
|
if (clientTransport is FailureTransport)
|
|
{
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async() => await actionTask);
|
|
Assert.Equal(FailureTransport.ExpectedMessage, exception.Message);
|
|
}
|
|
else
|
|
{
|
|
client = await actionTask;
|
|
|
|
// Assert
|
|
Assert.NotNull(client);
|
|
}
|
|
}
|
|
|
|
private class NopTransport : ITransport, IClientTransport
|
|
{
|
|
private readonly Channel<IJsonRpcMessage> _channel = Channel.CreateUnbounded<IJsonRpcMessage>();
|
|
|
|
public bool IsConnected => true;
|
|
|
|
public ChannelReader<IJsonRpcMessage> MessageReader => _channel.Reader;
|
|
|
|
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult<ITransport>(this);
|
|
|
|
public ValueTask DisposeAsync() => default;
|
|
|
|
public virtual Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
|
{
|
|
switch (message)
|
|
{
|
|
case JsonRpcRequest:
|
|
_channel.Writer.TryWrite(new JsonRpcResponse
|
|
{
|
|
Id = ((JsonRpcRequest)message).Id,
|
|
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
|
{
|
|
Capabilities = new ServerCapabilities(),
|
|
ProtocolVersion = "2024-11-05",
|
|
ServerInfo = new Implementation
|
|
{
|
|
Name = "NopTransport",
|
|
Version = "1.0.0"
|
|
},
|
|
}),
|
|
});
|
|
break;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FailureTransport : NopTransport
|
|
{
|
|
public const string ExpectedMessage = "Something failed";
|
|
|
|
public override Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new InvalidOperationException(ExpectedMessage);
|
|
}
|
|
}
|
|
}
|