Add public constructors to McpClient types for reusing cached definitions (#938)

* Initial plan

* Add public constructor to McpClientTool for reusing tool definitions

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix McpClientTool constructor per review feedback

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Add public constructors for McpClientPrompt, McpClientResource, and McpClientResourceTemplate

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
This commit is contained in:
Copilot 2025-11-06 13:59:17 -05:00 • committed by GitHub
commit b90f17bccb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 785 additions and 3 deletions

View file

@ -22,8 +22,30 @@ public sealed class McpClientPrompt
{
private readonly McpClient _client;
internal McpClientPrompt(McpClient client, Prompt prompt)
/// <summary>
/// Initializes a new instance of the <see cref="McpClientPrompt"/> class.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the prompt.</param>
/// <param name="prompt">The protocol <see cref="Prompt"/> definition describing the prompt's metadata.</param>
/// <remarks>
/// <para>
/// This constructor enables reusing cached prompt definitions across different <see cref="McpClient"/> instances
/// without needing to call <see cref="McpClient.ListPromptsAsync"/> on every reconnect. This is particularly useful
/// in scenarios where prompt definitions are stable and network round-trips should be minimized.
/// </para>
/// <para>
/// The provided <paramref name="prompt"/> must represent a prompt that is actually available on the server
/// associated with the <paramref name="client"/>. Attempting to invoke a prompt that doesn't exist on the
/// server will result in an <see cref="McpException"/>.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="prompt"/> is <see langword="null"/>.</exception>
public McpClientPrompt(McpClient client, Prompt prompt)
{
Throw.IfNull(client);
Throw.IfNull(prompt);
_client = client;
ProtocolPrompt = prompt;
}

View file

@ -17,8 +17,30 @@ public sealed class McpClientResource
{
private readonly McpClient _client;
internal McpClientResource(McpClient client, Resource resource)
/// <summary>
/// Initializes a new instance of the <see cref="McpClientResource"/> class.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> instance to use for reading the resource.</param>
/// <param name="resource">The protocol <see cref="Resource"/> definition describing the resource's metadata.</param>
/// <remarks>
/// <para>
/// This constructor enables reusing cached resource definitions across different <see cref="McpClient"/> instances
/// without needing to call <see cref="McpClient.ListResourcesAsync"/> on every reconnect. This is particularly useful
/// in scenarios where resource definitions are stable and network round-trips should be minimized.
/// </para>
/// <para>
/// The provided <paramref name="resource"/> must represent a resource that is actually available on the server
/// associated with the <paramref name="client"/>. Attempting to read a resource that doesn't exist on the
/// server will result in an <see cref="McpException"/>.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="resource"/> is <see langword="null"/>.</exception>
public McpClientResource(McpClient client, Resource resource)
{
Throw.IfNull(client);
Throw.IfNull(resource);
_client = client;
ProtocolResource = resource;
}

View file

@ -17,8 +17,30 @@ public sealed class McpClientResourceTemplate
{
private readonly McpClient _client;
internal McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate)
/// <summary>
/// Initializes a new instance of the <see cref="McpClientResourceTemplate"/> class.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> instance to use for reading the resource template.</param>
/// <param name="resourceTemplate">The protocol <see cref="ResourceTemplate"/> definition describing the resource template's metadata.</param>
/// <remarks>
/// <para>
/// This constructor enables reusing cached resource template definitions across different <see cref="McpClient"/> instances
/// without needing to call <see cref="McpClient.ListResourceTemplatesAsync"/> on every reconnect. This is particularly useful
/// in scenarios where resource template definitions are stable and network round-trips should be minimized.
/// </para>
/// <para>
/// The provided <paramref name="resourceTemplate"/> must represent a resource template that is actually available on the server
/// associated with the <paramref name="client"/>. Attempting to read a resource template that doesn't exist on the
/// server will result in an <see cref="McpException"/>.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="resourceTemplate"/> is <see langword="null"/>.</exception>
public McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate)
{
Throw.IfNull(client);
Throw.IfNull(resourceTemplate);
_client = client;
ProtocolResourceTemplate = resourceTemplate;
}

View file

@ -37,6 +37,45 @@ public sealed class McpClientTool : AIFunction
private readonly string _description;
private readonly IProgress<ProgressNotificationValue>? _progress;
/// <summary>
/// Initializes a new instance of the <see cref="McpClientTool"/> class.
/// </summary>
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the tool.</param>
/// <param name="tool">The protocol <see cref="Tool"/> definition describing the tool's metadata and schema.</param>
/// <param name="serializerOptions">
/// The JSON serialization options governing argument serialization. If <see langword="null"/>,
/// <see cref="McpJsonUtilities.DefaultOptions"/> will be used.
/// </param>
/// <remarks>
/// <para>
/// This constructor enables reusing cached tool definitions across different <see cref="McpClient"/> instances
/// without needing to call <see cref="McpClient.ListToolsAsync"/> on every reconnect. This is particularly useful
/// in scenarios where tool definitions are stable and network round-trips should be minimized.
/// </para>
/// <para>
/// The provided <paramref name="tool"/> must represent a tool that is actually available on the server
/// associated with the <paramref name="client"/>. Attempting to invoke a tool that doesn't exist on the
/// server will result in an <see cref="McpException"/>.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
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,

View file

@ -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<GreetingPrompts>();
}
[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<ArgumentNullException>("client", () => new McpClientPrompt(null!, promptDefinition));
}
[Fact]
public async Task Constructor_WithNullPrompt_ThrowsArgumentNullException()
{
await using McpClient client = await CreateMcpClientForServer();
Assert.Throws<ArgumentNullException>("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<string, object?> { ["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<string, object?> { ["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);
}
}

View file

@ -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<FileTemplateResources>();
}
[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<ArgumentNullException>("client", () => new McpClientResourceTemplate(null!, templateDefinition));
}
[Fact]
public async Task Constructor_WithNullResourceTemplate_ThrowsArgumentNullException()
{
await using McpClient client = await CreateMcpClientForServer();
Assert.Throws<ArgumentNullException>("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);
}
}

View file

@ -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<SampleResources>();
}
[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<ArgumentNullException>("client", () => new McpClientResource(null!, resourceDefinition));
}
[Fact]
public async Task Constructor_WithNullResource_ThrowsArgumentNullException()
{
await using McpClient client = await CreateMcpClientForServer();
Assert.Throws<ArgumentNullException>("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);
}
}

View file

@ -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<ArgumentNullException>("client", () => new McpClientTool(null!, toolDefinition));
}
[Fact]
public async Task Constructor_WithNullTool_ThrowsArgumentNullException()
{
await using McpClient client = await CreateMcpClientForServer();
Assert.Throws<ArgumentNullException>("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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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<JsonElement>(result);
var callToolResult = JsonSerializer.Deserialize<CallToolResult>(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<string, object?> { ["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<string, object?> { ["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);
}
}