Skip to content

Commit e0874bb

Browse files
committed
Add task calls for MCP client tools
1 parent 81ae6ec commit e0874bb

4 files changed

Lines changed: 221 additions & 32 deletions

File tree

docs/concepts/tasks/tasks.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,24 @@ var result = await client.CallToolWithPollingAsync(
153153
cancellationToken: cancellationToken);
154154
```
155155

156+
When you already have an <xref:ModelContextProtocol.Client.McpClientTool> from
157+
<xref:ModelContextProtocol.Client.McpClient.ListToolsAsync*>, invoke it directly without rebuilding
158+
the protocol request. The tool overload uses the original server-facing name even after
159+
<xref:ModelContextProtocol.Client.McpClientTool.WithName*> and preserves metadata configured with
160+
<xref:ModelContextProtocol.Client.McpClientTool.WithMeta*>:
161+
162+
```csharp
163+
var tools = await client.ListToolsAsync(cancellationToken: cancellationToken);
164+
var tool = tools.Single(tool => tool.Name == "long-running-tool");
165+
166+
var result = await tool.CallWithPollingAsync(
167+
new Dictionary<string, object?> { ["input"] = "value" },
168+
cancellationToken: cancellationToken);
169+
```
170+
171+
Use <xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientToolExtensions.CallAsTaskAsync*> when
172+
you want the created task handle instead of automatic polling.
173+
156174
#### Manual control
157175

158176
Use <xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.CallToolAsTaskAsync*> to receive the raw

src/ModelContextProtocol.Core/Client/McpClientTool.cs

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ internal McpClientTool(
100100
/// </remarks>
101101
public Tool ProtocolTool { get; }
102102

103+
/// <summary>
104+
/// Gets the <see cref="McpClient"/> used to invoke this tool.
105+
/// </summary>
106+
/// <remarks>
107+
/// This property is useful when implementing extensions that need to perform operations associated
108+
/// with the same client session as this tool.
109+
/// </remarks>
110+
public McpClient Client => _client;
111+
103112
/// <inheritdoc/>
104113
public override string Name => _name;
105114

@@ -211,36 +220,87 @@ public ValueTask<CallToolResult> CallAsync(
211220
IProgress<ProgressNotificationValue>? progress = null,
212221
RequestOptions? options = null,
213222
CancellationToken cancellationToken = default)
223+
{
224+
options = MergeOptions(options);
225+
226+
return _client.CallToolAsync(
227+
ProtocolTool.Name,
228+
arguments,
229+
progress,
230+
options,
231+
cancellationToken);
232+
}
233+
234+
/// <summary>
235+
/// Creates protocol request parameters for invoking this tool.
236+
/// </summary>
237+
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
238+
/// <param name="options">Optional request options including metadata and serialization settings.</param>
239+
/// <returns>
240+
/// Request parameters that use the tool's original protocol name and include metadata configured by
241+
/// <see cref="WithMeta"/> merged with metadata from <paramref name="options"/>.
242+
/// </returns>
243+
/// <remarks>
244+
/// This method is intended for extensions that need to invoke a tool through a protocol operation other
245+
/// than <see cref="CallAsync"/>. Metadata from <paramref name="options"/> takes precedence over metadata
246+
/// configured by <see cref="WithMeta"/> when the same key appears in both.
247+
/// </remarks>
248+
public CallToolRequestParams CreateCallToolRequestParams(
249+
IReadOnlyDictionary<string, object?>? arguments = null,
250+
RequestOptions? options = null)
251+
{
252+
options = MergeOptions(options);
253+
254+
JsonSerializerOptions serializerOptions = options?.JsonSerializerOptions ?? JsonSerializerOptions;
255+
serializerOptions.MakeReadOnly();
256+
var typeInfo = serializerOptions.GetTypeInfo<object?>();
257+
258+
Dictionary<string, JsonElement>? serializedArguments = null;
259+
if (arguments is not null)
260+
{
261+
serializedArguments = new(arguments.Count);
262+
foreach (var argument in arguments)
263+
{
264+
serializedArguments.Add(
265+
argument.Key,
266+
argument.Value is JsonElement element ? element : JsonSerializer.SerializeToElement(argument.Value, typeInfo));
267+
}
268+
}
269+
270+
return new CallToolRequestParams
271+
{
272+
Name = ProtocolTool.Name,
273+
Arguments = serializedArguments,
274+
Meta = options?.GetMetaForRequest(),
275+
};
276+
}
277+
278+
private RequestOptions? MergeOptions(RequestOptions? options)
214279
{
215280
// If there's any metadata provided with WithMeta, we can't just pass along the options as-is,
216281
// and instead need to create new options that merges in _meta.
217-
if (_meta is { } meta)
282+
if (_meta is not { } meta)
218283
{
219-
// Create a new RequestOptions, as we're going to need to store a new JsonObject for Meta (either
220-
// _meta or _meta+options.Meta), and we don't want to mutate the user's options object.
221-
RequestOptions newOptions = options?.Clone() ?? new();
284+
return options;
285+
}
286+
287+
// Create a new RequestOptions, as we're going to need to store a new JsonObject for Meta (either
288+
// _meta or _meta+options.Meta), and we don't want to mutate the user's options object.
289+
RequestOptions newOptions = options?.Clone() ?? new();
222290

223-
// If we also have newOptions.Meta, merge that with _meta into a new JsonObject, preferring
224-
// the objects from newOptions.Meta in case of conflicts.
225-
if (newOptions.Meta is { } newOptionsMeta)
291+
// If we also have newOptions.Meta, merge that with _meta into a new JsonObject, preferring
292+
// the objects from newOptions.Meta in case of conflicts.
293+
if (newOptions.Meta is { } newOptionsMeta)
294+
{
295+
meta = (JsonObject)meta.DeepClone();
296+
foreach (var p in newOptionsMeta)
226297
{
227-
meta = (JsonObject)meta.DeepClone();
228-
foreach (var p in newOptionsMeta)
229-
{
230-
meta[p.Key] = p.Value?.DeepClone();
231-
}
298+
meta[p.Key] = p.Value?.DeepClone();
232299
}
233-
234-
newOptions.Meta = meta;
235-
options = newOptions;
236300
}
237301

238-
return _client.CallToolAsync(
239-
ProtocolTool.Name,
240-
arguments,
241-
progress,
242-
options,
243-
cancellationToken);
302+
newOptions.Meta = meta;
303+
return newOptions;
244304
}
245305

246306
/// <summary>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using ModelContextProtocol.Client;
2+
using ModelContextProtocol.Protocol;
3+
4+
namespace ModelContextProtocol.Extensions.Tasks;
5+
6+
/// <summary>
7+
/// Extension methods for task-aware operations on <see cref="McpClientTool"/> instances.
8+
/// </summary>
9+
public static class McpTasksClientToolExtensions
10+
{
11+
/// <summary>
12+
/// Calls a tool and returns either an immediate result or a created task.
13+
/// </summary>
14+
/// <param name="tool">The tool to invoke.</param>
15+
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
16+
/// <param name="options">Optional request options including metadata and serialization settings.</param>
17+
/// <param name="cancellationToken">The cancellation token to monitor.</param>
18+
/// <returns>The immediate tool result or information about the created task.</returns>
19+
public static ValueTask<ResultOrCreatedTask<CallToolResult>> CallAsTaskAsync(
20+
this McpClientTool tool,
21+
IReadOnlyDictionary<string, object?>? arguments = null,
22+
RequestOptions? options = null,
23+
CancellationToken cancellationToken = default)
24+
{
25+
#if NET
26+
ArgumentNullException.ThrowIfNull(tool);
27+
#else
28+
if (tool is null) throw new ArgumentNullException(nameof(tool));
29+
#endif
30+
31+
return tool.Client.CallToolAsTaskAsync(
32+
tool.CreateCallToolRequestParams(arguments, options),
33+
cancellationToken);
34+
}
35+
36+
/// <summary>
37+
/// Calls a tool and, if the server creates a task, polls it to completion.
38+
/// </summary>
39+
/// <param name="tool">The tool to invoke.</param>
40+
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
41+
/// <param name="options">Optional request options including metadata and serialization settings.</param>
42+
/// <param name="maxConsecutiveStuckPolls">
43+
/// The maximum number of consecutive polls that may report input required without publishing a new input request.
44+
/// </param>
45+
/// <param name="cancellationToken">The cancellation token to monitor.</param>
46+
/// <returns>The completed tool result.</returns>
47+
public static ValueTask<CallToolResult> CallWithPollingAsync(
48+
this McpClientTool tool,
49+
IReadOnlyDictionary<string, object?>? arguments = null,
50+
RequestOptions? options = null,
51+
int maxConsecutiveStuckPolls = 60,
52+
CancellationToken cancellationToken = default)
53+
{
54+
#if NET
55+
ArgumentNullException.ThrowIfNull(tool);
56+
#else
57+
if (tool is null) throw new ArgumentNullException(nameof(tool));
58+
#endif
59+
60+
return tool.Client.CallToolWithPollingAsync(
61+
tool.CreateCallToolRequestParams(arguments, options),
62+
maxConsecutiveStuckPolls,
63+
cancellationToken);
64+
}
65+
}

tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,26 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
3131
{
3232
DefaultPollIntervalMs = 50,
3333
})
34-
.WithTools([McpServerTool.Create(
35-
async (string input, CancellationToken ct) =>
36-
{
37-
await Task.Delay(50, ct);
38-
return $"Processed: {input}";
39-
},
40-
new McpServerToolCreateOptions
41-
{
42-
Name = "test-tool",
43-
Description = "A test tool"
44-
})]);
34+
.WithTools([
35+
McpServerTool.Create(
36+
async (string input, CancellationToken ct) =>
37+
{
38+
await Task.Delay(50, ct);
39+
return $"Processed: {input}";
40+
},
41+
new McpServerToolCreateOptions
42+
{
43+
Name = "test-tool",
44+
Description = "A test tool"
45+
}),
46+
McpServerTool.Create(
47+
(RequestContext<CallToolRequestParams> context) =>
48+
context.Params.Meta?["sharedKey"]?.GetValue<string>() ?? "missing",
49+
new McpServerToolCreateOptions
50+
{
51+
Name = "metadata-tool",
52+
Description = "Returns request metadata"
53+
})]);
4554
}
4655

4756
private static IDictionary<string, JsonElement> CreateArguments(string key, string value)
@@ -133,6 +142,43 @@ public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult()
133142
Assert.Equal("Processed: hello", textContent.Text);
134143
}
135144

145+
[Fact]
146+
public async Task McpClientTool_CallAsTaskAsync_UsesProtocolName()
147+
{
148+
await using var client = await CreateMcpClientForServer();
149+
var ct = TestContext.Current.CancellationToken;
150+
var tools = await client.ListToolsAsync(cancellationToken: ct);
151+
var tool = tools.Single(t => t.Name == "test-tool").WithName("model-facing-name");
152+
153+
var augmented = await tool.CallAsTaskAsync(
154+
new Dictionary<string, object?> { ["input"] = "hello" },
155+
cancellationToken: ct);
156+
157+
Assert.True(augmented.IsTask);
158+
Assert.NotNull(augmented.TaskCreated);
159+
}
160+
161+
[Fact]
162+
public async Task McpClientTool_CallWithPollingAsync_PreservesAndMergesMetadata()
163+
{
164+
await using var client = await CreateMcpClientForServer();
165+
var ct = TestContext.Current.CancellationToken;
166+
var tools = await client.ListToolsAsync(cancellationToken: ct);
167+
var tool = tools.Single(t => t.Name == "metadata-tool")
168+
.WithName("model-facing-name")
169+
.WithMeta(new() { ["sharedKey"] = "from-tool" });
170+
171+
var result = await tool.CallWithPollingAsync(
172+
options: new RequestOptions
173+
{
174+
Meta = new() { ["sharedKey"] = "from-options" },
175+
},
176+
cancellationToken: ct);
177+
178+
var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
179+
Assert.Equal("from-options", textContent.Text);
180+
}
181+
136182
[Fact]
137183
public async Task CancelTaskAsync_ForWorkingTask_Succeeds()
138184
{

0 commit comments

Comments
 (0)