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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user