Skip to content

Commit 15e885d

Browse files
PederHPclaude
andcommitted
Add McpClientTool.WithResultMarshaling to let hosts control InvokeAsync results
McpClientTool.InvokeCoreAsync applies a fixed marshaling policy: content blocks become AIContent when the result carries nothing else, and otherwise the entire CallToolResult is serialized to a JsonElement. Because the returned value is what IChatClient pipelines surface to the AI model, that policy makes consequential decisions on the host's behalf: any application _meta key (e.g. a correlation id intended for the host) flips a plain text result into a JSON envelope handed to the model with the _meta included; isError results reach the model as textual JSON rather than the provider's native error signaling; and results carrying both content and structuredContent are forwarded in duplicate. Hosts currently have no way to opt out short of abandoning McpClientTool for a hand-rolled AIFunction. Add WithResultMarshaling(Func<CallToolResult, CancellationToken, ValueTask<object?>>), following the existing With* clone pattern. When supplied, the delegate receives the raw CallToolResult and fully replaces the built-in conversion, giving hosts exact control over what is surfaced to consumers. It plays the same role for tool results that AIFunctionFactoryOptions.MarshalResult plays for factory-created functions. Default behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3338e88 commit 15e885d

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

‎src/ModelContextProtocol.Core/Client/McpClientTool.cs‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public sealed class McpClientTool : AIFunction
3030
private readonly string _description;
3131
private readonly IProgress<ProgressNotificationValue>? _progress;
3232
private readonly JsonObject? _meta;
33+
private readonly Func<CallToolResult, CancellationToken, ValueTask<object?>>? _marshalResult;
3334

3435
/// <summary>
3536
/// Initializes a new instance of the <see cref="McpClientTool"/> class.
@@ -68,6 +69,7 @@ public McpClientTool(
6869
_description = tool.Description ?? string.Empty;
6970
_progress = null;
7071
_meta = null;
72+
_marshalResult = null;
7173
}
7274

7375
internal McpClientTool(
@@ -77,7 +79,8 @@ internal McpClientTool(
7779
string? name = null,
7880
string? description = null,
7981
IProgress<ProgressNotificationValue>? progress = null,
80-
JsonObject? meta = null)
82+
JsonObject? meta = null,
83+
Func<CallToolResult, CancellationToken, ValueTask<object?>>? marshalResult = null)
8184
{
8285
_client = client;
8386
ProtocolTool = tool;
@@ -86,6 +89,7 @@ internal McpClientTool(
8689
_description = description ?? tool.Description ?? string.Empty;
8790
_progress = progress;
8891
_meta = meta;
92+
_marshalResult = marshalResult;
8993
}
9094

9195
/// <summary>
@@ -128,6 +132,13 @@ internal McpClientTool(
128132
};
129133
CallToolResult result = await CallAsync(arguments, _progress, options, cancellationToken).ConfigureAwait(false);
130134

135+
// If the host supplied its own result marshaler, it fully controls how the CallToolResult is
136+
// surfaced to consumers; the default translation below is bypassed entirely.
137+
if (_marshalResult is not null)
138+
{
139+
return await _marshalResult(result, cancellationToken).ConfigureAwait(false);
140+
}
141+
131142
// We want to translate the result content into AIContent, using AIContent as the exchange types, so
132143
// that downstream IChatClients can specialize handling based on the content (e.g. sending image content
133144
// back to the AI service as a multi-modal tool response). However, when there is additional information
@@ -268,7 +279,7 @@ public ValueTask<CallToolResult> CallAsync(
268279
/// </para>
269280
/// </remarks>
270281
public McpClientTool WithName(string name) =>
271-
new(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress, _meta);
282+
new(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress, _meta, _marshalResult);
272283

273284
/// <summary>
274285
/// Creates a new instance of the tool but modified to return the specified description from its <see cref="Description"/> property.
@@ -292,7 +303,7 @@ public McpClientTool WithName(string name) =>
292303
/// </remarks>
293304
/// <returns>A new instance of <see cref="McpClientTool"/> with the provided description.</returns>
294305
public McpClientTool WithDescription(string description) =>
295-
new(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress, _meta);
306+
new(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress, _meta, _marshalResult);
296307

297308
/// <summary>
298309
/// Creates a new instance of the tool but modified to report progress via the specified <see cref="IProgress{T}"/>.
@@ -316,7 +327,7 @@ public McpClientTool WithProgress(IProgress<ProgressNotificationValue> progress)
316327
{
317328
Throw.IfNull(progress);
318329

319-
return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress, _meta);
330+
return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress, _meta, _marshalResult);
320331
}
321332

322333
/// <summary>
@@ -346,5 +357,45 @@ public McpClientTool WithProgress(IProgress<ProgressNotificationValue> progress)
346357
/// </remarks>
347358
/// <returns>A new instance of <see cref="McpClientTool"/>, configured with the provided metadata.</returns>
348359
public McpClientTool WithMeta(JsonObject? meta) =>
349-
new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, meta);
360+
new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, meta, _marshalResult);
361+
362+
/// <summary>
363+
/// Creates a new instance of the tool but modified to use the specified delegate to produce the value
364+
/// returned from <see cref="AIFunction.InvokeAsync"/>, replacing the default marshaling.
365+
/// </summary>
366+
/// <param name="marshalResult">
367+
/// The delegate invoked with the <see cref="CallToolResult"/> produced by the tool call. Whatever it
368+
/// returns becomes the return value of <see cref="AIFunction.InvokeAsync"/>.
369+
/// </param>
370+
/// <remarks>
371+
/// <para>
372+
/// By default, <see cref="AIFunction.InvokeAsync"/> applies a built-in marshaling policy: content blocks are
373+
/// converted to <see cref="AIContent"/> when the result carries no additional information, and otherwise the
374+
/// entire <see cref="CallToolResult"/> is serialized to a <see cref="JsonElement"/> — including
375+
/// <see cref="CallToolResult.StructuredContent"/>, <see cref="CallToolResult.IsError"/>, and any
376+
/// <c>_meta</c>. When these tools are used with clients such as <c>IChatClient</c>, the returned value is
377+
/// what gets surfaced to the AI model, so hosts may need precise control over that shape: keeping
378+
/// host-only metadata out of model context, choosing between <see cref="CallToolResult.Content"/> and
379+
/// <see cref="CallToolResult.StructuredContent"/>, or applying their own error handling for
380+
/// <see cref="CallToolResult.IsError"/> results.
381+
/// </para>
382+
/// <para>
383+
/// The delegate assumes full responsibility for the returned value; none of the default conversion runs.
384+
/// Only one delegate can be specified at a time. Calling <see cref="WithResultMarshaling"/> again
385+
/// replaces any previously specified delegate.
386+
/// </para>
387+
/// <para>
388+
/// This serves the same role for tool results that
389+
/// <c>Microsoft.Extensions.AI.AIFunctionFactoryOptions.MarshalResult</c> serves for factory-created
390+
/// functions.
391+
/// </para>
392+
/// </remarks>
393+
/// <returns>A new instance of <see cref="McpClientTool"/>, configured with the provided delegate.</returns>
394+
/// <exception cref="ArgumentNullException"><paramref name="marshalResult"/> is <see langword="null"/>.</exception>
395+
public McpClientTool WithResultMarshaling(Func<CallToolResult, CancellationToken, ValueTask<object?>> marshalResult)
396+
{
397+
Throw.IfNull(marshalResult);
398+
399+
return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, _meta, marshalResult);
400+
}
350401
}

‎tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,80 @@ public async Task ErrorWithMetaTool_ReturnsJsonElement()
476476
Assert.Equal(JsonValueKind.Array, content.ValueKind);
477477
}
478478

479+
[Fact]
480+
public async Task WithResultMarshaling_ReceivesRawCallToolResult_AndControlsReturnValue()
481+
{
482+
await using McpClient client = await CreateMcpClientForServer();
483+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
484+
485+
CallToolResult? observed = null;
486+
var tool = tools.Single(t => t.Name == "meta_tool").WithResultMarshaling((result, cancellationToken) =>
487+
{
488+
observed = result;
489+
return new ValueTask<object?>("marshaled");
490+
});
491+
492+
var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
493+
494+
Assert.Equal("marshaled", result);
495+
Assert.NotNull(observed);
496+
Assert.Equal("customValue", observed.Meta?["customKey"]?.GetValue<string>());
497+
Assert.Equal("Content with meta", Assert.IsType<TextContentBlock>(observed.Content[0]).Text);
498+
}
499+
500+
[Fact]
501+
public async Task WithResultMarshaling_CanKeepMetaOutOfReturnedValue()
502+
{
503+
await using McpClient client = await CreateMcpClientForServer();
504+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
505+
506+
// The default marshaling would return the whole CallToolResult as a JsonElement, _meta included.
507+
// A host-supplied marshaler can surface only the content blocks.
508+
var tool = tools.Single(t => t.Name == "meta_tool").WithResultMarshaling((result, cancellationToken) =>
509+
new ValueTask<object?>(result.Content.Select(c => c.ToAIContent()).ToArray()));
510+
511+
var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
512+
513+
var aiContents = Assert.IsType<AIContent[]>(result);
514+
var textContent = Assert.IsType<TextContent>(Assert.Single(aiContents));
515+
Assert.Equal("Content with meta", textContent.Text);
516+
}
517+
518+
[Fact]
519+
public async Task WithResultMarshaling_ReplacesDefaultConversionForSimpleContent()
520+
{
521+
await using McpClient client = await CreateMcpClientForServer();
522+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
523+
524+
// Without the marshaler this would return a single TextContent; with it, the host gets the
525+
// typed CallToolResult itself.
526+
var tool = tools.Single(t => t.Name == "text_only_tool").WithResultMarshaling((result, cancellationToken) =>
527+
new ValueTask<object?>(result));
528+
529+
var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
530+
531+
var callToolResult = Assert.IsType<CallToolResult>(result);
532+
Assert.Equal("Simple text result", Assert.IsType<TextContentBlock>(callToolResult.Content[0]).Text);
533+
}
534+
535+
[Fact]
536+
public async Task WithResultMarshaling_IsPreservedByOtherWithMethods()
537+
{
538+
await using McpClient client = await CreateMcpClientForServer();
539+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
540+
541+
var tool = tools.Single(t => t.Name == "text_only_tool")
542+
.WithResultMarshaling((result, cancellationToken) => new ValueTask<object?>("marshaled"))
543+
.WithName("renamed_tool")
544+
.WithDescription("renamed description");
545+
546+
Assert.Equal("renamed_tool", tool.Name);
547+
548+
var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
549+
550+
Assert.Equal("marshaled", result);
551+
}
552+
479553
[Fact]
480554
public async Task BinaryResourceTool_ReturnsSingleDataContent()
481555
{

0 commit comments

Comments
 (0)