diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index f9d353e8c..ce017cb11 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -30,6 +30,7 @@ public sealed class McpClientTool : AIFunction private readonly string _description; private readonly IProgress? _progress; private readonly JsonObject? _meta; + private readonly Func>? _marshalResult; /// /// Initializes a new instance of the class. @@ -68,6 +69,7 @@ public McpClientTool( _description = tool.Description ?? string.Empty; _progress = null; _meta = null; + _marshalResult = null; } internal McpClientTool( @@ -77,7 +79,8 @@ internal McpClientTool( string? name = null, string? description = null, IProgress? progress = null, - JsonObject? meta = null) + JsonObject? meta = null, + Func>? marshalResult = null) { _client = client; ProtocolTool = tool; @@ -86,6 +89,7 @@ internal McpClientTool( _description = description ?? tool.Description ?? string.Empty; _progress = progress; _meta = meta; + _marshalResult = marshalResult; } /// @@ -128,6 +132,13 @@ internal McpClientTool( }; CallToolResult result = await CallAsync(arguments, _progress, options, cancellationToken).ConfigureAwait(false); + // If the host supplied its own result marshaler, it fully controls how the CallToolResult is + // surfaced to consumers; the default translation below is bypassed entirely. + if (_marshalResult is not null) + { + return await _marshalResult(result, cancellationToken).ConfigureAwait(false); + } + // We want to translate the result content into AIContent, using AIContent as the exchange types, so // that downstream IChatClients can specialize handling based on the content (e.g. sending image content // back to the AI service as a multi-modal tool response). However, when there is additional information @@ -268,7 +279,7 @@ public ValueTask CallAsync( /// /// public McpClientTool WithName(string name) => - new(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress, _meta); + new(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress, _meta, _marshalResult); /// /// Creates a new instance of the tool but modified to return the specified description from its property. @@ -292,7 +303,7 @@ public McpClientTool WithName(string name) => /// /// A new instance of with the provided description. public McpClientTool WithDescription(string description) => - new(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress, _meta); + new(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress, _meta, _marshalResult); /// /// Creates a new instance of the tool but modified to report progress via the specified . @@ -316,7 +327,7 @@ public McpClientTool WithProgress(IProgress progress) { Throw.IfNull(progress); - return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress, _meta); + return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress, _meta, _marshalResult); } /// @@ -346,5 +357,45 @@ public McpClientTool WithProgress(IProgress progress) /// /// A new instance of , configured with the provided metadata. public McpClientTool WithMeta(JsonObject? meta) => - new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, meta); + new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, meta, _marshalResult); + + /// + /// Creates a new instance of the tool but modified to use the specified delegate to produce the value + /// returned from , replacing the default marshaling. + /// + /// + /// The delegate invoked with the produced by the tool call. Whatever it + /// returns becomes the return value of . + /// + /// + /// + /// By default, applies a built-in marshaling policy: content blocks are + /// converted to when the result carries no additional information, and otherwise the + /// entire is serialized to a — including + /// , , and any + /// _meta. When these tools are used with clients such as IChatClient, the returned value is + /// what gets surfaced to the AI model, so hosts may need precise control over that shape: keeping + /// host-only metadata out of model context, choosing between and + /// , or applying their own error handling for + /// results. + /// + /// + /// The delegate assumes full responsibility for the returned value; none of the default conversion runs. + /// Only one delegate can be specified at a time. Calling again + /// replaces any previously specified delegate. + /// + /// + /// This serves the same role for tool results that + /// Microsoft.Extensions.AI.AIFunctionFactoryOptions.MarshalResult serves for factory-created + /// functions. + /// + /// + /// A new instance of , configured with the provided delegate. + /// is . + public McpClientTool WithResultMarshaling(Func> marshalResult) + { + Throw.IfNull(marshalResult); + + return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, _meta, marshalResult); + } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index f789d1960..240fc66ba 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -476,6 +476,80 @@ public async Task ErrorWithMetaTool_ReturnsJsonElement() Assert.Equal(JsonValueKind.Array, content.ValueKind); } + [Fact] + public async Task WithResultMarshaling_ReceivesRawCallToolResult_AndControlsReturnValue() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + CallToolResult? observed = null; + var tool = tools.Single(t => t.Name == "meta_tool").WithResultMarshaling((result, cancellationToken) => + { + observed = result; + return new ValueTask("marshaled"); + }); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("marshaled", result); + Assert.NotNull(observed); + Assert.Equal("customValue", observed.Meta?["customKey"]?.GetValue()); + Assert.Equal("Content with meta", Assert.IsType(observed.Content[0]).Text); + } + + [Fact] + public async Task WithResultMarshaling_CanKeepMetaOutOfReturnedValue() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // The default marshaling would return the whole CallToolResult as a JsonElement, _meta included. + // A host-supplied marshaler can surface only the content blocks. + var tool = tools.Single(t => t.Name == "meta_tool").WithResultMarshaling((result, cancellationToken) => + new ValueTask(result.Content.Select(c => c.ToAIContent()).ToArray())); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + var aiContents = Assert.IsType(result); + var textContent = Assert.IsType(Assert.Single(aiContents)); + Assert.Equal("Content with meta", textContent.Text); + } + + [Fact] + public async Task WithResultMarshaling_ReplacesDefaultConversionForSimpleContent() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Without the marshaler this would return a single TextContent; with it, the host gets the + // typed CallToolResult itself. + var tool = tools.Single(t => t.Name == "text_only_tool").WithResultMarshaling((result, cancellationToken) => + new ValueTask(result)); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + var callToolResult = Assert.IsType(result); + Assert.Equal("Simple text result", Assert.IsType(callToolResult.Content[0]).Text); + } + + [Fact] + public async Task WithResultMarshaling_IsPreservedByOtherWithMethods() + { + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var tool = tools.Single(t => t.Name == "text_only_tool") + .WithResultMarshaling((result, cancellationToken) => new ValueTask("marshaled")) + .WithName("renamed_tool") + .WithDescription("renamed description"); + + Assert.Equal("renamed_tool", tool.Name); + + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("marshaled", result); + } + [Fact] public async Task BinaryResourceTool_ReturnsSingleDataContent() {