From 2e1b931c57007050c588aa2f5041c17a14818f44 Mon Sep 17 00:00:00 2001 From: Esteban Solano Date: Fri, 28 Mar 2025 22:45:30 -0700 Subject: [PATCH 1/2] + Tests for McpClientExtensions code --- .../Client/McpClientExtensionsTests.cs | 151 +++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs index 02bd50d1..8f5c915f 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs @@ -1,9 +1,11 @@ +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using Moq; using System.IO.Pipelines; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -40,7 +42,154 @@ public McpClientExtensionsTests(ITestOutputHelper outputHelper) _serverTask = server.RunAsync(cancellationToken: _cts.Token); } - public async ValueTask DisposeAsync() + [Theory] + [InlineData(null, null)] + [InlineData(0.7f, 50)] + [InlineData(1.0f, 100)] + public async Task CreateSamplingHandler_ShouldHandleTextMessages(float? temperature, int? maxTokens) + { + // Arrange + var mockChatClient = new Mock(); + var requestParams = new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = new Content { Type = "text", Text = "Hello" } + } + ], + Temperature = temperature, + MaxTokens = maxTokens + }; + + var cancellationToken = CancellationToken.None; + var expectedResponse = new ChatResponse + { + Messages = { new ChatMessage { Role = ChatRole.Assistant, Contents = { new TextContent("Hi there!") } } }, + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop + }; + + mockChatClient + .Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) + .ReturnsAsync(expectedResponse); + + var handler = McpClientExtensions.CreateSamplingHandler(mockChatClient.Object); + + // Act + var result = await handler(requestParams, cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal("Hi there!", result.Content.Text); + Assert.Equal("test-model", result.Model); + Assert.Equal("assistant", result.Role); + Assert.Equal("endTurn", result.StopReason); + } + + [Fact] + public async Task CreateSamplingHandler_ShouldHandleImageMessages() + { + // Arrange + var mockChatClient = new Mock(); + var requestParams = new CreateMessageRequestParams + { + Messages = new[] + { + new SamplingMessage + { + Role = Role.User, + Content = new Content + { + Type = "image", + MimeType = "image/png", + Data = Convert.ToBase64String(new byte[] { 1, 2, 3 }) + } + } + }, + MaxTokens = 100 + }; + var cancellationToken = CancellationToken.None; + + var expectedResponse = new ChatResponse + { + Messages = { new ChatMessage { Role = ChatRole.Assistant, Contents = new[] { new TextContent("Image received!") } } }, + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop + }; + + mockChatClient + .Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) + .ReturnsAsync(expectedResponse); + + var handler = McpClientExtensions.CreateSamplingHandler(mockChatClient.Object); + + // Act + var result = await handler(requestParams, cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal("Image received!", result.Content.Text); + Assert.Equal("test-model", result.Model); + Assert.Equal("assistant", result.Role); + Assert.Equal("endTurn", result.StopReason); + } + + [Fact] + public async Task CreateSamplingHandler_ShouldHandleResourceMessages() + { + // Arrange + var mockChatClient = new Mock(); + var requestParams = new CreateMessageRequestParams + { + Messages = new[] + { + new SamplingMessage + { + Role = Role.User, + Content = new Content + { + Type = "resource", + Resource = new ResourceContents + { + Text = "Resource text", + Blob = Convert.ToBase64String(new byte[] { 4, 5, 6 }), + MimeType = "application/octet-stream" + } + } + } + }, + MaxTokens = 100 + }; + var cancellationToken = CancellationToken.None; + + var expectedResponse = new ChatResponse + { + Messages = { new ChatMessage { Role = ChatRole.Assistant, Contents = new[] { new TextContent("Resource processed!") } } }, + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop + }; + + mockChatClient + .Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) + .ReturnsAsync(expectedResponse); + + var handler = McpClientExtensions.CreateSamplingHandler(mockChatClient.Object); + + // Act + var result = await handler(requestParams, cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal("Resource processed!", result.Content.Text); + Assert.Equal("test-model", result.Model); + Assert.Equal("assistant", result.Role); + Assert.Equal("endTurn", result.StopReason); + } + + public ValueTask DisposeAsync() { await _cts.CancelAsync(); From dc2221bd9e4013b557833b9839ca272403762534 Mon Sep 17 00:00:00 2001 From: Esteban Solano Date: Fri, 28 Mar 2025 22:48:04 -0700 Subject: [PATCH 2/2] + code coverage for ClientOptions/Capabilities code --- .../Client/McpClientExtensionsTests.cs | 148 +++++++++++------- .../Client/McpClientFactoryTests.cs | 77 ++++++++- 2 files changed, 163 insertions(+), 62 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs index 8f5c915f..1a451a2d 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; @@ -61,29 +62,39 @@ public async Task CreateSamplingHandler_ShouldHandleTextMessages(float? temperat } ], Temperature = temperature, - MaxTokens = maxTokens + MaxTokens = maxTokens, + Meta = new RequestParamsMetadata + { + ProgressToken = new ProgressToken(), + } }; var cancellationToken = CancellationToken.None; - var expectedResponse = new ChatResponse - { - Messages = { new ChatMessage { Role = ChatRole.Assistant, Contents = { new TextContent("Hi there!") } } }, - ModelId = "test-model", - FinishReason = ChatFinishReason.Stop - }; + var expectedResponse = new[] { + new ChatResponseUpdate + { + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop, + Role = ChatRole.Assistant, + Contents = + [ + new TextContent("Hello, World!") { RawRepresentation = "Hello, World!" } + ] + } + }.ToAsyncEnumerable(); mockChatClient - .Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) - .ReturnsAsync(expectedResponse); + .Setup(client => client.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) + .Returns(expectedResponse); var handler = McpClientExtensions.CreateSamplingHandler(mockChatClient.Object); // Act - var result = await handler(requestParams, cancellationToken); + var result = await handler(requestParams, Mock.Of>(), cancellationToken); // Assert Assert.NotNull(result); - Assert.Equal("Hi there!", result.Content.Text); + Assert.Equal("Hello, World!", result.Content.Text); Assert.Equal("test-model", result.Model); Assert.Equal("assistant", result.Role); Assert.Equal("endTurn", result.StopReason); @@ -96,42 +107,49 @@ public async Task CreateSamplingHandler_ShouldHandleImageMessages() var mockChatClient = new Mock(); var requestParams = new CreateMessageRequestParams { - Messages = new[] - { - new SamplingMessage - { - Role = Role.User, - Content = new Content + Messages = + [ + new SamplingMessage { - Type = "image", - MimeType = "image/png", - Data = Convert.ToBase64String(new byte[] { 1, 2, 3 }) + Role = Role.User, + Content = new Content + { + Type = "image", + MimeType = "image/png", + Data = Convert.ToBase64String(new byte[] { 1, 2, 3 }) + } } - } - }, + ], MaxTokens = 100 }; - var cancellationToken = CancellationToken.None; - var expectedResponse = new ChatResponse - { - Messages = { new ChatMessage { Role = ChatRole.Assistant, Contents = new[] { new TextContent("Image received!") } } }, - ModelId = "test-model", - FinishReason = ChatFinishReason.Stop - }; + const string expectedData = "SGVsbG8sIFdvcmxkIQ=="; + var cancellationToken = CancellationToken.None; + var expectedResponse = new[] { + new ChatResponseUpdate + { + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop, + Role = ChatRole.Assistant, + Contents = + [ + new DataContent($"data:image/png;base64,{expectedData}") { RawRepresentation = "Hello, World!" } + ] + } + }.ToAsyncEnumerable(); mockChatClient - .Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) - .ReturnsAsync(expectedResponse); + .Setup(client => client.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) + .Returns(expectedResponse); var handler = McpClientExtensions.CreateSamplingHandler(mockChatClient.Object); // Act - var result = await handler(requestParams, cancellationToken); + var result = await handler(requestParams, Mock.Of>(), cancellationToken); // Assert Assert.NotNull(result); - Assert.Equal("Image received!", result.Content.Text); + Assert.Equal(expectedData, result.Content.Data); Assert.Equal("test-model", result.Model); Assert.Equal("assistant", result.Role); Assert.Equal("endTurn", result.StopReason); @@ -141,55 +159,65 @@ public async Task CreateSamplingHandler_ShouldHandleImageMessages() public async Task CreateSamplingHandler_ShouldHandleResourceMessages() { // Arrange + const string data = "SGVsbG8sIFdvcmxkIQ=="; + string content = $"data:application/octet-stream;base64,{data}"; var mockChatClient = new Mock(); + var resource = new BlobResourceContents + { + Blob = data, + MimeType = "application/octet-stream", + Uri = "data:application/octet-stream" + }; + var requestParams = new CreateMessageRequestParams { - Messages = new[] - { - new SamplingMessage - { - Role = Role.User, - Content = new Content + Messages = + [ + new SamplingMessage { - Type = "resource", - Resource = new ResourceContents + Role = Role.User, + Content = new Content { - Text = "Resource text", - Blob = Convert.ToBase64String(new byte[] { 4, 5, 6 }), - MimeType = "application/octet-stream" - } + Type = "resource", + Resource = resource + }, } - } - }, + ], MaxTokens = 100 }; - var cancellationToken = CancellationToken.None; - var expectedResponse = new ChatResponse - { - Messages = { new ChatMessage { Role = ChatRole.Assistant, Contents = new[] { new TextContent("Resource processed!") } } }, - ModelId = "test-model", - FinishReason = ChatFinishReason.Stop - }; + var cancellationToken = CancellationToken.None; + var expectedResponse = new[] { + new ChatResponseUpdate + { + ModelId = "test-model", + FinishReason = ChatFinishReason.Stop, + AuthorName = "bot", + Role = ChatRole.Assistant, + Contents = + [ + resource.ToAIContent() + ] + } + }.ToAsyncEnumerable(); mockChatClient - .Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) - .ReturnsAsync(expectedResponse); + .Setup(client => client.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), cancellationToken)) + .Returns(expectedResponse); var handler = McpClientExtensions.CreateSamplingHandler(mockChatClient.Object); // Act - var result = await handler(requestParams, cancellationToken); + var result = await handler(requestParams, Mock.Of>(), cancellationToken); // Assert Assert.NotNull(result); - Assert.Equal("Resource processed!", result.Content.Text); Assert.Equal("test-model", result.Model); - Assert.Equal("assistant", result.Role); + Assert.Equal(ChatRole.Assistant.ToString(), result.Role); Assert.Equal("endTurn", result.StopReason); } - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { await _cts.CancelAsync(); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientFactoryTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientFactoryTests.cs index d4545ddc..ae58023f 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientFactoryTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientFactoryTests.cs @@ -1,7 +1,9 @@ +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; @@ -187,7 +189,68 @@ public async Task McpFactory_WithInvalidTransportOptions_ThrowsFormatException(s await Assert.ThrowsAsync(() => McpClientFactory.CreateAsync(config, _defaultOptions, cancellationToken: TestContext.Current.CancellationToken)); } - private sealed class NopTransport : ITransport, IClientTransport + [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 + { + ClientInfo = new Implementation + { + Name = "TestClient", + Version = "1.0.0.0" + }, + 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().Object, CancellationToken.None); + + // Act + if (clientTransport is FailureTransport) + { + var exception = await Assert.ThrowsAsync(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 _channel = Channel.CreateUnbounded(); @@ -199,7 +262,7 @@ private sealed class NopTransport : ITransport, IClientTransport public ValueTask DisposeAsync() => default; - public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) + public virtual Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) { switch (message) { @@ -224,4 +287,14 @@ public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancella 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); + } + } }