Skip to content

Adding an overload of McpClientExtensions::CallToolAsync taking JsonElement tool argument #641

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
95 changes: 95 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,101 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, Uri uri,
return UnsubscribeFromResourceAsync(client, uri.ToString(), cancellationToken);
}

/// <summary>
/// Invokes a tool on the server.
/// </summary>
/// <param name="client">The client instance used to communicate with the MCP server.</param>
/// <param name="toolName">The name of the tool to call on the server.</param>
/// <param name="arguments">A <see cref="JsonElement"/> containing arguments to pass to the tool. Each property represents a tool parameter name,
/// and its associated value represents the argument value as a <see cref="JsonElement"/>.
/// </param>
/// <param name="progress">
/// An optional <see cref="IProgress{T}"/> to have progress notifications reported to it. Setting this to a non-<see langword="null"/>
/// value will result in a progress token being included in the call, and any resulting progress notifications during the operation
/// routed to this instance.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task containing the <see cref="CallToolResult"/> from the tool execution. The response includes
/// the tool's output content, which may be structured data, text, or an error message.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="arguments"/> does not represent a JSON object.</exception>
/// <exception cref="McpException">The server could not find the requested tool, or the server encountered an error while processing the request.</exception>
/// <example>
/// <code>
/// // Call a tool with JsonElement arguments
/// var arguments = JsonDocument.Parse("""{"message": "Hello MCP!"}""").RootElement;
/// var result = await client.CallToolAsync("echo", arguments);
/// </code>
/// </example>
public static ValueTask<CallToolResult> CallToolAsync(
this IMcpClient client,
string toolName,
JsonElement arguments,
IProgress<ProgressNotificationValue>? 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) ?? []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will throw an exception if the JsonElement isn't representing an object. Perhaps it makes sense to add a check at the start of the method so that a bespoke error message can be raised?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point, Just updated the pr with the check as suggested.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will throw an exception if the JsonElement isn't representing an object. Perhaps it makes sense to add a check at the start of the method so that a bespoke error message can be raised?

What if it's a no-parameter tool? Would JsonValueKind.Undefined be an acceptable value then, or must it be a property-less JsonElement object? I would think default(JsonElement) would an intuitive choice for someone who is passing no arguments, and that's undefined afair?

Copy link
Member

@eiriktsarpalis eiriktsarpalis Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good point. Maybe we should make the parameter nullable for that reason, needing to create a null JsonElement or empty object seems somewhat overbearing.

},
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CallToolResult,
cancellationToken: cancellationToken);

static async ValueTask<CallToolResult> SendRequestWithProgressAsync(
IMcpClient client,
string toolName,
JsonElement arguments,
IProgress<ProgressNotificationValue> 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);
}
}

/// <summary>
/// Invokes a tool on the server.
/// </summary>
Expand Down
50 changes: 50 additions & 0 deletions tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextContentBlock>());
Assert.Equal("Echo: Hello MCP with JsonElement!", textContent.Text);
}

[Theory]
[MemberData(nameof(GetClients))]
public async Task CallTool_Stdio_EchoServer_WithJsonElementArguments_ThrowsForNonObject(string clientId)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For completeness, you could try turning this into a theory that accepts all types of invalid JSON.

{
// 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<ArgumentException>(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()
{
Expand Down