From b6aa585dc0db0d81b3815895ef195361ba2d57be Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Fri, 3 Apr 2026 17:22:40 +0200 Subject: [PATCH] fix(mcp): preserve raw MCP payload in text artifacts --- pkg/tools/mcp_tool.go | 33 +++++++++++++++++++---------- pkg/tools/mcp_tool_test.go | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index 5e1c48d25..fa153226c 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -272,14 +272,19 @@ func extractContentText(content []mcp.Content) string { func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult { llmParts := make([]string, 0, len(content)) + rawTextParts := make([]string, 0, len(content)) mediaRefs := make([]string, 0, len(content)) for _, c := range content { switch v := c.(type) { case *mcp.TextContent: - text := strings.TrimSpace(sanitizeToolLLMContent(v.Text)) - if text != "" { - llmParts = append(llmParts, text) + rawText := strings.TrimSpace(v.Text) + if rawText != "" { + rawTextParts = append(rawTextParts, rawText) + } + safeText := strings.TrimSpace(sanitizeToolLLMContent(v.Text)) + if safeText != "" { + llmParts = append(llmParts, safeText) } case *mcp.ImageContent: ref, note := t.storeBinaryContent( @@ -312,10 +317,13 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont case *mcp.ResourceLink: llmParts = append(llmParts, summarizeResourceLink(v)) case *mcp.EmbeddedResource: - ref, note := t.storeEmbeddedResource(ctx, v) + ref, note, rawText := t.storeEmbeddedResource(ctx, v) if ref != "" { mediaRefs = append(mediaRefs, ref) } + if rawText != "" { + rawTextParts = append(rawTextParts, rawText) + } if note != "" { llmParts = append(llmParts, note) } @@ -325,7 +333,8 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont } forLLM := strings.Join(compactStrings(llmParts), "\n") - if artifactResult := t.persistLargeTextArtifact(forLLM); artifactResult != nil { + rawText := strings.Join(compactStrings(rawTextParts), "\n") + if artifactResult := t.persistLargeTextArtifact(rawText); artifactResult != nil { artifactResult.Media = mediaRefs return artifactResult } @@ -381,27 +390,29 @@ func (t *MCPTool) persistLargeTextArtifact(text string) *ToolResult { } } -func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) { +func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string, string) { if content == nil || content.Resource == nil { - return "", "[MCP returned an embedded resource without data.]" + return "", "[MCP returned an embedded resource without data.]", "" } resource := content.Resource if len(resource.Blob) > 0 { - return t.storeBinaryContent( + ref, note := t.storeBinaryContent( ctx, "resource", normalizedMIMEType(resource.MIMEType), resource.Blob, content.Annotations, ) + return ref, note, "" } - if strings.TrimSpace(resource.Text) != "" { - return "", sanitizeToolLLMContent(resource.Text) + rawText := strings.TrimSpace(resource.Text) + if rawText != "" { + return "", sanitizeToolLLMContent(resource.Text), rawText } - return "", summarizeEmbeddedResource(content) + return "", summarizeEmbeddedResource(content), "" } func (t *MCPTool) storeBinaryContent( diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index e972f5add..816ac712a 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -635,6 +635,49 @@ func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) { } } +func TestMCPTool_Execute_LargeBase64TextArtifactPreservesRawPayload(t *testing.T) { + workspace := t.TempDir() + largeBase64 := strings.Repeat("QUJD", 400) + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: largeBase64}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + mcpTool.SetWorkspace(workspace) + mcpTool.SetMaxInlineTextRunes(32) + + result := mcpTool.Execute(context.Background(), nil) + + if !strings.Contains(result.ForLLM, "saved as a local artifact") { + t.Fatalf("expected artifact note, got %q", result.ForLLM) + } + if result.ForLLM == largeBase64OmittedMessage { + t.Fatalf("expected artifact note instead of sanitized base64 placeholder") + } + if len(result.ArtifactTags) != 1 { + t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags)) + } + tag := result.ArtifactTags[0] + const prefix = "[file:" + if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") { + t.Fatalf("expected file artifact tag, got %q", tag) + } + path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected artifact file to be readable: %v", err) + } + if string(data) != largeBase64 { + t.Fatalf("expected artifact file contents to preserve raw MCP payload") + } +} + func TestMCPTool_Execute_LargeTextStoredAsArtifact(t *testing.T) { workspace := t.TempDir() largeText := strings.Repeat("This is a large MCP text payload.\n", 800)