Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/concepts/tasks/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ var result = await client.CallToolWithPollingAsync(
cancellationToken: cancellationToken);
```

When you already have an <xref:ModelContextProtocol.Client.McpClientTool> from
<xref:ModelContextProtocol.Client.McpClient.ListToolsAsync*>, invoke it directly without rebuilding
the protocol request. The tool overload uses the original server-facing name even after
<xref:ModelContextProtocol.Client.McpClientTool.WithName*> and preserves metadata configured with
<xref:ModelContextProtocol.Client.McpClientTool.WithMeta*>:

```csharp
var tools = await client.ListToolsAsync(cancellationToken: cancellationToken);
var tool = tools.Single(tool => tool.Name == "long-running-tool");

var result = await tool.CallWithPollingAsync(
new Dictionary<string, object?> { ["input"] = "value" },
cancellationToken: cancellationToken);
```

Use <xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientToolExtensions.CallAsTaskAsync*> when
you want the created task handle instead of automatic polling.

#### Manual control

Use <xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.CallToolAsTaskAsync*> to receive the raw
Expand Down
102 changes: 81 additions & 21 deletions src/ModelContextProtocol.Core/Client/McpClientTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ internal McpClientTool(
/// </remarks>
public Tool ProtocolTool { get; }

/// <summary>
/// Gets the <see cref="McpClient"/> used to invoke this tool.
/// </summary>
/// <remarks>
/// This property is useful when implementing extensions that need to perform operations associated
/// with the same client session as this tool.
/// </remarks>
public McpClient Client => _client;

/// <inheritdoc/>
public override string Name => _name;

Expand Down Expand Up @@ -211,36 +220,87 @@ public ValueTask<CallToolResult> CallAsync(
IProgress<ProgressNotificationValue>? progress = null,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
options = MergeOptions(options);

return _client.CallToolAsync(
ProtocolTool.Name,
arguments,
progress,
options,
cancellationToken);
}

/// <summary>
/// Creates protocol request parameters for invoking this tool.
/// </summary>
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
/// <param name="options">Optional request options including metadata and serialization settings.</param>
/// <returns>
/// Request parameters that use the tool's original protocol name and include metadata configured by
/// <see cref="WithMeta"/> merged with metadata from <paramref name="options"/>.
/// </returns>
/// <remarks>
/// This method is intended for extensions that need to invoke a tool through a protocol operation other
/// than <see cref="CallAsync"/>. Metadata from <paramref name="options"/> takes precedence over metadata
/// configured by <see cref="WithMeta"/> when the same key appears in both.
/// </remarks>
public CallToolRequestParams CreateCallToolRequestParams(
IReadOnlyDictionary<string, object?>? arguments = null,
RequestOptions? options = null)
{
options = MergeOptions(options);

JsonSerializerOptions serializerOptions = options?.JsonSerializerOptions ?? JsonSerializerOptions;
serializerOptions.MakeReadOnly();
var typeInfo = serializerOptions.GetTypeInfo<object?>();

Dictionary<string, JsonElement>? serializedArguments = null;
if (arguments is not null)
{
serializedArguments = new(arguments.Count);
foreach (var argument in arguments)
{
serializedArguments.Add(
argument.Key,
argument.Value is JsonElement element ? element : JsonSerializer.SerializeToElement(argument.Value, typeInfo));
}
}

return new CallToolRequestParams
{
Name = ProtocolTool.Name,
Arguments = serializedArguments,
Meta = options?.GetMetaForRequest(),
};
}

private RequestOptions? MergeOptions(RequestOptions? options)
{
// If there's any metadata provided with WithMeta, we can't just pass along the options as-is,
// and instead need to create new options that merges in _meta.
if (_meta is { } meta)
if (_meta is not { } meta)
{
// Create a new RequestOptions, as we're going to need to store a new JsonObject for Meta (either
// _meta or _meta+options.Meta), and we don't want to mutate the user's options object.
RequestOptions newOptions = options?.Clone() ?? new();
return options;
}

// Create a new RequestOptions, as we're going to need to store a new JsonObject for Meta (either
// _meta or _meta+options.Meta), and we don't want to mutate the user's options object.
RequestOptions newOptions = options?.Clone() ?? new();

// If we also have newOptions.Meta, merge that with _meta into a new JsonObject, preferring
// the objects from newOptions.Meta in case of conflicts.
if (newOptions.Meta is { } newOptionsMeta)
// If we also have newOptions.Meta, merge that with _meta into a new JsonObject, preferring
// the objects from newOptions.Meta in case of conflicts.
if (newOptions.Meta is { } newOptionsMeta)
{
meta = (JsonObject)meta.DeepClone();
foreach (var p in newOptionsMeta)
{
meta = (JsonObject)meta.DeepClone();
foreach (var p in newOptionsMeta)
{
meta[p.Key] = p.Value?.DeepClone();
}
meta[p.Key] = p.Value?.DeepClone();
}

newOptions.Meta = meta;
options = newOptions;
}

return _client.CallToolAsync(
ProtocolTool.Name,
arguments,
progress,
options,
cancellationToken);
newOptions.Meta = meta;
return newOptions;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;

namespace ModelContextProtocol.Extensions.Tasks;

/// <summary>
/// Extension methods for task-aware operations on <see cref="McpClientTool"/> instances.
/// </summary>
public static class McpTasksClientToolExtensions
{
/// <summary>
/// Calls a tool and returns either an immediate result or a created task.
/// </summary>
/// <param name="tool">The tool to invoke.</param>
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
/// <param name="options">Optional request options including metadata and serialization settings.</param>
/// <param name="cancellationToken">The cancellation token to monitor.</param>
/// <returns>The immediate tool result or information about the created task.</returns>
public static ValueTask<ResultOrCreatedTask<CallToolResult>> CallAsTaskAsync(
this McpClientTool tool,
IReadOnlyDictionary<string, object?>? arguments = null,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
#if NET
ArgumentNullException.ThrowIfNull(tool);
#else
if (tool is null) throw new ArgumentNullException(nameof(tool));
#endif

return tool.Client.CallToolAsTaskAsync(
tool.CreateCallToolRequestParams(arguments, options),
cancellationToken);
}

/// <summary>
/// Calls a tool and, if the server creates a task, polls it to completion.
/// </summary>
/// <param name="tool">The tool to invoke.</param>
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
/// <param name="options">Optional request options including metadata and serialization settings.</param>
/// <param name="maxConsecutiveStuckPolls">
/// The maximum number of consecutive polls that may report input required without publishing a new input request.
/// </param>
/// <param name="cancellationToken">The cancellation token to monitor.</param>
/// <returns>The completed tool result.</returns>
public static ValueTask<CallToolResult> CallWithPollingAsync(
this McpClientTool tool,
IReadOnlyDictionary<string, object?>? arguments = null,
RequestOptions? options = null,
int maxConsecutiveStuckPolls = 60,
CancellationToken cancellationToken = default)
{
#if NET
ArgumentNullException.ThrowIfNull(tool);
#else
if (tool is null) throw new ArgumentNullException(nameof(tool));
#endif

return tool.Client.CallToolWithPollingAsync(
tool.CreateCallToolRequestParams(arguments, options),
maxConsecutiveStuckPolls,
cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,26 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
{
DefaultPollIntervalMs = 50,
})
.WithTools([McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct);
return $"Processed: {input}";
},
new McpServerToolCreateOptions
{
Name = "test-tool",
Description = "A test tool"
})]);
.WithTools([
McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct);
return $"Processed: {input}";
},
new McpServerToolCreateOptions
{
Name = "test-tool",
Description = "A test tool"
}),
McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
context.Params.Meta?["sharedKey"]?.GetValue<string>() ?? "missing",
new McpServerToolCreateOptions
{
Name = "metadata-tool",
Description = "Returns request metadata"
})]);
}

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

[Fact]
public async Task McpClientTool_CallAsTaskAsync_UsesProtocolName()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var tools = await client.ListToolsAsync(cancellationToken: ct);
var tool = tools.Single(t => t.Name == "test-tool").WithName("model-facing-name");

var augmented = await tool.CallAsTaskAsync(
new Dictionary<string, object?> { ["input"] = "hello" },
cancellationToken: ct);

Assert.True(augmented.IsTask);
Assert.NotNull(augmented.TaskCreated);
}

[Fact]
public async Task McpClientTool_CallWithPollingAsync_PreservesAndMergesMetadata()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var tools = await client.ListToolsAsync(cancellationToken: ct);
var tool = tools.Single(t => t.Name == "metadata-tool")
.WithName("model-facing-name")
.WithMeta(new() { ["sharedKey"] = "from-tool" });

var result = await tool.CallWithPollingAsync(
options: new RequestOptions
{
Meta = new() { ["sharedKey"] = "from-options" },
},
cancellationToken: ct);

var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
Assert.Equal("from-options", textContent.Text);
}

[Fact]
public async Task CancelTaskAsync_ForWorkingTask_Succeeds()
{
Expand Down