diff --git a/tool/mcptool/mcp.go b/tool/mcptool/mcp.go index 6e57b5b9..28ebd695 100644 --- a/tool/mcptool/mcp.go +++ b/tool/mcptool/mcp.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "strings" + "unicode/utf8" "github.com/microsoft/agent-framework-go/message" "github.com/microsoft/agent-framework-go/tool" @@ -349,6 +350,24 @@ func agentContentToMCPContent(contentValue message.Content) mcp.Content { return &mcp.ImageContent{Data: data, MIMEType: c.MediaType} case "audio": return &mcp.AudioContent{Data: data, MIMEType: c.MediaType} + case "text": + // Text resources carry their payload in Text, not Blob. The reverse + // mapping (mcpContentToAgentContent) already reads Resource.Text for + // text; emitting Blob here would make text unreadable to MCP clients. + // Non-UTF-8 payloads cannot survive JSON transport as Text (invalid + // sequences are replaced), so fall back to Blob for those. + if utf8.Valid(data) { + return &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: c.Name, + MIMEType: c.MediaType, + Text: string(data), + }} + } + return &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: c.Name, + MIMEType: c.MediaType, + Blob: data, + }} default: return &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ URI: c.Name, diff --git a/tool/mcptool/mcp_test.go b/tool/mcptool/mcp_test.go index c06203b1..28738253 100644 --- a/tool/mcptool/mcp_test.go +++ b/tool/mcptool/mcp_test.go @@ -706,6 +706,35 @@ func TestAddToolReturnsDataAndMultipleContentResults(t *testing.T) { }) } +// A text-typed DataContent whose bytes are not valid UTF-8 must fall back to a +// binary Blob resource; putting invalid UTF-8 in Text corrupts it on transport. +func TestAddToolReturnsInvalidUTF8TextAsBlob(t *testing.T) { + result := callAddedTool(t, stubFuncTool{ + name: "bad-utf8-result", + description: "returns text media type with non-UTF-8 bytes", + schema: map[string]any{"type": "object"}, + returnSchema: map[string]any{"type": "object"}, + call: func(context.Context, string) (any, error) { + // "//4=" is base64 for {0xff, 0xfe}, which is not valid UTF-8. + return &message.DataContent{Name: "note.txt", Data: "//4=", MediaType: "text/plain"}, nil + }, + }) + + if len(result.Content) != 1 { + t.Fatalf("expected one content item, got %d", len(result.Content)) + } + embedded, ok := result.Content[0].(*mcp.EmbeddedResource) + if !ok { + t.Fatalf("content is %T, want *mcp.EmbeddedResource", result.Content[0]) + } + if embedded.Resource.Text != "" { + t.Errorf("Resource.Text = %q, want empty (non-UTF-8 must not be placed in Text)", embedded.Resource.Text) + } + if len(embedded.Resource.Blob) == 0 { + t.Error("Resource.Blob is empty, want the raw non-UTF-8 bytes") + } +} + func TestAddToolReturnsBinaryDataAsEmbeddedResource(t *testing.T) { result := callAddedTool(t, stubFuncTool{ name: "binary-result", @@ -732,6 +761,35 @@ func TestAddToolReturnsBinaryDataAsEmbeddedResource(t *testing.T) { } } +// A text DataContent must be surfaced as an MCP text resource (Resource.Text), +// not a binary Blob; the reverse mapping reads Resource.Text for text resources. +func TestAddToolReturnsTextDataAsTextResource(t *testing.T) { + result := callAddedTool(t, stubFuncTool{ + name: "text-result", + description: "returns text content", + schema: map[string]any{"type": "object"}, + returnSchema: map[string]any{"type": "object"}, + call: func(context.Context, string) (any, error) { + // "hello" base64-encoded, with a text media type. + return &message.DataContent{Name: "note.txt", Data: "aGVsbG8=", MediaType: "text/plain"}, nil + }, + }) + + if len(result.Content) != 1 { + t.Fatalf("expected one content item, got %d", len(result.Content)) + } + embedded, ok := result.Content[0].(*mcp.EmbeddedResource) + if !ok { + t.Fatalf("content is %T, want *mcp.EmbeddedResource", result.Content[0]) + } + if embedded.Resource.Text != "hello" { + t.Errorf("resource Text = %q, want %q", embedded.Resource.Text, "hello") + } + if len(embedded.Resource.Blob) != 0 { + t.Errorf("text resource must not use Blob, got %#v", embedded.Resource.Blob) + } +} + func TestCallReturnsEmptyAndStructuredOnlyMCPResults(t *testing.T) { t.Run("empty", func(t *testing.T) { result := callMCPResult(t, &mcp.CallToolResult{})