diff --git a/tool/mcptoolset/tool.go b/tool/mcptoolset/tool.go index ec73d8f67..2b9aa80a9 100644 --- a/tool/mcptoolset/tool.go +++ b/tool/mcptoolset/tool.go @@ -17,7 +17,9 @@ package mcptoolset import ( "errors" "fmt" + "mime" "strings" + "unicode/utf8" "github.com/modelcontextprotocol/go-sdk/mcp" "google.golang.org/genai" @@ -126,51 +128,218 @@ func (t *mcpTool) Run(ctx agent.Context, args any) (map[string]any, error) { } if res.IsError { - details := strings.Builder{} - for _, c := range res.Content { - textContent, ok := c.(*mcp.TextContent) - if !ok { - continue - } - if _, err := details.WriteString(textContent.Text); err != nil { - return nil, fmt.Errorf("failed to write error details: %w", err) - } - } + details, _ := formatMCPContent(res.Content) errMsg := "Tool execution failed." - if details.Len() > 0 { - errMsg += " Details: " + details.String() + if details != "" { + errMsg += " Details: " + details } return nil, errors.New(errMsg) } + content, hasNonText := formatMCPContent(res.Content) + if res.StructuredContent != nil { - return map[string]any{ + result := map[string]any{ "output": res.StructuredContent, - }, nil + } + if hasNonText && content != "" { + result["content"] = content + } + return result, nil } - textResponse := strings.Builder{} + if content == "" { + return nil, errors.New("no text content in tool response") + } - for _, c := range res.Content { - textContent, ok := c.(*mcp.TextContent) - if !ok { - continue + return map[string]any{ + "output": content, + }, nil +} + +type formattedMCPContent struct { + text string + isPlain bool +} + +// formatMCPContent renders MCP's ordered content blocks into the text-only +// response shape supported by FunctionTool.Run. The boolean reports whether +// the result contains a non-text block that must accompany structured output. +func formatMCPContent(contents []mcp.Content) (string, bool) { + formatted := make([]formattedMCPContent, 0, len(contents)) + hasNonText := false + for _, content := range contents { + block := formattedMCPContent{isPlain: true} + switch content := content.(type) { + case *mcp.TextContent: + if content == nil { + block.text = "[MCP text content: unavailable]" + block.isPlain = false + } else { + block.text = content.Text + } + case *mcp.EmbeddedResource: + block.text = formatEmbeddedResource(content) + block.isPlain = false + case *mcp.ResourceLink: + block.text = formatResourceLink(content) + block.isPlain = false + case *mcp.ImageContent: + if content == nil { + block.text = "[MCP image: unavailable]" + } else { + block.text = formatMediaContent("image", content.MIMEType, len(content.Data)) + } + block.isPlain = false + case *mcp.AudioContent: + if content == nil { + block.text = "[MCP audio: unavailable]" + } else { + block.text = formatMediaContent("audio", content.MIMEType, len(content.Data)) + } + block.isPlain = false + default: + block.text = fmt.Sprintf("[MCP content: unsupported type %T]", content) + block.isPlain = false + } + if !block.isPlain { + hasNonText = true } + formatted = append(formatted, block) + } - if _, err := textResponse.WriteString(textContent.Text); err != nil { - return nil, fmt.Errorf("failed to write text response: %w", err) + var result strings.Builder + var previous *formattedMCPContent + for i := range formatted { + block := &formatted[i] + if block.text == "" { + continue } + if previous != nil && (!previous.isPlain || !block.isPlain) && + !strings.HasSuffix(previous.text, "\n") && !strings.HasPrefix(block.text, "\n") { + result.WriteByte('\n') + } + result.WriteString(block.text) + previous = block } + return result.String(), hasNonText +} - if textResponse.Len() == 0 { - return nil, errors.New("no text content in tool response") +func formatEmbeddedResource(content *mcp.EmbeddedResource) string { + if content == nil || content.Resource == nil { + return "[MCP embedded resource: unavailable]" } - return map[string]any{ - "output": textResponse.String(), - }, nil + resource := content.Resource + attributes := resourceAttributes(resource.URI, resource.MIMEType) + if resource.Text != "" { + return formatContentWithBody("embedded resource", attributes, resource.Text) + } + if text, ok := decodeTextBlob(resource.Blob, resource.MIMEType); ok { + return formatContentWithBody("embedded resource", attributes, text) + } + if len(resource.Blob) > 0 { + attributes = append(attributes, fmt.Sprintf("size=%d bytes", len(resource.Blob))) + } + return formatContentLabel("embedded resource", attributes) +} + +func formatResourceLink(content *mcp.ResourceLink) string { + if content == nil { + return "[MCP resource link: unavailable]" + } + + attributes := resourceAttributes(content.URI, content.MIMEType) + if content.Name != "" { + attributes = append(attributes, fmt.Sprintf("name=%q", content.Name)) + } + if content.Title != "" { + attributes = append(attributes, fmt.Sprintf("title=%q", content.Title)) + } + if content.Description != "" { + attributes = append(attributes, fmt.Sprintf("description=%q", content.Description)) + } + if content.Size != nil { + attributes = append(attributes, fmt.Sprintf("size=%d bytes", *content.Size)) + } + return formatContentLabel("resource link", attributes) +} + +func formatMediaContent(kind, mimeType string, size int) string { + attributes := make([]string, 0, 2) + if mimeType != "" { + attributes = append(attributes, fmt.Sprintf("mimeType=%q", mimeType)) + } + attributes = append(attributes, fmt.Sprintf("size=%d bytes", size)) + return formatContentLabel(kind, attributes) +} + +func resourceAttributes(uri, mimeType string) []string { + attributes := make([]string, 0, 2) + if uri != "" { + attributes = append(attributes, fmt.Sprintf("uri=%q", uri)) + } + if mimeType != "" { + attributes = append(attributes, fmt.Sprintf("mimeType=%q", mimeType)) + } + return attributes +} + +func formatContentWithBody(kind string, attributes []string, body string) string { + return formatContentLabel(kind, attributes) + "\n" + body +} + +func formatContentLabel(kind string, attributes []string) string { + if len(attributes) == 0 { + return "[MCP " + kind + "]" + } + return "[MCP " + kind + ": " + strings.Join(attributes, ", ") + "]" +} + +func decodeTextBlob(blob []byte, mimeType string) (string, bool) { + if len(blob) == 0 { + return "", false + } + + mediaType, params, err := mime.ParseMediaType(mimeType) + if err != nil { + mediaType = strings.ToLower(strings.TrimSpace(strings.SplitN(mimeType, ";", 2)[0])) + } + if !isTextMediaType(mediaType) { + return "", false + } + + charset := strings.ToLower(params["charset"]) + switch charset { + case "", "utf-8", "utf8": + if !utf8.Valid(blob) { + return "", false + } + case "us-ascii": + for _, b := range blob { + if b >= utf8.RuneSelf { + return "", false + } + } + default: + return "", false + } + return string(blob), true +} + +func isTextMediaType(mediaType string) bool { + if strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml") { + return true + } + switch mediaType { + case "application/json", "application/javascript", "application/toml", "application/xml", + "application/x-yaml", "application/yaml", "image/svg+xml": + return true + default: + return false + } } var ( diff --git a/tool/mcptoolset/tool_test.go b/tool/mcptoolset/tool_test.go new file mode 100644 index 000000000..20d06f004 --- /dev/null +++ b/tool/mcptoolset/tool_test.go @@ -0,0 +1,214 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcptoolset + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "google.golang.org/adk/v2/agent" +) + +type fixedResultMCPClient struct { + result *mcp.CallToolResult +} + +func (c *fixedResultMCPClient) CallTool(context.Context, *mcp.CallToolParams) (*mcp.CallToolResult, error) { + return c.result, nil +} + +func (*fixedResultMCPClient) ListTools(context.Context) ([]*mcp.Tool, error) { + return nil, nil +} + +func TestMCPToolRunContent(t *testing.T) { + resourceSize := int64(1 << 20) + tests := []struct { + name string + result *mcp.CallToolResult + want map[string]any + wantErr string + }{ + { + name: "text only remains compatible", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.TextContent{Text: "first"}, + &mcp.TextContent{Text: "second"}, + }}, + want: map[string]any{"output": "firstsecond"}, + }, + { + name: "GitHub file response includes embedded text", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.TextContent{Text: "successfully downloaded text file"}, + &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: "repo://owner/project/main/file.go", + MIMEType: "text/plain", + Text: "package example\n", + }}, + }}, + want: map[string]any{"output": "successfully downloaded text file\n" + + "[MCP embedded resource: uri=\"repo://owner/project/main/file.go\", mimeType=\"text/plain\"]\n" + + "package example\n"}, + }, + { + name: "text MIME blob is decoded", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: "file:///response.json", + MIMEType: "application/problem+json; charset=utf-8", + Blob: []byte(`{"status":"ok"}`), + }}, + }}, + want: map[string]any{"output": "[MCP embedded resource: uri=\"file:///response.json\", " + + "mimeType=\"application/problem+json; charset=utf-8\"]\n{\"status\":\"ok\"}"}, + }, + { + name: "binary resource is represented by metadata", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: "file:///report.pdf", + MIMEType: "application/pdf", + Blob: []byte{1, 2, 3, 4}, + }}, + }}, + want: map[string]any{"output": "[MCP embedded resource: uri=\"file:///report.pdf\", " + + "mimeType=\"application/pdf\", size=4 bytes]"}, + }, + { + name: "unsupported text charset is represented by metadata", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: "file:///utf16.txt", + MIMEType: "text/plain; charset=utf-16", + Blob: []byte{0xff, 0xfe, 0x68, 0x00, 0x69, 0x00}, + }}, + }}, + want: map[string]any{"output": "[MCP embedded resource: uri=\"file:///utf16.txt\", " + + "mimeType=\"text/plain; charset=utf-16\", size=6 bytes]"}, + }, + { + name: "invalid UTF-8 text blob is represented by metadata", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: "file:///invalid.txt", + MIMEType: "text/plain", + Blob: []byte{0xc3, 0x28}, + }}, + }}, + want: map[string]any{"output": "[MCP embedded resource: uri=\"file:///invalid.txt\", " + + "mimeType=\"text/plain\", size=2 bytes]"}, + }, + { + name: "resource link includes available metadata", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.ResourceLink{ + URI: "https://example.com/archive.txt", + Name: "archive.txt", + Title: "Archive", + Description: "Large text file", + MIMEType: "text/plain", + Size: &resourceSize, + }, + }}, + want: map[string]any{"output": "[MCP resource link: uri=\"https://example.com/archive.txt\", " + + "mimeType=\"text/plain\", name=\"archive.txt\", title=\"Archive\", " + + "description=\"Large text file\", size=1048576 bytes]"}, + }, + { + name: "image and audio are represented in order", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.TextContent{Text: "media:"}, + &mcp.ImageContent{MIMEType: "image/png", Data: []byte{1, 2, 3}}, + &mcp.AudioContent{MIMEType: "audio/wav", Data: []byte{4, 5}}, + &mcp.TextContent{}, + &mcp.TextContent{Text: "done"}, + }}, + want: map[string]any{"output": "media:\n[MCP image: mimeType=\"image/png\", size=3 bytes]\n" + + "[MCP audio: mimeType=\"audio/wav\", size=2 bytes]\ndone"}, + }, + { + name: "structured output retains non-text content", + result: &mcp.CallToolResult{ + StructuredContent: map[string]any{"sha": "abc123"}, + Content: []mcp.Content{ + &mcp.TextContent{Text: "downloaded"}, + &mcp.EmbeddedResource{Resource: &mcp.ResourceContents{ + URI: "repo://owner/project/file.txt", + MIMEType: "text/plain", + Text: "file contents", + }}, + }, + }, + want: map[string]any{ + "output": map[string]any{"sha": "abc123"}, + "content": "downloaded\n[MCP embedded resource: uri=\"repo://owner/project/file.txt\", " + + "mimeType=\"text/plain\"]\nfile contents", + }, + }, + { + name: "structured output with text only remains compatible", + result: &mcp.CallToolResult{ + StructuredContent: map[string]any{"status": "ok"}, + Content: []mcp.Content{&mcp.TextContent{Text: `{"status":"ok"}`}}, + }, + want: map[string]any{"output": map[string]any{"status": "ok"}}, + }, + { + name: "error response includes non-text details", + result: &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: "download failed"}, + &mcp.ResourceLink{URI: "https://example.com/error", MIMEType: "text/plain"}, + }, + }, + wantErr: "Tool execution failed. Details: download failed\n" + + "[MCP resource link: uri=\"https://example.com/error\", mimeType=\"text/plain\"]", + }, + { + name: "nil resource does not panic", + result: &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.EmbeddedResource{}, + }}, + want: map[string]any{"output": "[MCP embedded resource: unavailable]"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tool := &mcpTool{ + name: "test_tool", + mcpClient: &fixedResultMCPClient{result: test.result}, + } + got, err := tool.Run(&agent.ContextMock{}, map[string]any{}) + if test.wantErr != "" { + if err == nil || err.Error() != test.wantErr { + t.Fatalf("Run() error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf("Run() result mismatch (-want +got):\n%s", diff) + } + }) + } +}