Skip to content

Commit 44ea87c

Browse files
Copilotstephentoub
andcommitted
Add public constructor to McpClientTool for reusing tool definitions
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 350f8e1 commit 44ea87c

2 files changed

Lines changed: 362 additions & 0 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClientTool.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,57 @@ internal McpClientTool(
5353
_progress = progress;
5454
}
5555

56+
/// <summary>
57+
/// Initializes a new instance of the <see cref="McpClientTool"/> class.
58+
/// </summary>
59+
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the tool.</param>
60+
/// <param name="tool">The protocol <see cref="Tool"/> definition describing the tool's metadata and schema.</param>
61+
/// <param name="serializerOptions">
62+
/// The JSON serialization options governing argument serialization. If <see langword="null"/>,
63+
/// <see cref="McpJsonUtilities.DefaultOptions"/> will be used.
64+
/// </param>
65+
/// <remarks>
66+
/// <para>
67+
/// This constructor enables reusing cached tool definitions across different <see cref="McpClient"/> instances
68+
/// without needing to call <see cref="McpClient.ListToolsAsync"/> on every reconnect. This is particularly useful
69+
/// in scenarios where tool definitions are stable and network round-trips should be minimized.
70+
/// </para>
71+
/// <para>
72+
/// The provided <paramref name="tool"/> must represent a tool that is actually available on the server
73+
/// associated with the <paramref name="client"/>. Attempting to invoke a tool that doesn't exist on the
74+
/// server will result in an <see cref="McpException"/>.
75+
/// </para>
76+
/// </remarks>
77+
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
78+
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
79+
/// <example>
80+
/// <code>
81+
/// // Cache tool definition from first client
82+
/// var tools = await client1.ListToolsAsync();
83+
/// var toolDefinition = tools[0].ProtocolTool;
84+
///
85+
/// // Later, reuse with a different client instance
86+
/// var client2 = await McpClient.CreateAsync(transport2);
87+
/// var reusedTool = new McpClientTool(client2, toolDefinition);
88+
/// var result = await reusedTool.CallAsync(new Dictionary&lt;string, object?&gt; { ["param"] = "value" });
89+
/// </code>
90+
/// </example>
91+
public McpClientTool(
92+
McpClient client,
93+
Tool tool,
94+
JsonSerializerOptions? serializerOptions = null)
95+
{
96+
Throw.IfNull(client);
97+
Throw.IfNull(tool);
98+
99+
_client = client;
100+
ProtocolTool = tool;
101+
JsonSerializerOptions = serializerOptions ?? McpJsonUtilities.DefaultOptions;
102+
_name = tool.Name;
103+
_description = tool.Description ?? string.Empty;
104+
_progress = null;
105+
}
106+
56107
/// <summary>
57108
/// Gets the protocol <see cref="Tool"/> type for this instance.
58109
/// </summary>
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModelContextProtocol.Client;
3+
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
5+
using System.Text.Json;
6+
7+
namespace ModelContextProtocol.Tests.Client;
8+
9+
public class McpClientToolTests : ClientServerTestBase
10+
{
11+
public McpClientToolTests(ITestOutputHelper outputHelper)
12+
: base(outputHelper)
13+
{
14+
}
15+
16+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
17+
{
18+
// Add a simple echo tool for testing
19+
mcpServerBuilder.WithTools([McpServerTool.Create((string message) => $"Echo: {message}", new() { Name = "echo", Description = "Echoes back the message" })]);
20+
21+
// Add a tool with parameters for testing
22+
mcpServerBuilder.WithTools([McpServerTool.Create((int a, int b) => a + b, new() { Name = "add", Description = "Adds two numbers" })]);
23+
24+
// Add a tool that returns complex result
25+
mcpServerBuilder.WithTools([McpServerTool.Create((string name, int age) => $"Person: {name}, Age: {age}", new() { Name = "createPerson", Description = "Creates a person description" })]);
26+
}
27+
28+
[Fact]
29+
public async Task Constructor_WithValidParameters_CreatesInstance()
30+
{
31+
await using McpClient client = await CreateMcpClientForServer();
32+
33+
// Get a tool definition from the server
34+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
35+
var originalTool = tools.First(t => t.Name == "echo");
36+
var toolDefinition = originalTool.ProtocolTool;
37+
38+
// Create a new McpClientTool using the public constructor
39+
var newTool = new McpClientTool(client, toolDefinition);
40+
41+
Assert.NotNull(newTool);
42+
Assert.Equal("echo", newTool.Name);
43+
Assert.Equal("Echoes back the message", newTool.Description);
44+
Assert.Same(toolDefinition, newTool.ProtocolTool);
45+
}
46+
47+
[Fact]
48+
public async Task Constructor_WithNullClient_ThrowsArgumentNullException()
49+
{
50+
var toolDefinition = new Tool
51+
{
52+
Name = "test",
53+
Description = "Test tool"
54+
};
55+
56+
var exception = Assert.Throws<ArgumentNullException>(() => new McpClientTool(null!, toolDefinition));
57+
Assert.Equal("client", exception.ParamName);
58+
}
59+
60+
[Fact]
61+
public async Task Constructor_WithNullTool_ThrowsArgumentNullException()
62+
{
63+
await using McpClient client = await CreateMcpClientForServer();
64+
65+
var exception = Assert.Throws<ArgumentNullException>(() => new McpClientTool(client, null!));
66+
Assert.Equal("tool", exception.ParamName);
67+
}
68+
69+
[Fact]
70+
public async Task Constructor_WithNullSerializerOptions_UsesDefaultOptions()
71+
{
72+
await using McpClient client = await CreateMcpClientForServer();
73+
74+
var toolDefinition = new Tool
75+
{
76+
Name = "test",
77+
Description = "Test tool"
78+
};
79+
80+
var tool = new McpClientTool(client, toolDefinition, serializerOptions: null);
81+
82+
Assert.NotNull(tool.JsonSerializerOptions);
83+
Assert.Same(McpJsonUtilities.DefaultOptions, tool.JsonSerializerOptions);
84+
}
85+
86+
[Fact]
87+
public async Task Constructor_WithCustomSerializerOptions_UsesProvidedOptions()
88+
{
89+
await using McpClient client = await CreateMcpClientForServer();
90+
91+
var toolDefinition = new Tool
92+
{
93+
Name = "test",
94+
Description = "Test tool"
95+
};
96+
97+
var customOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
98+
99+
var tool = new McpClientTool(client, toolDefinition, customOptions);
100+
101+
Assert.NotNull(tool.JsonSerializerOptions);
102+
Assert.Same(customOptions, tool.JsonSerializerOptions);
103+
}
104+
105+
[Fact]
106+
public async Task ReuseToolDefinition_AcrossDifferentClients_InvokesSuccessfully()
107+
{
108+
// Create first client and get tool definition
109+
Tool toolDefinition;
110+
{
111+
await using McpClient client1 = await CreateMcpClientForServer();
112+
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
113+
var echoTool = tools.First(t => t.Name == "echo");
114+
toolDefinition = echoTool.ProtocolTool;
115+
}
116+
117+
// Create second client (simulating reconnect)
118+
await using McpClient client2 = await CreateMcpClientForServer();
119+
120+
// Create new McpClientTool with cached tool definition and new client
121+
var reusedTool = new McpClientTool(client2, toolDefinition);
122+
123+
// Invoke the tool using the new client
124+
var result = await reusedTool.CallAsync(
125+
new Dictionary<string, object?> { ["message"] = "Hello from reused tool" },
126+
cancellationToken: TestContext.Current.CancellationToken);
127+
128+
Assert.NotNull(result);
129+
Assert.NotNull(result.Content);
130+
var textContent = result.Content.FirstOrDefault() as TextContentBlock;
131+
Assert.NotNull(textContent);
132+
Assert.Equal("Echo: Hello from reused tool", textContent.Text);
133+
}
134+
135+
[Fact]
136+
public async Task ReuseToolDefinition_WithComplexParameters_InvokesSuccessfully()
137+
{
138+
// Create first client and get tool definition
139+
Tool toolDefinition;
140+
{
141+
await using McpClient client1 = await CreateMcpClientForServer();
142+
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
143+
var addTool = tools.First(t => t.Name == "add");
144+
toolDefinition = addTool.ProtocolTool;
145+
}
146+
147+
// Create second client
148+
await using McpClient client2 = await CreateMcpClientForServer();
149+
150+
// Create new McpClientTool with cached tool definition
151+
var reusedTool = new McpClientTool(client2, toolDefinition);
152+
153+
// Invoke the tool with integer parameters
154+
var result = await reusedTool.CallAsync(
155+
new Dictionary<string, object?> { ["a"] = 5, ["b"] = 7 },
156+
cancellationToken: TestContext.Current.CancellationToken);
157+
158+
Assert.NotNull(result);
159+
Assert.NotNull(result.Content);
160+
var textContent = result.Content.FirstOrDefault() as TextContentBlock;
161+
Assert.NotNull(textContent);
162+
Assert.Equal("12", textContent.Text);
163+
}
164+
165+
[Fact]
166+
public async Task ReuseToolDefinition_PreservesToolMetadata()
167+
{
168+
await using McpClient client = await CreateMcpClientForServer();
169+
170+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
171+
var originalTool = tools.First(t => t.Name == "createPerson");
172+
var toolDefinition = originalTool.ProtocolTool;
173+
174+
// Create new McpClientTool with cached tool definition
175+
var reusedTool = new McpClientTool(client, toolDefinition);
176+
177+
// Verify metadata is preserved
178+
Assert.Equal(originalTool.Name, reusedTool.Name);
179+
Assert.Equal(originalTool.Description, reusedTool.Description);
180+
Assert.Equal(originalTool.ProtocolTool.Name, reusedTool.ProtocolTool.Name);
181+
Assert.Equal(originalTool.ProtocolTool.Description, reusedTool.ProtocolTool.Description);
182+
183+
// Verify JSON schema is preserved
184+
Assert.Equal(
185+
JsonSerializer.Serialize(originalTool.JsonSchema, McpJsonUtilities.DefaultOptions),
186+
JsonSerializer.Serialize(reusedTool.JsonSchema, McpJsonUtilities.DefaultOptions));
187+
}
188+
189+
[Fact]
190+
public async Task ManuallyConstructedTool_CanBeInvoked()
191+
{
192+
await using McpClient client = await CreateMcpClientForServer();
193+
194+
// Manually construct a Tool object matching the server's tool
195+
var manualTool = new Tool
196+
{
197+
Name = "echo",
198+
Description = "Echoes back the message",
199+
InputSchema = JsonDocument.Parse("""
200+
{
201+
"type": "object",
202+
"properties": {
203+
"message": { "type": "string" }
204+
}
205+
}
206+
""").RootElement.Clone()
207+
};
208+
209+
// Create McpClientTool with manually constructed tool
210+
var clientTool = new McpClientTool(client, manualTool);
211+
212+
// Invoke the tool
213+
var result = await clientTool.CallAsync(
214+
new Dictionary<string, object?> { ["message"] = "Test message" },
215+
cancellationToken: TestContext.Current.CancellationToken);
216+
217+
Assert.NotNull(result);
218+
Assert.NotNull(result.Content);
219+
var textContent = result.Content.FirstOrDefault() as TextContentBlock;
220+
Assert.NotNull(textContent);
221+
Assert.Equal("Echo: Test message", textContent.Text);
222+
}
223+
224+
[Fact]
225+
public async Task ReuseToolDefinition_WithInvokeAsync_WorksCorrectly()
226+
{
227+
Tool toolDefinition;
228+
{
229+
await using McpClient client1 = await CreateMcpClientForServer();
230+
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
231+
var addTool = tools.First(t => t.Name == "add");
232+
toolDefinition = addTool.ProtocolTool;
233+
}
234+
235+
await using McpClient client2 = await CreateMcpClientForServer();
236+
var reusedTool = new McpClientTool(client2, toolDefinition);
237+
238+
// Use AIFunction.InvokeAsync (inherited method)
239+
var result = await reusedTool.InvokeAsync(
240+
new() { ["a"] = 10, ["b"] = 20 },
241+
TestContext.Current.CancellationToken);
242+
243+
Assert.NotNull(result);
244+
245+
// InvokeAsync returns a JsonElement containing the serialized CallToolResult
246+
var jsonElement = Assert.IsType<JsonElement>(result);
247+
var callToolResult = JsonSerializer.Deserialize<CallToolResult>(jsonElement, McpJsonUtilities.DefaultOptions);
248+
249+
Assert.NotNull(callToolResult);
250+
Assert.NotNull(callToolResult.Content);
251+
var textContent = callToolResult.Content.FirstOrDefault() as TextContentBlock;
252+
Assert.NotNull(textContent);
253+
Assert.Equal("30", textContent.Text);
254+
}
255+
256+
[Fact]
257+
public async Task Constructor_WithToolWithoutDescription_UsesEmptyDescription()
258+
{
259+
await using McpClient client = await CreateMcpClientForServer();
260+
261+
var toolWithoutDescription = new Tool
262+
{
263+
Name = "noDescTool",
264+
Description = null
265+
};
266+
267+
var clientTool = new McpClientTool(client, toolWithoutDescription);
268+
269+
Assert.Equal("noDescTool", clientTool.Name);
270+
Assert.Equal(string.Empty, clientTool.Description);
271+
}
272+
273+
[Fact]
274+
public async Task ReuseToolDefinition_MultipleClients_AllWorkIndependently()
275+
{
276+
// Get tool definition from first client
277+
Tool toolDefinition;
278+
{
279+
await using McpClient client1 = await CreateMcpClientForServer();
280+
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
281+
var echoTool = tools.First(t => t.Name == "echo");
282+
toolDefinition = echoTool.ProtocolTool;
283+
}
284+
285+
// Create and invoke on second client
286+
string text2;
287+
{
288+
await using McpClient client2 = await CreateMcpClientForServer();
289+
var tool2 = new McpClientTool(client2, toolDefinition);
290+
var result2 = await tool2.CallAsync(
291+
new Dictionary<string, object?> { ["message"] = "From client 2" },
292+
cancellationToken: TestContext.Current.CancellationToken);
293+
text2 = (result2.Content.FirstOrDefault() as TextContentBlock)?.Text ?? string.Empty;
294+
}
295+
296+
// Create and invoke on third client
297+
string text3;
298+
{
299+
await using McpClient client3 = await CreateMcpClientForServer();
300+
var tool3 = new McpClientTool(client3, toolDefinition);
301+
var result3 = await tool3.CallAsync(
302+
new Dictionary<string, object?> { ["message"] = "From client 3" },
303+
cancellationToken: TestContext.Current.CancellationToken);
304+
text3 = (result3.Content.FirstOrDefault() as TextContentBlock)?.Text ?? string.Empty;
305+
}
306+
307+
// Verify both worked
308+
Assert.Equal("Echo: From client 2", text2);
309+
Assert.Equal("Echo: From client 3", text3);
310+
}
311+
}

0 commit comments

Comments
 (0)