diff --git a/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs b/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs index 5a618242f..c84e0787b 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs @@ -22,8 +22,30 @@ public sealed class McpClientPrompt { private readonly McpClient _client; - internal McpClientPrompt(McpClient client, Prompt prompt) + /// + /// Initializes a new instance of the class. + /// + /// The instance to use for invoking the prompt. + /// The protocol definition describing the prompt's metadata. + /// + /// + /// This constructor enables reusing cached prompt definitions across different instances + /// without needing to call on every reconnect. This is particularly useful + /// in scenarios where prompt definitions are stable and network round-trips should be minimized. + /// + /// + /// The provided must represent a prompt that is actually available on the server + /// associated with the . Attempting to invoke a prompt that doesn't exist on the + /// server will result in an . + /// + /// + /// is . + /// is . + public McpClientPrompt(McpClient client, Prompt prompt) { + Throw.IfNull(client); + Throw.IfNull(prompt); + _client = client; ProtocolPrompt = prompt; } diff --git a/src/ModelContextProtocol.Core/Client/McpClientResource.cs b/src/ModelContextProtocol.Core/Client/McpClientResource.cs index 19f11bfdf..504eca088 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientResource.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientResource.cs @@ -17,8 +17,30 @@ public sealed class McpClientResource { private readonly McpClient _client; - internal McpClientResource(McpClient client, Resource resource) + /// + /// Initializes a new instance of the class. + /// + /// The instance to use for reading the resource. + /// The protocol definition describing the resource's metadata. + /// + /// + /// This constructor enables reusing cached resource definitions across different instances + /// without needing to call on every reconnect. This is particularly useful + /// in scenarios where resource definitions are stable and network round-trips should be minimized. + /// + /// + /// The provided must represent a resource that is actually available on the server + /// associated with the . Attempting to read a resource that doesn't exist on the + /// server will result in an . + /// + /// + /// is . + /// is . + public McpClientResource(McpClient client, Resource resource) { + Throw.IfNull(client); + Throw.IfNull(resource); + _client = client; ProtocolResource = resource; } diff --git a/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs b/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs index 033f7cf00..134cef570 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs @@ -17,8 +17,30 @@ public sealed class McpClientResourceTemplate { private readonly McpClient _client; - internal McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate) + /// + /// Initializes a new instance of the class. + /// + /// The instance to use for reading the resource template. + /// The protocol definition describing the resource template's metadata. + /// + /// + /// This constructor enables reusing cached resource template definitions across different instances + /// without needing to call on every reconnect. This is particularly useful + /// in scenarios where resource template definitions are stable and network round-trips should be minimized. + /// + /// + /// The provided must represent a resource template that is actually available on the server + /// associated with the . Attempting to read a resource template that doesn't exist on the + /// server will result in an . + /// + /// + /// is . + /// is . + public McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate) { + Throw.IfNull(client); + Throw.IfNull(resourceTemplate); + _client = client; ProtocolResourceTemplate = resourceTemplate; } diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index c7af513ef..60c2ee89d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -37,6 +37,45 @@ public sealed class McpClientTool : AIFunction private readonly string _description; private readonly IProgress? _progress; + /// + /// Initializes a new instance of the class. + /// + /// The instance to use for invoking the tool. + /// The protocol definition describing the tool's metadata and schema. + /// + /// The JSON serialization options governing argument serialization. If , + /// will be used. + /// + /// + /// + /// This constructor enables reusing cached tool definitions across different instances + /// without needing to call on every reconnect. This is particularly useful + /// in scenarios where tool definitions are stable and network round-trips should be minimized. + /// + /// + /// The provided must represent a tool that is actually available on the server + /// associated with the . Attempting to invoke a tool that doesn't exist on the + /// server will result in an . + /// + /// + /// is . + /// is . + public McpClientTool( + McpClient client, + Tool tool, + JsonSerializerOptions? serializerOptions = null) + { + Throw.IfNull(client); + Throw.IfNull(tool); + + _client = client; + ProtocolTool = tool; + JsonSerializerOptions = serializerOptions ?? McpJsonUtilities.DefaultOptions; + _name = tool.Name; + _description = tool.Description ?? string.Empty; + _progress = null; + } + internal McpClientTool( McpClient client, Tool tool, diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientPromptTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientPromptTests.cs new file mode 100644 index 000000000..9bad26a6b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientPromptTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientPromptTests : ClientServerTestBase +{ + public McpClientPromptTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithPrompts(); + } + + [McpServerPromptType] + private sealed class GreetingPrompts + { + [McpServerPrompt, Description("Generates a greeting prompt")] + public static ChatMessage Greeting([Description("The name to greet")] string name) => + new(ChatRole.User, $"Hello, {name}!"); + } + + [Fact] + public async Task Constructor_WithValidParameters_CreatesInstance() + { + await using McpClient client = await CreateMcpClientForServer(); + + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + var originalPrompt = prompts.First(); + var promptDefinition = originalPrompt.ProtocolPrompt; + + var newPrompt = new McpClientPrompt(client, promptDefinition); + + Assert.NotNull(newPrompt); + Assert.Equal("greeting", newPrompt.Name); + Assert.Equal("Generates a greeting prompt", newPrompt.Description); + Assert.Same(promptDefinition, newPrompt.ProtocolPrompt); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + var promptDefinition = new Prompt + { + Name = "test", + Description = "Test prompt" + }; + + Assert.Throws("client", () => new McpClientPrompt(null!, promptDefinition)); + } + + [Fact] + public async Task Constructor_WithNullPrompt_ThrowsArgumentNullException() + { + await using McpClient client = await CreateMcpClientForServer(); + + Assert.Throws("prompt", () => new McpClientPrompt(client, null!)); + } + + [Fact] + public async Task ReusePromptDefinition_AcrossDifferentClients_InvokesSuccessfully() + { + Prompt promptDefinition; + { + await using McpClient client1 = await CreateMcpClientForServer(); + var prompts = await client1.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + var greetingPrompt = prompts.First(p => p.Name == "greeting"); + promptDefinition = greetingPrompt.ProtocolPrompt; + } + + await using McpClient client2 = await CreateMcpClientForServer(); + + var reusedPrompt = new McpClientPrompt(client2, promptDefinition); + + var result = await reusedPrompt.GetAsync( + new Dictionary { ["name"] = "World" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Messages); + var message = result.Messages.First(); + Assert.NotNull(message.Content); + var textContent = message.Content as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal("Hello, World!", textContent.Text); + } + + [Fact] + public async Task ReusePromptDefinition_PreservesPromptMetadata() + { + await using McpClient client = await CreateMcpClientForServer(); + + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + var originalPrompt = prompts.First(); + var promptDefinition = originalPrompt.ProtocolPrompt; + + var reusedPrompt = new McpClientPrompt(client, promptDefinition); + + Assert.Equal(originalPrompt.Name, reusedPrompt.Name); + Assert.Equal(originalPrompt.Description, reusedPrompt.Description); + Assert.Equal(originalPrompt.ProtocolPrompt.Name, reusedPrompt.ProtocolPrompt.Name); + Assert.Equal(originalPrompt.ProtocolPrompt.Description, reusedPrompt.ProtocolPrompt.Description); + } + + [Fact] + public async Task ManuallyConstructedPrompt_CanBeInvoked() + { + await using McpClient client = await CreateMcpClientForServer(); + + var manualPrompt = new Prompt + { + Name = "greeting", + Description = "Generates a greeting prompt" + }; + + var clientPrompt = new McpClientPrompt(client, manualPrompt); + + var result = await clientPrompt.GetAsync( + new Dictionary { ["name"] = "Test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Messages); + var message = result.Messages.First(); + Assert.NotNull(message.Content); + var textContent = message.Content as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal("Hello, Test!", textContent.Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateConstructorTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateConstructorTests.cs new file mode 100644 index 000000000..c47ad69ad --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateConstructorTests.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientResourceTemplateConstructorTests : ClientServerTestBase +{ + public McpClientResourceTemplateConstructorTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithResources(); + } + + [McpServerResourceType] + private sealed class FileTemplateResources + { + [McpServerResource, Description("A file template")] + public static string FileTemplate([Description("The file path")] string path) => $"Content for {path}"; + } + + [Fact] + public async Task Constructor_WithValidParameters_CreatesInstance() + { + await using McpClient client = await CreateMcpClientForServer(); + + var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); + var originalTemplate = templates.First(); + var templateDefinition = originalTemplate.ProtocolResourceTemplate; + + var newTemplate = new McpClientResourceTemplate(client, templateDefinition); + + Assert.NotNull(newTemplate); + Assert.Equal("file_template", newTemplate.Name); + Assert.Equal("A file template", newTemplate.Description); + Assert.Same(templateDefinition, newTemplate.ProtocolResourceTemplate); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + var templateDefinition = new ResourceTemplate + { + UriTemplate = "file:///{path}", + Name = "test", + Description = "Test template" + }; + + Assert.Throws("client", () => new McpClientResourceTemplate(null!, templateDefinition)); + } + + [Fact] + public async Task Constructor_WithNullResourceTemplate_ThrowsArgumentNullException() + { + await using McpClient client = await CreateMcpClientForServer(); + + Assert.Throws("resourceTemplate", () => new McpClientResourceTemplate(client, null!)); + } + + [Fact] + public async Task ReuseResourceTemplateDefinition_PreservesTemplateMetadata() + { + await using McpClient client = await CreateMcpClientForServer(); + + var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); + var originalTemplate = templates.First(); + var templateDefinition = originalTemplate.ProtocolResourceTemplate; + + var reusedTemplate = new McpClientResourceTemplate(client, templateDefinition); + + Assert.Equal(originalTemplate.Name, reusedTemplate.Name); + Assert.Equal(originalTemplate.Description, reusedTemplate.Description); + Assert.Equal(originalTemplate.UriTemplate, reusedTemplate.UriTemplate); + Assert.Equal(originalTemplate.ProtocolResourceTemplate.Name, reusedTemplate.ProtocolResourceTemplate.Name); + Assert.Equal(originalTemplate.ProtocolResourceTemplate.Description, reusedTemplate.ProtocolResourceTemplate.Description); + } + + [Fact] + public async Task ManuallyConstructedResourceTemplate_CreatesValidInstance() + { + await using McpClient client = await CreateMcpClientForServer(); + + var manualTemplate = new ResourceTemplate + { + UriTemplate = "file:///{path}", + Name = "file_template", + Description = "A file template" + }; + + var clientTemplate = new McpClientResourceTemplate(client, manualTemplate); + + Assert.NotNull(clientTemplate); + Assert.Equal("file_template", clientTemplate.Name); + Assert.Equal("A file template", clientTemplate.Description); + Assert.Equal("file:///{path}", clientTemplate.UriTemplate); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTests.cs new file mode 100644 index 000000000..a7b7c739e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientResourceTests : ClientServerTestBase +{ + public McpClientResourceTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithResources(); + } + + [McpServerResourceType] + private sealed class SampleResources + { + [McpServerResource, Description("A sample resource")] + public static string Sample() => "Sample content"; + } + + [Fact] + public async Task Constructor_WithValidParameters_CreatesInstance() + { + await using McpClient client = await CreateMcpClientForServer(); + + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + var originalResource = resources.First(); + var resourceDefinition = originalResource.ProtocolResource; + + var newResource = new McpClientResource(client, resourceDefinition); + + Assert.NotNull(newResource); + Assert.Equal("sample", newResource.Name); + Assert.Equal("A sample resource", newResource.Description); + Assert.Same(resourceDefinition, newResource.ProtocolResource); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + var resourceDefinition = new Resource + { + Uri = "file:///test.txt", + Name = "test", + Description = "Test resource" + }; + + Assert.Throws("client", () => new McpClientResource(null!, resourceDefinition)); + } + + [Fact] + public async Task Constructor_WithNullResource_ThrowsArgumentNullException() + { + await using McpClient client = await CreateMcpClientForServer(); + + Assert.Throws("resource", () => new McpClientResource(client, null!)); + } + + [Fact] + public async Task ReuseResourceDefinition_AcrossDifferentClients_ReadsSuccessfully() + { + Resource resourceDefinition; + { + await using McpClient client1 = await CreateMcpClientForServer(); + var resources = await client1.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + var sampleResource = resources.First(r => r.Name == "sample"); + resourceDefinition = sampleResource.ProtocolResource; + } + + await using McpClient client2 = await CreateMcpClientForServer(); + + var reusedResource = new McpClientResource(client2, resourceDefinition); + + var result = await reusedResource.ReadAsync( + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Contents); + var content = result.Contents.FirstOrDefault() as TextResourceContents; + Assert.NotNull(content); + Assert.Equal("Sample content", content.Text); + } + + [Fact] + public async Task ReuseResourceDefinition_PreservesResourceMetadata() + { + await using McpClient client = await CreateMcpClientForServer(); + + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + var originalResource = resources.First(); + var resourceDefinition = originalResource.ProtocolResource; + + var reusedResource = new McpClientResource(client, resourceDefinition); + + Assert.Equal(originalResource.Name, reusedResource.Name); + Assert.Equal(originalResource.Description, reusedResource.Description); + Assert.Equal(originalResource.Uri, reusedResource.Uri); + Assert.Equal(originalResource.ProtocolResource.Name, reusedResource.ProtocolResource.Name); + Assert.Equal(originalResource.ProtocolResource.Description, reusedResource.ProtocolResource.Description); + } + + [Fact] + public async Task ManuallyConstructedResource_CreatesValidInstance() + { + await using McpClient client = await CreateMcpClientForServer(); + + var manualResource = new Resource + { + Uri = "file:///sample.txt", + Name = "sample", + Description = "A sample resource" + }; + + var clientResource = new McpClientResource(client, manualResource); + + Assert.NotNull(clientResource); + Assert.Equal("sample", clientResource.Name); + Assert.Equal("A sample resource", clientResource.Description); + Assert.Equal("file:///sample.txt", clientResource.Uri); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs new file mode 100644 index 000000000..3ba569883 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -0,0 +1,309 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientToolTests : ClientServerTestBase +{ + public McpClientToolTests(ITestOutputHelper outputHelper) + : base(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" })]); + } + + [Fact] + public async Task Constructor_WithValidParameters_CreatesInstance() + { + 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; + + // Create a new McpClientTool using the public constructor + var newTool = new McpClientTool(client, toolDefinition); + + Assert.NotNull(newTool); + Assert.Equal("echo", newTool.Name); + Assert.Equal("Echoes back the message", newTool.Description); + Assert.Same(toolDefinition, newTool.ProtocolTool); + } + + [Fact] + public async Task Constructor_WithNullClient_ThrowsArgumentNullException() + { + var toolDefinition = new Tool + { + Name = "test", + Description = "Test tool" + }; + + Assert.Throws("client", () => new McpClientTool(null!, toolDefinition)); + } + + [Fact] + public async Task Constructor_WithNullTool_ThrowsArgumentNullException() + { + await using McpClient client = await CreateMcpClientForServer(); + + Assert.Throws("tool", () => new McpClientTool(client, null!)); + } + + [Fact] + public async Task Constructor_WithNullSerializerOptions_UsesDefaultOptions() + { + await using McpClient client = await CreateMcpClientForServer(); + + var toolDefinition = new Tool + { + Name = "test", + Description = "Test tool" + }; + + var tool = new McpClientTool(client, toolDefinition, serializerOptions: null); + + Assert.NotNull(tool.JsonSerializerOptions); + Assert.Same(McpJsonUtilities.DefaultOptions, tool.JsonSerializerOptions); + } + + [Fact] + public async Task Constructor_WithCustomSerializerOptions_UsesProvidedOptions() + { + await using McpClient client = await CreateMcpClientForServer(); + + var toolDefinition = new Tool + { + Name = "test", + Description = "Test tool" + }; + + var customOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + var tool = new McpClientTool(client, toolDefinition, customOptions); + + Assert.NotNull(tool.JsonSerializerOptions); + Assert.Same(customOptions, tool.JsonSerializerOptions); + } + + [Fact] + public async Task ReuseToolDefinition_AcrossDifferentClients_InvokesSuccessfully() + { + // 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; + } + + // Create second client (simulating reconnect) + await using McpClient client2 = await CreateMcpClientForServer(); + + // Create new McpClientTool with cached tool definition and new client + var reusedTool = new McpClientTool(client2, toolDefinition); + + // Invoke the tool using the new client + var result = await reusedTool.CallAsync( + new Dictionary { ["message"] = "Hello from reused tool" }, + 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); + } + + [Fact] + public async Task ReuseToolDefinition_WithComplexParameters_InvokesSuccessfully() + { + // 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; + } + + // Create second client + await using McpClient client2 = await CreateMcpClientForServer(); + + // Create new McpClientTool with cached tool definition + var reusedTool = new McpClientTool(client2, toolDefinition); + + // Invoke the tool with integer parameters + var result = await reusedTool.CallAsync( + new Dictionary { ["a"] = 5, ["b"] = 7 }, + 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); + } + + [Fact] + public async Task ReuseToolDefinition_PreservesToolMetadata() + { + 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; + + // Create new McpClientTool with cached tool definition + var reusedTool = new McpClientTool(client, toolDefinition); + + // 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); + + // Verify JSON schema is preserved + Assert.Equal( + JsonSerializer.Serialize(originalTool.JsonSchema, McpJsonUtilities.DefaultOptions), + JsonSerializer.Serialize(reusedTool.JsonSchema, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public async Task ManuallyConstructedTool_CanBeInvoked() + { + await using McpClient client = await CreateMcpClientForServer(); + + // 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); + } + + [Fact] + public async Task ReuseToolDefinition_WithInvokeAsync_WorksCorrectly() + { + 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 client2 = await CreateMcpClientForServer(); + var reusedTool = new McpClientTool(client2, toolDefinition); + + // Use AIFunction.InvokeAsync (inherited method) + var result = await reusedTool.InvokeAsync( + new() { ["a"] = 10, ["b"] = 20 }, + 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); + } + + [Fact] + public async Task Constructor_WithToolWithoutDescription_UsesEmptyDescription() + { + await using McpClient client = await CreateMcpClientForServer(); + + var toolWithoutDescription = new Tool + { + Name = "noDescTool", + Description = null + }; + + var clientTool = new McpClientTool(client, toolWithoutDescription); + + Assert.Equal("noDescTool", clientTool.Name); + Assert.Equal(string.Empty, clientTool.Description); + } + + [Fact] + public async Task ReuseToolDefinition_MultipleClients_AllWorkIndependently() + { + // 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; + } + + // 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; + } + + // 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; + } + + // Verify both worked + Assert.Equal("Echo: From client 2", text2); + Assert.Equal("Echo: From client 3", text3); + } +}