feat(mcp): map MCP tool results to core tool results

The truncation note follows the kept text after a single newline, as the built-in read/grep tools do (plan snippet updated).
This commit is contained in:
EugeneTes
2026-09-26 20:19:03 +02:00
parent b30c71d30a
commit da6068c5dc
2 changed files with 222 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
using System.Text;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using CoreBlock = Volcanic.AgenticCore.Abstractions.Messages.ContentBlock;
using McpBlock = ModelContextProtocol.Protocol.ContentBlock;
using TextContent = Volcanic.AgenticCore.Abstractions.Messages.TextContent;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Maps an MCP <c>tools/call</c> result to a core <see cref="ToolResult"/> (spec §7).</summary>
internal static class McpResultMapper
{
public const int MaxImageBytes = 5 * 1024 * 1024;
public const int MaxLines = 2000;
public const int MaxBytes = 50 * 1024;
/// <summary>
/// One <see cref="TextContent"/> (the joined, truncated text) followed by the images in order.
/// Throws <see cref="ToolExecutionException"/> carrying the mapped result when the server set <c>isError</c>.
/// </summary>
public static ToolResult Map(string server, string tool, CallToolResult result)
{
var text = new List<string>();
var images = new List<ImageContent>();
foreach (var block in result.Content ?? []) Add(block, text, images);
if (text.Count == 0 && images.Count == 0 && result.StructuredContent is { } structured)
text.Add(structured.GetRawText());
var (shown, truncated) = Truncate(string.Join('\n', text));
var content = new List<CoreBlock>();
if (shown.Length > 0) content.Add(new TextContent(shown));
else if (images.Count == 0) content.Add(new TextContent("(no output)"));
content.AddRange(images);
var isError = result.IsError == true;
var mapped = new ToolResult(content, VolcanicJson.ToElement(new { server, tool, isError, truncated, images = images.Count }));
if (isError) throw new ToolExecutionException(mapped);
return mapped;
}
private static void Add(McpBlock block, List<string> text, List<ImageContent> images)
{
switch (block)
{
case TextContentBlock t:
text.Add(t.Text);
break;
case ImageContentBlock image:
var size = image.DecodedData.Length;
if (size > MaxImageBytes) text.Add($"[image omitted: {image.MimeType}, {size} bytes]");
else images.Add(new ImageContent(Encoding.UTF8.GetString(image.Data.Span), image.MimeType));
break;
case AudioContentBlock audio:
text.Add($"[audio omitted: {audio.MimeType}, {audio.DecodedData.Length} bytes]");
break;
case EmbeddedResourceBlock { Resource: TextResourceContents resource }:
text.Add($"Resource {resource.Uri}:\n{resource.Text}");
break;
case EmbeddedResourceBlock { Resource: BlobResourceContents blob }:
text.Add($"[resource {blob.Uri}: {blob.MimeType ?? "application/octet-stream"}, {blob.DecodedData.Length} bytes omitted]");
break;
case ResourceLinkBlock link:
text.Add($"Resource link: {link.Uri} ({link.Name})");
break;
default:
text.Add($"[{block.Type} content omitted]");
break;
}
}
/// <summary>
/// Head truncation with the core <c>OutputTruncation.Head</c> limits and note wording, reimplemented here because
/// that type lives in the runtime. The note follows the kept text on its own line, as in the built-in tools.
/// </summary>
private static (string Text, bool Truncated) Truncate(string text)
{
var lines = text.Split('\n');
var totalBytes = Encoding.UTF8.GetByteCount(text);
if (lines.Length <= MaxLines && totalBytes <= MaxBytes) return (text, false);
var sb = new StringBuilder();
var bytes = 0;
var shown = 0;
foreach (var line in lines)
{
var lineBytes = Encoding.UTF8.GetByteCount(line) + 1;
if (shown >= MaxLines || bytes + lineBytes > MaxBytes) break;
if (shown > 0) sb.Append('\n');
sb.Append(line);
bytes += lineBytes;
shown++;
}
var note = $"[Output truncated: showing {shown} of {lines.Length} lines ({totalBytes} bytes). Use offset/limit or a narrower query to see more.]";
return ($"{sb}\n{note}", true);
}
}