From 61f5895053775d516aaf043d22052e3a7ea5105d Mon Sep 17 00:00:00 2001 From: syf2211 Date: Tue, 15 Sep 2026 00:11:44 +0000 Subject: [PATCH] fix(mcp): project multi-content AIContent[] tool results for models When the MCP SDK returns a successful multi-content result as in-memory AIContent objects, Format() no longer falls through to ToString() (which surfaced the literal type name). Text blocks are joined readably and image attachments are projected as MIME markers without embedding bytes. Fixes #2051 --- .../Tools/McpToolResultFormatterTests.cs | 45 +++++++++++++++++++ .../Tools/McpToolResultFormatter.cs | 42 +++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/Netclaw.Actors.Tests/Tools/McpToolResultFormatterTests.cs b/src/Netclaw.Actors.Tests/Tools/McpToolResultFormatterTests.cs index fb65ec75a..2ad9584e9 100644 --- a/src/Netclaw.Actors.Tests/Tools/McpToolResultFormatterTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/McpToolResultFormatterTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using Microsoft.Extensions.AI; using Netclaw.Actors.Tools; using Netclaw.Configuration; using Netclaw.Tools; @@ -134,4 +135,48 @@ public void Plain_string_result_is_passed_through() [Fact] public void Null_result_is_empty() => Assert.Equal(string.Empty, McpToolResultFormatter.Format(null, "srv/tool")); + + [Fact] + public void Multi_content_AIContent_array_projects_text_and_image_marker() + { + var chartJson = """{"title":"Example title","series":[]}"""; + var result = new AIContent[] + { + new DataContent(new byte[] { 1, 2, 3 }, "image/png"), + new TextContent(chartJson), + }; + + var message = McpToolResultFormatter.Format(result, "srv/chart"); + + Assert.Equal($"[image: image/png]\n{chartJson}", message); + Assert.DoesNotContain("AIContent", message); + } + + [Fact] + public void Image_only_AIContent_array_projects_marker_only() + { + var result = new AIContent[] { new DataContent(Array.Empty(), "image/jpeg") }; + + Assert.Equal("[image: image/jpeg]", McpToolResultFormatter.Format(result, "srv/tool")); + } + + [Fact] + public void Text_only_AIContent_array_projects_text() + { + var result = new AIContent[] { new TextContent("hello world") }; + + Assert.Equal("hello world", McpToolResultFormatter.Format(result, "srv/tool")); + } + + [Fact] + public void Single_TextContent_is_passed_through() + => Assert.Equal("done", McpToolResultFormatter.Format(new TextContent("done"), "srv/tool")); + + [Fact] + public void Non_image_DataContent_projects_attachment_marker() + { + var result = new AIContent[] { new DataContent(new byte[] { 4, 5 }, "application/pdf") }; + + Assert.Equal("[attachment: application/pdf]", McpToolResultFormatter.Format(result, "srv/tool")); + } } diff --git a/src/Netclaw.Actors/Tools/McpToolResultFormatter.cs b/src/Netclaw.Actors/Tools/McpToolResultFormatter.cs index 55357dfdb..9b5d4a7a2 100644 --- a/src/Netclaw.Actors/Tools/McpToolResultFormatter.cs +++ b/src/Netclaw.Actors/Tools/McpToolResultFormatter.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using Microsoft.Extensions.AI; using Netclaw.Tools; namespace Netclaw.Actors.Tools; @@ -57,6 +58,15 @@ public static string Format(object? result, string toolName) return string.IsNullOrWhiteSpace(detail) ? element.GetRawText() : detail; } + if (result is AIContent singleContent) + return FormatAiContent(singleContent); + + if (result is AIContent[] contentArray) + return FormatAiContents(contentArray); + + if (result is IEnumerable contents) + return FormatAiContents(contents); + return result?.ToString() ?? string.Empty; } @@ -121,4 +131,36 @@ private static string JoinTextContent(JsonElement element) return string.Join("\n", parts); } + + private static string FormatAiContents(IEnumerable contents) + { + var parts = new List(); + foreach (var content in contents) + { + var part = FormatAiContent(content); + if (!string.IsNullOrEmpty(part)) + parts.Add(part); + } + + return string.Join("\n", parts); + } + + private static string FormatAiContent(AIContent content) + => content switch + { + TextContent text when !string.IsNullOrEmpty(text.Text) => text.Text, + DataContent data => FormatDataContentMarker(data), + _ => string.Empty, + }; + + private static string FormatDataContentMarker(DataContent data) + { + var mediaType = string.IsNullOrWhiteSpace(data.MediaType) + ? "application/octet-stream" + : data.MediaType; + + return mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + ? $"[image: {mediaType}]" + : $"[attachment: {mediaType}]"; + } }