diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index aad44e4eb..cbceeede2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1549,13 +1549,7 @@ public async Task OnToolCallV2(string sessionId, var result = await tool.InvokeAsync(aiFunctionArgs); - var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject - { - ResultType = "success", - TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je - ? je.GetString()! - : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), - }; + var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions); return new ToolCallResponseV2(toolResultObject); } catch (Exception ex) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 4e5142cb8..189cdfaff 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -568,13 +568,7 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, var result = await tool.InvokeAsync(aiFunctionArgs); - var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject - { - ResultType = "success", - TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je - ? je.GetString()! - : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), - }; + var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions); await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null); } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index d8262e140..1645b1eb0 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -324,6 +324,95 @@ public class ToolResultObject /// [JsonPropertyName("toolTelemetry")] public Dictionary? ToolTelemetry { get; set; } + + /// + /// Converts the result of an invocation into a + /// . Handles , + /// , and falls back to JSON serialization. + /// + internal static ToolResultObject ConvertFromInvocationResult(object? result, JsonSerializerOptions jsonOptions) + { + if (result is ToolResultAIContent trac) + { + return trac.Result; + } + + if (TryConvertFromAIContent(result) is { } aiConverted) + { + return aiConverted; + } + + return new ToolResultObject + { + ResultType = "success", + TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je + ? je.GetString()! + : JsonSerializer.Serialize(result, jsonOptions.GetTypeInfo(typeof(object))), + }; + } + + /// + /// Attempts to convert a result from an invocation into a + /// . Handles , + /// , and collections of . + /// Returns if the value is not a recognized type. + /// + internal static ToolResultObject? TryConvertFromAIContent(object? result) + { + if (result is AIContent singleContent) + { + return ConvertAIContents([singleContent]); + } + + if (result is IEnumerable contentList) + { + return ConvertAIContents(contentList); + } + + return null; + } + + private static ToolResultObject ConvertAIContents(IEnumerable contents) + { + List? textParts = null; + List? binaryResults = null; + + foreach (var content in contents) + { + switch (content) + { + case TextContent textContent: + if (textContent.Text is { } text) + { + (textParts ??= []).Add(text); + } + break; + + case DataContent dataContent: + (binaryResults ??= []).Add(new ToolBinaryResult + { + Data = dataContent.Base64Data.ToString(), + MimeType = dataContent.MediaType ?? "application/octet-stream", + Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource", + }); + break; + + default: + (textParts ??= []).Add(SerializeAIContent(content)); + break; + } + } + + return new ToolResultObject + { + TextResultForLlm = textParts is not null ? string.Join("\n", textParts) : "", + ResultType = "success", + BinaryResultsForLlm = binaryResults, + }; + } + + private static string SerializeAIContent(AIContent content) => + JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))); } /// diff --git a/go/definetool.go b/go/definetool.go index 406a8c0b8..ccaa69a58 100644 --- a/go/definetool.go +++ b/go/definetool.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "github.com/google/jsonschema-go/jsonschema" ) @@ -65,7 +66,8 @@ func createTypedHandler[T any, U any](handler func(T, ToolInvocation) (U, error) } // normalizeResult converts any value to a ToolResult. -// Strings pass through directly, ToolResult passes through, other types are JSON-serialized. +// Strings pass through directly, ToolResult passes through, and other types +// are JSON-serialized. func normalizeResult(result any) (ToolResult, error) { if result == nil { return ToolResult{ @@ -99,6 +101,104 @@ func normalizeResult(result any) (ToolResult, error) { }, nil } +// ConvertMCPCallToolResult converts an MCP CallToolResult value (a map or struct +// with a "content" array and optional "isError" bool) into a ToolResult. +// Returns the converted ToolResult and true if the value matched the expected +// shape, or a zero ToolResult and false otherwise. +func ConvertMCPCallToolResult(value any) (ToolResult, bool) { + m, ok := value.(map[string]any) + if !ok { + jsonBytes, err := json.Marshal(value) + if err != nil { + return ToolResult{}, false + } + + if err := json.Unmarshal(jsonBytes, &m); err != nil { + return ToolResult{}, false + } + } + + contentRaw, exists := m["content"] + if !exists { + return ToolResult{}, false + } + + contentSlice, ok := contentRaw.([]any) + if !ok { + return ToolResult{}, false + } + + // Verify every element has a string "type" field + for _, item := range contentSlice { + block, ok := item.(map[string]any) + if !ok { + return ToolResult{}, false + } + if _, ok := block["type"].(string); !ok { + return ToolResult{}, false + } + } + + var textParts []string + var binaryResults []ToolBinaryResult + + for _, item := range contentSlice { + block := item.(map[string]any) + blockType := block["type"].(string) + + switch blockType { + case "text": + if text, ok := block["text"].(string); ok { + textParts = append(textParts, text) + } + case "image": + data, _ := block["data"].(string) + mimeType, _ := block["mimeType"].(string) + if data == "" { + continue + } + binaryResults = append(binaryResults, ToolBinaryResult{ + Data: data, + MimeType: mimeType, + Type: "image", + }) + case "resource": + if resRaw, ok := block["resource"].(map[string]any); ok { + if text, ok := resRaw["text"].(string); ok && text != "" { + textParts = append(textParts, text) + } + if blob, ok := resRaw["blob"].(string); ok && blob != "" { + mimeType, _ := resRaw["mimeType"].(string) + if mimeType == "" { + mimeType = "application/octet-stream" + } + uri, _ := resRaw["uri"].(string) + binaryResults = append(binaryResults, ToolBinaryResult{ + Data: blob, + MimeType: mimeType, + Type: "resource", + Description: uri, + }) + } + } + } + } + + resultType := "success" + if isErr, ok := m["isError"].(bool); ok && isErr { + resultType = "failure" + } + + tr := ToolResult{ + TextResultForLLM: strings.Join(textParts, "\n"), + ResultType: resultType, + } + if len(binaryResults) > 0 { + tr.BinaryResultsForLLM = binaryResults + } + return tr, true +} + // generateSchemaForType generates a JSON schema map from a Go type using reflection. // Panics if schema generation fails, as this indicates a programming error. func generateSchemaForType(t reflect.Type) map[string]any { diff --git a/go/definetool_test.go b/go/definetool_test.go index af620b180..cc9fecb2c 100644 --- a/go/definetool_test.go +++ b/go/definetool_test.go @@ -253,6 +253,186 @@ func TestNormalizeResult(t *testing.T) { }) } +func TestConvertMCPCallToolResult(t *testing.T) { + t.Run("typed CallToolResult struct is converted", func(t *testing.T) { + type Resource struct { + URI string `json:"uri"` + Text string `json:"text"` + } + type ContentBlock struct { + Type string `json:"type"` + Resource *Resource `json:"resource,omitempty"` + } + type CallToolResult struct { + Content []ContentBlock `json:"content"` + } + + input := CallToolResult{ + Content: []ContentBlock{ + { + Type: "resource", + Resource: &Resource{URI: "file:///report.txt", Text: "details"}, + }, + }, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if result.TextResultForLLM != "details" { + t.Errorf("Expected 'details', got %q", result.TextResultForLLM) + } + if result.ResultType != "success" { + t.Errorf("Expected 'success', got %q", result.ResultType) + } + }) + + t.Run("text-only CallToolResult is converted", func(t *testing.T) { + input := map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "hello"}, + }, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if result.TextResultForLLM != "hello" { + t.Errorf("Expected 'hello', got %q", result.TextResultForLLM) + } + if result.ResultType != "success" { + t.Errorf("Expected 'success', got %q", result.ResultType) + } + }) + + t.Run("multiple text blocks are joined with newline", func(t *testing.T) { + input := map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "line 1"}, + map[string]any{"type": "text", "text": "line 2"}, + }, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if result.TextResultForLLM != "line 1\nline 2" { + t.Errorf("Expected 'line 1\\nline 2', got %q", result.TextResultForLLM) + } + }) + + t.Run("isError maps to failure resultType", func(t *testing.T) { + input := map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "oops"}, + }, + "isError": true, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if result.ResultType != "failure" { + t.Errorf("Expected 'failure', got %q", result.ResultType) + } + }) + + t.Run("image content becomes binaryResultsForLLM", func(t *testing.T) { + input := map[string]any{ + "content": []any{ + map[string]any{"type": "image", "data": "base64data", "mimeType": "image/png"}, + }, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if len(result.BinaryResultsForLLM) != 1 { + t.Fatalf("Expected 1 binary result, got %d", len(result.BinaryResultsForLLM)) + } + if result.BinaryResultsForLLM[0].Data != "base64data" { + t.Errorf("Expected data 'base64data', got %q", result.BinaryResultsForLLM[0].Data) + } + if result.BinaryResultsForLLM[0].MimeType != "image/png" { + t.Errorf("Expected mimeType 'image/png', got %q", result.BinaryResultsForLLM[0].MimeType) + } + }) + + t.Run("resource text goes to textResultForLLM", func(t *testing.T) { + input := map[string]any{ + "content": []any{ + map[string]any{ + "type": "resource", + "resource": map[string]any{"uri": "file:///tmp/data.txt", "text": "file contents"}, + }, + }, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if result.TextResultForLLM != "file contents" { + t.Errorf("Expected 'file contents', got %q", result.TextResultForLLM) + } + }) + + t.Run("resource blob goes to binaryResultsForLLM", func(t *testing.T) { + input := map[string]any{ + "content": []any{ + map[string]any{ + "type": "resource", + "resource": map[string]any{"uri": "file:///img.png", "blob": "blobdata", "mimeType": "image/png"}, + }, + }, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if len(result.BinaryResultsForLLM) != 1 { + t.Fatalf("Expected 1 binary result, got %d", len(result.BinaryResultsForLLM)) + } + if result.BinaryResultsForLLM[0].Description != "file:///img.png" { + t.Errorf("Expected description 'file:///img.png', got %q", result.BinaryResultsForLLM[0].Description) + } + }) + + t.Run("non-CallToolResult map returns false", func(t *testing.T) { + input := map[string]any{ + "key": "value", + } + + _, ok := ConvertMCPCallToolResult(input) + if ok { + t.Error("Expected ConvertMCPCallToolResult to return false for non-CallToolResult map") + } + }) + + t.Run("empty content array is converted", func(t *testing.T) { + input := map[string]any{ + "content": []any{}, + } + + result, ok := ConvertMCPCallToolResult(input) + if !ok { + t.Fatal("Expected ConvertMCPCallToolResult to succeed") + } + if result.TextResultForLLM != "" { + t.Errorf("Expected empty text, got %q", result.TextResultForLLM) + } + if result.ResultType != "success" { + t.Errorf("Expected 'success', got %q", result.ResultType) + } + }) +} + func TestGenerateSchemaForType(t *testing.T) { t.Run("generates schema for simple struct", func(t *testing.T) { type Simple struct { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 3fab122db..13e0670fb 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,12 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool, approveAll, SYSTEM_PROMPT_SECTIONS } from "./types.js"; +export { + defineTool, + approveAll, + convertMcpCallToolResult, + SYSTEM_PROMPT_SECTIONS, +} from "./types.js"; export type { CommandContext, CommandDefinition, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c2d095234..c8a27009d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -207,6 +207,101 @@ export type ToolResultObject = { export type ToolResult = string | ToolResultObject; +// ============================================================================ +// MCP CallToolResult support +// ============================================================================ + +/** + * Content block types within an MCP CallToolResult. + */ +type McpCallToolResultTextContent = { + type: "text"; + text: string; +}; + +type McpCallToolResultImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +type McpCallToolResultResourceContent = { + type: "resource"; + resource: { + uri: string; + mimeType?: string; + text?: string; + blob?: string; + }; +}; + +type McpCallToolResultContent = + | McpCallToolResultTextContent + | McpCallToolResultImageContent + | McpCallToolResultResourceContent; + +/** + * MCP-compatible CallToolResult type. Can be passed to + * {@link convertMcpCallToolResult} to produce a {@link ToolResultObject}. + */ +type McpCallToolResult = { + content: McpCallToolResultContent[]; + isError?: boolean; +}; + +/** + * Converts an MCP CallToolResult into the SDK's ToolResultObject format. + */ +export function convertMcpCallToolResult(callResult: McpCallToolResult): ToolResultObject { + const textParts: string[] = []; + const binaryResults: ToolBinaryResult[] = []; + + for (const block of callResult.content) { + switch (block.type) { + case "text": + // Guard against malformed input where text field is missing at runtime + if (typeof block.text === "string") { + textParts.push(block.text); + } + break; + case "image": + if ( + typeof block.data === "string" && + block.data && + typeof block.mimeType === "string" + ) { + binaryResults.push({ + data: block.data, + mimeType: block.mimeType, + type: "image", + }); + } + break; + case "resource": { + // Use optional chaining: resource field may be absent in malformed input + if (block.resource?.text) { + textParts.push(block.resource.text); + } + if (block.resource?.blob) { + binaryResults.push({ + data: block.resource.blob, + mimeType: block.resource.mimeType ?? "application/octet-stream", + type: "resource", + description: block.resource.uri, + }); + } + break; + } + } + } + + return { + textResultForLlm: textParts.join("\n"), + resultType: callResult.isError ? "failure" : "success", + ...(binaryResults.length > 0 ? { binaryResultsForLlm: binaryResults } : {}), + }; +} + export interface ToolInvocation { sessionId: string; toolCallId: string; diff --git a/nodejs/test/call-tool-result.test.ts b/nodejs/test/call-tool-result.test.ts new file mode 100644 index 000000000..132e482bd --- /dev/null +++ b/nodejs/test/call-tool-result.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { convertMcpCallToolResult } from "../src/types.js"; + +type McpCallToolResult = Parameters[0]; + +describe("convertMcpCallToolResult", () => { + it("extracts text from text content blocks", () => { + const input: McpCallToolResult = { + content: [ + { type: "text", text: "line 1" }, + { type: "text", text: "line 2" }, + ], + }; + + const result = convertMcpCallToolResult(input); + + expect(result.textResultForLlm).toBe("line 1\nline 2"); + expect(result.resultType).toBe("success"); + expect(result.binaryResultsForLlm).toBeUndefined(); + }); + + it("maps isError to failure resultType", () => { + const input: McpCallToolResult = { + content: [{ type: "text", text: "error occurred" }], + isError: true, + }; + + const result = convertMcpCallToolResult(input); + + expect(result.textResultForLlm).toBe("error occurred"); + expect(result.resultType).toBe("failure"); + }); + + it("maps isError: false to success", () => { + const input: McpCallToolResult = { + content: [{ type: "text", text: "ok" }], + isError: false, + }; + + expect(convertMcpCallToolResult(input).resultType).toBe("success"); + }); + + it("converts image content to binaryResultsForLlm", () => { + const input: McpCallToolResult = { + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + }; + + const result = convertMcpCallToolResult(input); + + expect(result.textResultForLlm).toBe(""); + expect(result.binaryResultsForLlm).toHaveLength(1); + expect(result.binaryResultsForLlm![0]).toEqual({ + data: "base64data", + mimeType: "image/png", + type: "image", + }); + }); + + it("converts resource with text to textResultForLlm", () => { + const input: McpCallToolResult = { + content: [ + { + type: "resource", + resource: { uri: "file:///tmp/data.txt", text: "file contents" }, + }, + ], + }; + + const result = convertMcpCallToolResult(input); + + expect(result.textResultForLlm).toBe("file contents"); + }); + + it("converts resource with blob to binaryResultsForLlm", () => { + const input: McpCallToolResult = { + content: [ + { + type: "resource", + resource: { + uri: "file:///tmp/image.png", + mimeType: "image/png", + blob: "blobdata", + }, + }, + ], + }; + + const result = convertMcpCallToolResult(input); + + expect(result.binaryResultsForLlm).toHaveLength(1); + expect(result.binaryResultsForLlm![0]).toEqual({ + data: "blobdata", + mimeType: "image/png", + type: "resource", + description: "file:///tmp/image.png", + }); + }); + + it("handles mixed content types", () => { + const input: McpCallToolResult = { + content: [ + { type: "text", text: "Analysis complete" }, + { type: "image", data: "chartdata", mimeType: "image/svg+xml" }, + { + type: "resource", + resource: { uri: "file:///report.txt", text: "Report details" }, + }, + ], + }; + + const result = convertMcpCallToolResult(input); + + expect(result.textResultForLlm).toBe("Analysis complete\nReport details"); + expect(result.binaryResultsForLlm).toHaveLength(1); + expect(result.binaryResultsForLlm![0]!.mimeType).toBe("image/svg+xml"); + }); + + it("handles empty content array", () => { + const result = convertMcpCallToolResult({ content: [] }); + + expect(result.textResultForLlm).toBe(""); + expect(result.resultType).toBe("success"); + expect(result.binaryResultsForLlm).toBeUndefined(); + }); + + it("defaults resource blob mimeType to application/octet-stream", () => { + const input: McpCallToolResult = { + content: [ + { + type: "resource", + resource: { uri: "file:///data.bin", blob: "binarydata" }, + }, + ], + }; + + const result = convertMcpCallToolResult(input); + + expect(result.binaryResultsForLlm![0]!.mimeType).toBe("application/octet-stream"); + }); + + it("handles text block with missing text field without corrupting output", () => { + // The input type uses structural typing, so type-specific fields might be absent + // at runtime. convertMcpCallToolResult must be defensive. + const input = { content: [{ type: "text" }] } as unknown as McpCallToolResult; + + const result = convertMcpCallToolResult(input); + + expect(result.textResultForLlm).toBe(""); + expect(result.textResultForLlm).not.toBe("undefined"); + }); + + it("handles resource block with missing resource field without crashing", () => { + // A resource content item missing the resource field would crash with an + // unguarded block.resource.text access. Optional chaining must be used. + const input = { content: [{ type: "resource" }] } as unknown as McpCallToolResult; + + expect(() => convertMcpCallToolResult(input)).not.toThrow(); + const result = convertMcpCallToolResult(input); + expect(result.textResultForLlm).toBe(""); + }); +}); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 702d35035..6333aea51 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -29,7 +29,7 @@ SessionUiApi, SessionUiCapabilities, ) -from .tools import define_tool +from .tools import convert_mcp_call_tool_result, define_tool __version__ = "0.1.0" @@ -55,5 +55,6 @@ "SessionUiApi", "SessionUiCapabilities", "SubprocessConfig", + "convert_mcp_call_tool_result", "define_tool", ] diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 66c660536..c94c396e9 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -274,3 +274,54 @@ def default(obj: Any) -> Any: text_result_for_llm=json_str, result_type="success", ) + + +def convert_mcp_call_tool_result(call_result: dict[str, Any]) -> ToolResult: + """Convert an MCP CallToolResult dict into a ToolResult.""" + text_parts: list[str] = [] + binary_results: list[ToolBinaryResult] = [] + + for block in call_result["content"]: + block_type = block.get("type") + if block_type == "text": + text = block.get("text", "") + if isinstance(text, str): + text_parts.append(text) + elif block_type == "image": + data = block.get("data", "") + mime_type = block.get("mimeType", "") + if isinstance(data, str) and data and isinstance(mime_type, str): + binary_results.append( + ToolBinaryResult( + data=data, + mime_type=mime_type, + type="image", + ) + ) + elif block_type == "resource": + resource = block.get("resource", {}) + if not isinstance(resource, dict): + continue + text = resource.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + blob = resource.get("blob") + if isinstance(blob, str) and blob: + mime_type = resource.get("mimeType", "application/octet-stream") + uri = resource.get("uri", "") + binary_results.append( + ToolBinaryResult( + data=blob, + mime_type=mime_type + if isinstance(mime_type, str) + else "application/octet-stream", + type="resource", + description=uri if isinstance(uri, str) else "", + ) + ) + + return ToolResult( + text_result_for_llm="\n".join(text_parts), + result_type="failure" if call_result.get("isError") is True else "success", + binary_results_for_llm=binary_results if binary_results else None, + ) diff --git a/python/e2e/test_tools_unit.py b/python/e2e/test_tools_unit.py index c9c996f0e..bbbe2190f 100644 --- a/python/e2e/test_tools_unit.py +++ b/python/e2e/test_tools_unit.py @@ -6,7 +6,12 @@ from pydantic import BaseModel, Field from copilot import define_tool -from copilot.tools import ToolInvocation, ToolResult, _normalize_result +from copilot.tools import ( + ToolInvocation, + ToolResult, + _normalize_result, + convert_mcp_call_tool_result, +) class TestDefineTool: @@ -284,3 +289,99 @@ def test_raises_for_unserializable_value(self): # Functions cannot be JSON serialized with pytest.raises(TypeError, match="Failed to serialize"): _normalize_result(lambda x: x) + + +class TestConvertMcpCallToolResult: + def test_text_only_call_tool_result(self): + result = convert_mcp_call_tool_result( + { + "content": [{"type": "text", "text": "hello"}], + } + ) + assert result.text_result_for_llm == "hello" + assert result.result_type == "success" + + def test_multiple_text_blocks(self): + result = convert_mcp_call_tool_result( + { + "content": [ + {"type": "text", "text": "line 1"}, + {"type": "text", "text": "line 2"}, + ], + } + ) + assert result.text_result_for_llm == "line 1\nline 2" + + def test_is_error_maps_to_failure(self): + result = convert_mcp_call_tool_result( + { + "content": [{"type": "text", "text": "oops"}], + "isError": True, + } + ) + assert result.result_type == "failure" + + def test_is_error_false_maps_to_success(self): + result = convert_mcp_call_tool_result( + { + "content": [{"type": "text", "text": "ok"}], + "isError": False, + } + ) + assert result.result_type == "success" + + def test_image_content_to_binary(self): + result = convert_mcp_call_tool_result( + { + "content": [{"type": "image", "data": "base64data", "mimeType": "image/png"}], + } + ) + assert result.binary_results_for_llm is not None + assert len(result.binary_results_for_llm) == 1 + assert result.binary_results_for_llm[0].data == "base64data" + assert result.binary_results_for_llm[0].mime_type == "image/png" + assert result.binary_results_for_llm[0].type == "image" + + def test_resource_text_to_text_result(self): + result = convert_mcp_call_tool_result( + { + "content": [ + { + "type": "resource", + "resource": {"uri": "file:///data.txt", "text": "file contents"}, + }, + ], + } + ) + assert result.text_result_for_llm == "file contents" + + def test_resource_blob_to_binary(self): + result = convert_mcp_call_tool_result( + { + "content": [ + { + "type": "resource", + "resource": { + "uri": "file:///img.png", + "blob": "blobdata", + "mimeType": "image/png", + }, + }, + ], + } + ) + assert result.binary_results_for_llm is not None + assert len(result.binary_results_for_llm) == 1 + assert result.binary_results_for_llm[0].data == "blobdata" + assert result.binary_results_for_llm[0].description == "file:///img.png" + + def test_empty_content_array(self): + result = convert_mcp_call_tool_result({"content": []}) + assert result.text_result_for_llm == "" + assert result.result_type == "success" + + def test_call_tool_result_dict_is_json_serialized_by_normalize(self): + """_normalize_result does NOT auto-detect MCP results; it JSON-serializes them.""" + result = _normalize_result({"content": [{"type": "text", "text": "hello"}]}) + parsed = json.loads(result.text_result_for_llm) + assert parsed == {"content": [{"type": "text", "text": "hello"}]}