diff --git a/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs b/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs index 60a9c3a6..02716ca6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs @@ -789,6 +789,101 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, Uri uri, return UnsubscribeFromResourceAsync(client, uri.ToString(), cancellationToken); } + /// + /// Invokes a tool on the server. + /// + /// The client instance used to communicate with the MCP server. + /// The name of the tool to call on the server. + /// A containing arguments to pass to the tool. Each property represents a tool parameter name, + /// and its associated value represents the argument value as a . + /// + /// + /// An optional to have progress notifications reported to it. Setting this to a non- + /// value will result in a progress token being included in the call, and any resulting progress notifications during the operation + /// routed to this instance. + /// + /// The to monitor for cancellation requests. The default is . + /// + /// A task containing the from the tool execution. The response includes + /// the tool's output content, which may be structured data, text, or an error message. + /// + /// is . + /// is . + /// does not represent a JSON object. + /// The server could not find the requested tool, or the server encountered an error while processing the request. + /// + /// + /// // Call a tool with JsonElement arguments + /// var arguments = JsonDocument.Parse("""{"message": "Hello MCP!"}""").RootElement; + /// var result = await client.CallToolAsync("echo", arguments); + /// + /// + public static ValueTask CallToolAsync( + this IMcpClient client, + string toolName, + JsonElement arguments, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(client); + Throw.IfNull(toolName); + + if (arguments.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException($"The arguments parameter must represent a JSON object, but was {arguments.ValueKind}.", nameof(arguments)); + } + + if (progress is not null) + { + return SendRequestWithProgressAsync(client, toolName, arguments, progress, cancellationToken); + } + + return client.SendRequestAsync( + RequestMethods.ToolsCall, + new() + { + Name = toolName, + Arguments = arguments.EnumerateObject().ToDictionary(prop => prop.Name, prop => prop.Value) ?? [] + }, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken); + + static async ValueTask SendRequestWithProgressAsync( + IMcpClient client, + string toolName, + JsonElement arguments, + IProgress progress, + CancellationToken cancellationToken) + { + ProgressToken progressToken = new(Guid.NewGuid().ToString("N")); + + await using var _ = client.RegisterNotificationHandler(NotificationMethods.ProgressNotification, + (notification, cancellationToken) => + { + if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams) is { } pn && + pn.ProgressToken == progressToken) + { + progress.Report(pn.Progress); + } + + return default; + }).ConfigureAwait(false); + + return await client.SendRequestAsync( + RequestMethods.ToolsCall, + new() + { + Name = toolName, + Arguments = arguments.EnumerateObject().ToDictionary(prop => prop.Name, prop => prop.Value) ?? [], + ProgressToken = progressToken, + }, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + /// /// Invokes a tool on the server. /// diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 3e4361a5..14a305a7 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -96,6 +96,56 @@ public async Task CallTool_Stdio_EchoServer(string clientId) Assert.Equal("Echo: Hello MCP!", textContent.Text); } + [Theory] + [MemberData(nameof(GetClients))] + public async Task CallTool_Stdio_EchoServer_WithJsonElementArguments(string clientId) + { + // arrange + JsonElement arguments = JsonDocument.Parse(""" + { + "message": "Hello MCP with JsonElement!" + } + """).RootElement; + + // act + await using var client = await _fixture.CreateClientAsync(clientId); + var result = await client.CallToolAsync( + "echo", + arguments, + cancellationToken: TestContext.Current.CancellationToken + ); + + // assert + Assert.NotNull(result); + Assert.Null(result.IsError); + var textContent = Assert.Single(result.Content.OfType()); + Assert.Equal("Echo: Hello MCP with JsonElement!", textContent.Text); + } + + [Theory] + [MemberData(nameof(GetClients))] + public async Task CallTool_Stdio_EchoServer_WithJsonElementArguments_ThrowsForNonObject(string clientId) + { + // arrange - JsonElement representing a string, not an object + JsonElement stringArguments = JsonDocument.Parse(""" + "Hello MCP!" + """).RootElement; + + // act & assert + await using var client = await _fixture.CreateClientAsync(clientId); + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "echo", + stringArguments, + cancellationToken: TestContext.Current.CancellationToken + ) + ); + + Assert.Contains("arguments parameter must represent a JSON object", exception.Message); + Assert.Contains("String", exception.Message); + Assert.Equal("arguments", exception.ParamName); + } + [Fact] public async Task CallTool_Stdio_EchoSessionId_ReturnsEmpty() {