diff --git a/Directory.Packages.props b/Directory.Packages.props index 656f5fe9..c058881f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ true 9.0.10 10.0.0-rc.2.25502.107 - 9.10.1 + 9.10.2 @@ -60,7 +60,7 @@ all - + diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index 60c2ee89..012bc813 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -130,6 +130,26 @@ internal McpClientTool( AIFunctionArguments arguments, CancellationToken cancellationToken) { CallToolResult result = await CallAsync(arguments, _progress, JsonSerializerOptions, cancellationToken).ConfigureAwait(false); + + // We want to translate the result content into AIContent, using AIContent as the exchange types, so + // that downstream IChatClients can specialize handling based on the content (e.g. sending image content + // back to the AI service as a multi-modal tool response). However, when there is additional information + // carried by the CallToolResult outside of its ContentBlocks, just returning AIContent from those ContentBlocks + // would lose that information. So, we only do the translation if there is no additional information to preserve. + if (result.IsError is not true && + result.StructuredContent is null && + result.Meta is not { Count: > 0 }) + { + switch (result.Content.Count) + { + case 1 when result.Content[0].ToAIContent() is { } aiContent: + return aiContent; + + case > 1 when result.Content.Select(c => c.ToAIContent()).ToArray() is { } aiContents && aiContents.All(static c => c is not null): + return aiContents; + } + } + return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult); } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index 3ba56988..45d8a467 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -1,8 +1,11 @@ +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -15,295 +18,475 @@ public McpClientToolTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Add a simple echo tool for testing - mcpServerBuilder.WithTools([McpServerTool.Create((string message) => $"Echo: {message}", new() { Name = "echo", Description = "Echoes back the message" })]); - - // Add a tool with parameters for testing - mcpServerBuilder.WithTools([McpServerTool.Create((int a, int b) => a + b, new() { Name = "add", Description = "Adds two numbers" })]); - - // Add a tool that returns complex result - mcpServerBuilder.WithTools([McpServerTool.Create((string name, int age) => $"Person: {name}, Age: {age}", new() { Name = "createPerson", Description = "Creates a person description" })]); + mcpServerBuilder.WithTools(); + } + + private class TestTools + { + // Tool that returns only text content + [McpServerTool] + public static TextContentBlock TextOnlyTool() => + new TextContentBlock { Text = "Simple text result" }; + + // Tool that returns only text content (string) + [McpServerTool] + public static string StringTool() => "Simple string result"; + + // Tool that returns image content as single ContentBlock + [McpServerTool] + public static ImageContentBlock ImageTool() => + new ImageContentBlock { Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-image-data")), MimeType = "image/png" }; + + // Tool that returns audio content as single ContentBlock + [McpServerTool] + public static AudioContentBlock AudioTool() => + new AudioContentBlock { Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-audio-data")), MimeType = "audio/mp3" }; + + // Tool that returns embedded resource + [McpServerTool] + public static EmbeddedResourceBlock EmbeddedResourceTool() => + new EmbeddedResourceBlock { Resource = new TextResourceContents { Uri = "resource-uri", Text = "Resource text content", MimeType = "text/plain" } }; + + // Tool that returns mixed content (text + image) using IEnumerable + [McpServerTool] + public static IEnumerable MixedContentTool() + { + yield return new TextContent("Description of the image"); + yield return new DataContent(Encoding.UTF8.GetBytes("fake-image-data"), "image/png"); + } + + // Tool that returns multiple images using IEnumerable + [McpServerTool] + public static IEnumerable MultipleImagesTool() + { + yield return new DataContent(Encoding.UTF8.GetBytes("image1"), "image/png"); + yield return new DataContent(Encoding.UTF8.GetBytes("image2"), "image/jpeg"); + } + + // Tool that returns audio + text using IEnumerable + [McpServerTool] + public static IEnumerable AudioWithTextTool() + { + yield return new TextContent("Audio transcription"); + yield return new DataContent(Encoding.UTF8.GetBytes("fake-audio"), "audio/wav"); + } + + // Tool that returns embedded resource + text using IEnumerable + [McpServerTool] + public static IEnumerable ResourceWithTextTool() + { + yield return new TextContentBlock { Text = "Resource description" }; + yield return new EmbeddedResourceBlock { Resource = new TextResourceContents { Uri = "file://test.txt", Text = "File content", MimeType = "text/plain" } }; + } + + // Tool that returns all content types using IEnumerable + [McpServerTool] + public static IEnumerable AllContentTypesTool() + { + yield return new TextContent("Mixed content"); + yield return new DataContent(Encoding.UTF8.GetBytes("image"), "image/png"); + yield return new DataContent(Encoding.UTF8.GetBytes("audio"), "audio/mp3"); + yield return new DataContent(Encoding.UTF8.GetBytes("blob"), "application/octet-stream"); + } + + // Tool that returns content that can't be converted to AIContent (ResourceLinkBlock) + [McpServerTool] + public static ResourceLinkBlock ResourceLinkTool() => + new ResourceLinkBlock { Uri = "file://test.txt", Name = "test.txt" }; + + // Tool that returns mixed content where some can't be converted (ResourceLinkBlock + Image) + [McpServerTool] + public static IEnumerable MixedWithNonConvertibleTool() + { + yield return new ImageContentBlock { Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("image-data")), MimeType = "image/png" }; + yield return new ResourceLinkBlock { Uri = "file://linked.txt", Name = "linked.txt" }; + } + + // Tool that returns CallToolResult with IsError = true + [McpServerTool] + public static CallToolResult ErrorTool() => + new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "Error message" }] + }; + + // Tool that returns CallToolResult with StructuredContent + [McpServerTool] + public static CallToolResult StructuredContentTool() => + new CallToolResult + { + Content = [new TextContentBlock { Text = "Regular content" }], + StructuredContent = JsonNode.Parse("{\"key\":\"value\"}") + }; + + // Tool that returns CallToolResult with Meta + [McpServerTool] + public static CallToolResult MetaTool() => + new CallToolResult + { + Content = [new TextContentBlock { Text = "Content with meta" }], + Meta = new JsonObject { ["customKey"] = "customValue" } + }; + + // Tool that returns CallToolResult with multiple properties (IsError + Meta) + [McpServerTool] + public static CallToolResult ErrorWithMetaTool() => + new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "Error with metadata" }], + Meta = new JsonObject { ["errorCode"] = 500 } + }; + + // Tool that returns binary resource (non-text) + [McpServerTool] + public static EmbeddedResourceBlock BinaryResourceTool() => + new EmbeddedResourceBlock + { + Resource = new BlobResourceContents + { + Uri = "data://blob", + Blob = Convert.ToBase64String(Encoding.UTF8.GetBytes("binary-data")), + MimeType = "application/octet-stream" + } + }; } [Fact] - public async Task Constructor_WithValidParameters_CreatesInstance() + public async Task TextOnlyTool_ReturnsSingleTextContent() { + // Arrange await using McpClient client = await CreateMcpClientForServer(); - - // Get a tool definition from the server var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var originalTool = tools.First(t => t.Name == "echo"); - var toolDefinition = originalTool.ProtocolTool; + var tool = tools.Single(t => t.Name == "text_only_tool"); - // Create a new McpClientTool using the public constructor - var newTool = new McpClientTool(client, toolDefinition); + // Act + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(newTool); - Assert.Equal("echo", newTool.Name); - Assert.Equal("Echoes back the message", newTool.Description); - Assert.Same(toolDefinition, newTool.ProtocolTool); + // Assert - single text content should return TextContent + var textContent = Assert.IsType(result); + Assert.Equal("Simple text result", textContent.Text); } [Fact] - public async Task Constructor_WithNullClient_ThrowsArgumentNullException() + public async Task StringTool_ReturnsSingleTextContent() { - var toolDefinition = new Tool - { - Name = "test", - Description = "Test tool" - }; + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "string_tool"); - Assert.Throws("client", () => new McpClientTool(null!, toolDefinition)); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + var textContent = Assert.IsType(result); + Assert.Equal("Simple string result", textContent.Text); } [Fact] - public async Task Constructor_WithNullTool_ThrowsArgumentNullException() + public async Task ImageTool_ReturnsSingleDataContent() { await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "image_tool"); - Assert.Throws("tool", () => new McpClientTool(client, null!)); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + var dataContent = Assert.IsType(result); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal("fake-image-data", Encoding.UTF8.GetString(dataContent.Data.ToArray())); } [Fact] - public async Task Constructor_WithNullSerializerOptions_UsesDefaultOptions() + public async Task AudioTool_ReturnsSingleDataContent() { await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "audio_tool"); - var toolDefinition = new Tool - { - Name = "test", - Description = "Test tool" - }; + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + var dataContent = Assert.IsType(result); + Assert.Equal("audio/mp3", dataContent.MediaType); + Assert.Equal("fake-audio-data", Encoding.UTF8.GetString(dataContent.Data.ToArray())); + } + + [Fact] + public async Task EmbeddedResourceTool_ReturnsSingleTextContent() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "embedded_resource_tool"); - var tool = new McpClientTool(client, toolDefinition, serializerOptions: null); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(tool.JsonSerializerOptions); - Assert.Same(McpJsonUtilities.DefaultOptions, tool.JsonSerializerOptions); + var textContent = Assert.IsType(result); + Assert.Equal("Resource text content", textContent.Text); } [Fact] - public async Task Constructor_WithCustomSerializerOptions_UsesProvidedOptions() + public async Task MixedContentTool_ReturnsAIContentArray() { await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "mixed_content_tool"); - var toolDefinition = new Tool - { - Name = "test", - Description = "Test tool" - }; + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + var aiContents = Assert.IsType(result); + Assert.Equal(2, aiContents.Length); + + var textContent = Assert.IsType(aiContents[0]); + Assert.Equal("Description of the image", textContent.Text); + + var dataContent = Assert.IsType(aiContents[1]); + Assert.Equal("image/png", dataContent.MediaType); + } - var customOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + [Fact] + public async Task MultipleImagesTool_ReturnsAIContentArray() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "multiple_images_tool"); - var tool = new McpClientTool(client, toolDefinition, customOptions); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(tool.JsonSerializerOptions); - Assert.Same(customOptions, tool.JsonSerializerOptions); + var aiContents = Assert.IsType(result); + Assert.Equal(2, aiContents.Length); + + var dataContent0 = Assert.IsType(aiContents[0]); + Assert.Equal("image/png", dataContent0.MediaType); + Assert.Equal("image1", Encoding.UTF8.GetString(dataContent0.Data.ToArray())); + + var dataContent1 = Assert.IsType(aiContents[1]); + Assert.Equal("image/jpeg", dataContent1.MediaType); + Assert.Equal("image2", Encoding.UTF8.GetString(dataContent1.Data.ToArray())); } [Fact] - public async Task ReuseToolDefinition_AcrossDifferentClients_InvokesSuccessfully() + public async Task AudioWithTextTool_ReturnsAIContentArray() { - // Create first client and get tool definition - Tool toolDefinition; - { - await using McpClient client1 = await CreateMcpClientForServer(); - var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var echoTool = tools.First(t => t.Name == "echo"); - toolDefinition = echoTool.ProtocolTool; - } + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "audio_with_text_tool"); - // Create second client (simulating reconnect) - await using McpClient client2 = await CreateMcpClientForServer(); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - // Create new McpClientTool with cached tool definition and new client - var reusedTool = new McpClientTool(client2, toolDefinition); + var aiContents = Assert.IsType(result); + Assert.Equal(2, aiContents.Length); + + var textContent = Assert.IsType(aiContents[0]); + Assert.Equal("Audio transcription", textContent.Text); + + var dataContent = Assert.IsType(aiContents[1]); + Assert.Equal("audio/wav", dataContent.MediaType); + } - // Invoke the tool using the new client - var result = await reusedTool.CallAsync( - new Dictionary { ["message"] = "Hello from reused tool" }, - cancellationToken: TestContext.Current.CancellationToken); + [Fact] + public async Task ResourceWithTextTool_ReturnsAIContentArray() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "resource_with_text_tool"); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - Assert.NotNull(result.Content); - var textContent = result.Content.FirstOrDefault() as TextContentBlock; - Assert.NotNull(textContent); - Assert.Equal("Echo: Hello from reused tool", textContent.Text); + var aiContents = Assert.IsType(result); + Assert.Equal(2, aiContents.Length); + + var textContent0 = Assert.IsType(aiContents[0]); + Assert.Equal("Resource description", textContent0.Text); + + var textContent1 = Assert.IsType(aiContents[1]); + Assert.Equal("File content", textContent1.Text); } [Fact] - public async Task ReuseToolDefinition_WithComplexParameters_InvokesSuccessfully() + public async Task AllContentTypesTool_ReturnsAIContentArray() { - // Create first client and get tool definition - Tool toolDefinition; - { - await using McpClient client1 = await CreateMcpClientForServer(); - var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var addTool = tools.First(t => t.Name == "add"); - toolDefinition = addTool.ProtocolTool; - } + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "all_content_types_tool"); - // Create second client - await using McpClient client2 = await CreateMcpClientForServer(); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - // Create new McpClientTool with cached tool definition - var reusedTool = new McpClientTool(client2, toolDefinition); + var aiContents = Assert.IsType(result); + Assert.Equal(4, aiContents.Length); + + var textContent = Assert.IsType(aiContents[0]); + Assert.Equal("Mixed content", textContent.Text); + + var dataContent1 = Assert.IsType(aiContents[1]); + Assert.Equal("image/png", dataContent1.MediaType); + + var dataContent2 = Assert.IsType(aiContents[2]); + Assert.Equal("audio/mp3", dataContent2.MediaType); + + var dataContent3 = Assert.IsType(aiContents[3]); + Assert.Equal("application/octet-stream", dataContent3.MediaType); + } + + [Fact] + public async Task SingleAIContent_PreservesRawRepresentation() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "image_tool"); - // Invoke the tool with integer parameters - var result = await reusedTool.CallAsync( - new Dictionary { ["a"] = 5, ["b"] = 7 }, - cancellationToken: TestContext.Current.CancellationToken); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - Assert.NotNull(result.Content); - var textContent = result.Content.FirstOrDefault() as TextContentBlock; - Assert.NotNull(textContent); - Assert.Equal("12", textContent.Text); + var dataContent = Assert.IsType(result); + Assert.NotNull(dataContent.RawRepresentation); + var imageBlock = Assert.IsType(dataContent.RawRepresentation); + Assert.Equal("image/png", imageBlock.MimeType); } [Fact] - public async Task ReuseToolDefinition_PreservesToolMetadata() + public async Task ResourceLinkTool_ReturnsJsonElement() { await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "resource_link_tool"); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.IsType(result); + var jsonElement = (JsonElement)result!; + Assert.True(jsonElement.TryGetProperty("content", out var contentArray)); + Assert.Equal(JsonValueKind.Array, contentArray.ValueKind); + Assert.Equal(1, contentArray.GetArrayLength()); + var firstContent = contentArray[0]; + Assert.True(firstContent.TryGetProperty("type", out var typeProperty)); + Assert.Equal("resource_link", typeProperty.GetString()); + } + + [Fact] + public async Task MixedWithNonConvertibleTool_ReturnsJsonElement() + { + await using McpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var originalTool = tools.First(t => t.Name == "createPerson"); - var toolDefinition = originalTool.ProtocolTool; + var tool = tools.Single(t => t.Name == "mixed_with_non_convertible_tool"); - // Create new McpClientTool with cached tool definition - var reusedTool = new McpClientTool(client, toolDefinition); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - // Verify metadata is preserved - Assert.Equal(originalTool.Name, reusedTool.Name); - Assert.Equal(originalTool.Description, reusedTool.Description); - Assert.Equal(originalTool.ProtocolTool.Name, reusedTool.ProtocolTool.Name); - Assert.Equal(originalTool.ProtocolTool.Description, reusedTool.ProtocolTool.Description); + var jsonElement = Assert.IsType(result); + Assert.True(jsonElement.TryGetProperty("content", out var contentArray)); + Assert.Equal(JsonValueKind.Array, contentArray.ValueKind); + Assert.Equal(2, contentArray.GetArrayLength()); + + var firstContent = contentArray[0]; + Assert.True(firstContent.TryGetProperty("type", out var type1)); + Assert.Equal("image", type1.GetString()); - // Verify JSON schema is preserved - Assert.Equal( - JsonSerializer.Serialize(originalTool.JsonSchema, McpJsonUtilities.DefaultOptions), - JsonSerializer.Serialize(reusedTool.JsonSchema, McpJsonUtilities.DefaultOptions)); + var secondContent = contentArray[1]; + Assert.True(secondContent.TryGetProperty("type", out var type2)); + Assert.Equal("resource_link", type2.GetString()); } [Fact] - public async Task ManuallyConstructedTool_CanBeInvoked() + public async Task ErrorTool_ReturnsJsonElement() { await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "error_tool"); - // Manually construct a Tool object matching the server's tool - var manualTool = new Tool - { - Name = "echo", - Description = "Echoes back the message", - InputSchema = JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "message": { "type": "string" } - } - } - """).RootElement.Clone() - }; - - // Create McpClientTool with manually constructed tool - var clientTool = new McpClientTool(client, manualTool); - - // Invoke the tool - var result = await clientTool.CallAsync( - new Dictionary { ["message"] = "Test message" }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - Assert.NotNull(result.Content); - var textContent = result.Content.FirstOrDefault() as TextContentBlock; - Assert.NotNull(textContent); - Assert.Equal("Echo: Test message", textContent.Text); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.IsType(result); + var jsonElement = (JsonElement)result!; + Assert.True(jsonElement.TryGetProperty("isError", out var isError)); + Assert.True(isError.GetBoolean()); + Assert.True(jsonElement.TryGetProperty("content", out var content)); + Assert.Equal(JsonValueKind.Array, content.ValueKind); } [Fact] - public async Task ReuseToolDefinition_WithInvokeAsync_WorksCorrectly() + public async Task StructuredContentTool_ReturnsJsonElement() { - Tool toolDefinition; - { - await using McpClient client1 = await CreateMcpClientForServer(); - var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var addTool = tools.First(t => t.Name == "add"); - toolDefinition = addTool.ProtocolTool; - } + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "structured_content_tool"); - await using McpClient client2 = await CreateMcpClientForServer(); - var reusedTool = new McpClientTool(client2, toolDefinition); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - // Use AIFunction.InvokeAsync (inherited method) - var result = await reusedTool.InvokeAsync( - new() { ["a"] = 10, ["b"] = 20 }, - TestContext.Current.CancellationToken); + var jsonElement = Assert.IsType(result); + Assert.True(jsonElement.TryGetProperty("structuredContent", out var structuredContent)); + Assert.True(structuredContent.TryGetProperty("key", out var key)); + Assert.Equal("value", key.GetString()); + Assert.True(jsonElement.TryGetProperty("content", out var content)); + Assert.Equal(JsonValueKind.Array, content.ValueKind); + } + + [Fact] + public async Task MetaTool_ReturnsJsonElement() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "meta_tool"); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - - // InvokeAsync returns a JsonElement containing the serialized CallToolResult var jsonElement = Assert.IsType(result); - var callToolResult = JsonSerializer.Deserialize(jsonElement, McpJsonUtilities.DefaultOptions); - - Assert.NotNull(callToolResult); - Assert.NotNull(callToolResult.Content); - var textContent = callToolResult.Content.FirstOrDefault() as TextContentBlock; - Assert.NotNull(textContent); - Assert.Equal("30", textContent.Text); + Assert.True(jsonElement.TryGetProperty("_meta", out var meta)); + Assert.True(meta.TryGetProperty("customKey", out var customKey)); + Assert.Equal("customValue", customKey.GetString()); + Assert.True(jsonElement.TryGetProperty("content", out var content)); + Assert.Equal(JsonValueKind.Array, content.ValueKind); } [Fact] - public async Task Constructor_WithToolWithoutDescription_UsesEmptyDescription() + public async Task ErrorWithMetaTool_ReturnsJsonElement() { await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "error_with_meta_tool"); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.IsType(result); + var jsonElement = (JsonElement)result!; + Assert.True(jsonElement.TryGetProperty("isError", out var isError)); + Assert.True(isError.GetBoolean()); + Assert.True(jsonElement.TryGetProperty("_meta", out var meta)); + Assert.True(meta.TryGetProperty("errorCode", out var errorCode)); + Assert.Equal(500, errorCode.GetInt32()); + Assert.True(jsonElement.TryGetProperty("content", out var content)); + Assert.Equal(JsonValueKind.Array, content.ValueKind); + } - var toolWithoutDescription = new Tool - { - Name = "noDescTool", - Description = null - }; + [Fact] + public async Task BinaryResourceTool_ReturnsSingleDataContent() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "binary_resource_tool"); - var clientTool = new McpClientTool(client, toolWithoutDescription); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("noDescTool", clientTool.Name); - Assert.Equal(string.Empty, clientTool.Description); + var dataContent = Assert.IsType(result); + Assert.Equal("application/octet-stream", dataContent.MediaType); + Assert.Equal("binary-data", Encoding.UTF8.GetString(dataContent.Data.ToArray())); } [Fact] - public async Task ReuseToolDefinition_MultipleClients_AllWorkIndependently() + public async Task MultipleAIContent_PreservesRawRepresentation() { - // Get tool definition from first client - Tool toolDefinition; - { - await using McpClient client1 = await CreateMcpClientForServer(); - var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var echoTool = tools.First(t => t.Name == "echo"); - toolDefinition = echoTool.ProtocolTool; - } + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "mixed_content_tool"); - // Create and invoke on second client - string text2; - { - await using McpClient client2 = await CreateMcpClientForServer(); - var tool2 = new McpClientTool(client2, toolDefinition); - var result2 = await tool2.CallAsync( - new Dictionary { ["message"] = "From client 2" }, - cancellationToken: TestContext.Current.CancellationToken); - text2 = (result2.Content.FirstOrDefault() as TextContentBlock)?.Text ?? string.Empty; - } + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - // Create and invoke on third client - string text3; - { - await using McpClient client3 = await CreateMcpClientForServer(); - var tool3 = new McpClientTool(client3, toolDefinition); - var result3 = await tool3.CallAsync( - new Dictionary { ["message"] = "From client 3" }, - cancellationToken: TestContext.Current.CancellationToken); - text3 = (result3.Content.FirstOrDefault() as TextContentBlock)?.Text ?? string.Empty; - } + var aiContents = Assert.IsType(result); + Assert.Equal(2, aiContents.Length); + + var textContent = Assert.IsType(aiContents[0]); + Assert.NotNull(textContent.RawRepresentation); + Assert.IsType(textContent.RawRepresentation); - // Verify both worked - Assert.Equal("Echo: From client 2", text2); - Assert.Equal("Echo: From client 3", text3); + var dataContent = Assert.IsType(aiContents[1]); + Assert.NotNull(dataContent.RawRepresentation); + Assert.IsType(dataContent.RawRepresentation); } }