diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index c1ab0c23a..8278048a7 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -153,6 +153,24 @@ var result = await client.CallToolWithPollingAsync( cancellationToken: cancellationToken); ``` +When you already have an from +, invoke it directly without rebuilding +the protocol request. The tool overload uses the original server-facing name even after + and preserves metadata configured with +: + +```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 { ["input"] = "value" }, + cancellationToken: cancellationToken); +``` + +Use when +you want the created task handle instead of automatic polling. + #### Manual control Use to receive the raw diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index f9d353e8c..a4735e466 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -100,6 +100,15 @@ internal McpClientTool( /// public Tool ProtocolTool { get; } + /// + /// Gets the used to invoke this tool. + /// + /// + /// This property is useful when implementing extensions that need to perform operations associated + /// with the same client session as this tool. + /// + public McpClient Client => _client; + /// public override string Name => _name; @@ -211,36 +220,87 @@ public ValueTask CallAsync( IProgress? progress = null, RequestOptions? options = null, CancellationToken cancellationToken = default) + { + options = MergeOptions(options); + + return _client.CallToolAsync( + ProtocolTool.Name, + arguments, + progress, + options, + cancellationToken); + } + + /// + /// Creates protocol request parameters for invoking this tool. + /// + /// An optional dictionary of arguments to pass to the tool. + /// Optional request options including metadata and serialization settings. + /// + /// Request parameters that use the tool's original protocol name and include metadata configured by + /// merged with metadata from . + /// + /// + /// This method is intended for extensions that need to invoke a tool through a protocol operation other + /// than . Metadata from takes precedence over metadata + /// configured by when the same key appears in both. + /// + public CallToolRequestParams CreateCallToolRequestParams( + IReadOnlyDictionary? arguments = null, + RequestOptions? options = null) + { + options = MergeOptions(options); + + JsonSerializerOptions serializerOptions = options?.JsonSerializerOptions ?? JsonSerializerOptions; + serializerOptions.MakeReadOnly(); + var typeInfo = serializerOptions.GetTypeInfo(); + + Dictionary? 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; } /// diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientToolExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientToolExtensions.cs new file mode 100644 index 000000000..ad318d949 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientToolExtensions.cs @@ -0,0 +1,65 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware operations on instances. +/// +public static class McpTasksClientToolExtensions +{ + /// + /// Calls a tool and returns either an immediate result or a created task. + /// + /// The tool to invoke. + /// An optional dictionary of arguments to pass to the tool. + /// Optional request options including metadata and serialization settings. + /// The cancellation token to monitor. + /// The immediate tool result or information about the created task. + public static ValueTask> CallAsTaskAsync( + this McpClientTool tool, + IReadOnlyDictionary? 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); + } + + /// + /// Calls a tool and, if the server creates a task, polls it to completion. + /// + /// The tool to invoke. + /// An optional dictionary of arguments to pass to the tool. + /// Optional request options including metadata and serialization settings. + /// + /// The maximum number of consecutive polls that may report input required without publishing a new input request. + /// + /// The cancellation token to monitor. + /// The completed tool result. + public static ValueTask CallWithPollingAsync( + this McpClientTool tool, + IReadOnlyDictionary? 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); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index e3da699c4..aa0b10724 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -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 context) => + context.Params.Meta?["sharedKey"]?.GetValue() ?? "missing", + new McpServerToolCreateOptions + { + Name = "metadata-tool", + Description = "Returns request metadata" + })]); } private static IDictionary CreateArguments(string key, string value) @@ -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 { ["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(Assert.Single(result.Content)); + Assert.Equal("from-options", textContent.Text); + } + [Fact] public async Task CancelTaskAsync_ForWorkingTask_Succeeds() {