Skip to content

Commit d2130ff

Browse files
stephentoubCopilotSteveSandersonMS
authored
Fix CallToolResult handling across all SDKs (#1049)
* Fix CallToolResult handling across all SDKs When a tool handler returns an MCP CallToolResult object ({ content: [...], isError?: bool }), all four SDKs were JSON-serializing it instead of converting it to ToolResultObject. This caused the LLM to see raw JSON instead of actual tool output. Add detection and conversion of CallToolResult in Node.js, Python, Go, and .NET. The .NET SDK additionally handles Microsoft.Extensions.AI content types (TextContent, DataContent, and unknown subtypes via AIJsonUtilities serialization). Fixes #937 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix formatting and lint issues Run prettier on Node.js files, ruff format on Python files, and remove unused ToolResultObject import from test file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unused _convert_call_tool_result import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: add type guards in Python, fix Go comment typo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Python import sorting in test_tools_unit.py Sort imports in copilot.tools import block to satisfy ruff I001 rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove isCallToolResult and convertCallToolResult from public exports These are internal implementation details used by session.ts and client.ts. Go and Python already keep them private (lowercase/underscore-prefixed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * TypeScript formatting Co-Authored-By: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com> * Address review feedback: explicit MCP conversion, shared .NET helper, consistent guards - Remove implicit duck-typing of MCP CallToolResult from all SDKs - Add explicit public conversion: convertMcpCallToolResult (TS), ConvertMCPCallToolResult (Go), convert_mcp_call_tool_result (Python) - Extract shared ConvertFromInvocationResult helper in .NET - Remove isCallToolResult type guard (TS) and _is_call_tool_result (Python) - Rename types/functions to include 'Mcp' prefix across all languages - Make McpCallToolResult type non-exported in TS (structural typing) - Skip image blocks with empty data consistently across TS/Go/Python - Update all tests to use explicit conversion functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI: prettier formatting and ruff import sorting - Fix prettier line-length violation in nodejs/src/types.ts (long if condition) - Fix ruff I001 import sorting in python/e2e/test_tools_unit.py (_normalize_result before convert_mcp_call_tool_result) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Co-authored-by: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com>
1 parent 6029b37 commit d2130ff

File tree

11 files changed

+789
-18
lines changed

11 files changed

+789
-18
lines changed

dotnet/src/Client.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,13 +1549,7 @@ public async Task<ToolCallResponseV2> OnToolCallV2(string sessionId,
15491549

15501550
var result = await tool.InvokeAsync(aiFunctionArgs);
15511551

1552-
var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject
1553-
{
1554-
ResultType = "success",
1555-
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
1556-
? je.GetString()!
1557-
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
1558-
};
1552+
var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions);
15591553
return new ToolCallResponseV2(toolResultObject);
15601554
}
15611555
catch (Exception ex)

dotnet/src/Session.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -568,13 +568,7 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName,
568568

569569
var result = await tool.InvokeAsync(aiFunctionArgs);
570570

571-
var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject
572-
{
573-
ResultType = "success",
574-
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
575-
? je.GetString()!
576-
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
577-
};
571+
var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions);
578572

579573
await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null);
580574
}

dotnet/src/Types.cs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,95 @@ public class ToolResultObject
324324
/// </summary>
325325
[JsonPropertyName("toolTelemetry")]
326326
public Dictionary<string, object>? ToolTelemetry { get; set; }
327+
328+
/// <summary>
329+
/// Converts the result of an <see cref="AIFunction"/> invocation into a
330+
/// <see cref="ToolResultObject"/>. Handles <see cref="ToolResultAIContent"/>,
331+
/// <see cref="AIContent"/>, and falls back to JSON serialization.
332+
/// </summary>
333+
internal static ToolResultObject ConvertFromInvocationResult(object? result, JsonSerializerOptions jsonOptions)
334+
{
335+
if (result is ToolResultAIContent trac)
336+
{
337+
return trac.Result;
338+
}
339+
340+
if (TryConvertFromAIContent(result) is { } aiConverted)
341+
{
342+
return aiConverted;
343+
}
344+
345+
return new ToolResultObject
346+
{
347+
ResultType = "success",
348+
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
349+
? je.GetString()!
350+
: JsonSerializer.Serialize(result, jsonOptions.GetTypeInfo(typeof(object))),
351+
};
352+
}
353+
354+
/// <summary>
355+
/// Attempts to convert a result from an <see cref="AIFunction"/> invocation into a
356+
/// <see cref="ToolResultObject"/>. Handles <see cref="TextContent"/>,
357+
/// <see cref="DataContent"/>, and collections of <see cref="AIContent"/>.
358+
/// Returns <see langword="null"/> if the value is not a recognized <see cref="AIContent"/> type.
359+
/// </summary>
360+
internal static ToolResultObject? TryConvertFromAIContent(object? result)
361+
{
362+
if (result is AIContent singleContent)
363+
{
364+
return ConvertAIContents([singleContent]);
365+
}
366+
367+
if (result is IEnumerable<AIContent> contentList)
368+
{
369+
return ConvertAIContents(contentList);
370+
}
371+
372+
return null;
373+
}
374+
375+
private static ToolResultObject ConvertAIContents(IEnumerable<AIContent> contents)
376+
{
377+
List<string>? textParts = null;
378+
List<ToolBinaryResult>? binaryResults = null;
379+
380+
foreach (var content in contents)
381+
{
382+
switch (content)
383+
{
384+
case TextContent textContent:
385+
if (textContent.Text is { } text)
386+
{
387+
(textParts ??= []).Add(text);
388+
}
389+
break;
390+
391+
case DataContent dataContent:
392+
(binaryResults ??= []).Add(new ToolBinaryResult
393+
{
394+
Data = dataContent.Base64Data.ToString(),
395+
MimeType = dataContent.MediaType ?? "application/octet-stream",
396+
Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource",
397+
});
398+
break;
399+
400+
default:
401+
(textParts ??= []).Add(SerializeAIContent(content));
402+
break;
403+
}
404+
}
405+
406+
return new ToolResultObject
407+
{
408+
TextResultForLlm = textParts is not null ? string.Join("\n", textParts) : "",
409+
ResultType = "success",
410+
BinaryResultsForLlm = binaryResults,
411+
};
412+
}
413+
414+
private static string SerializeAIContent(AIContent content) =>
415+
JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent)));
327416
}
328417

329418
/// <summary>

go/definetool.go

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/json"
99
"fmt"
1010
"reflect"
11+
"strings"
1112

1213
"github.com/google/jsonschema-go/jsonschema"
1314
)
@@ -65,7 +66,8 @@ func createTypedHandler[T any, U any](handler func(T, ToolInvocation) (U, error)
6566
}
6667

6768
// normalizeResult converts any value to a ToolResult.
68-
// Strings pass through directly, ToolResult passes through, other types are JSON-serialized.
69+
// Strings pass through directly, ToolResult passes through, and other types
70+
// are JSON-serialized.
6971
func normalizeResult(result any) (ToolResult, error) {
7072
if result == nil {
7173
return ToolResult{
@@ -99,6 +101,104 @@ func normalizeResult(result any) (ToolResult, error) {
99101
}, nil
100102
}
101103

104+
// ConvertMCPCallToolResult converts an MCP CallToolResult value (a map or struct
105+
// with a "content" array and optional "isError" bool) into a ToolResult.
106+
// Returns the converted ToolResult and true if the value matched the expected
107+
// shape, or a zero ToolResult and false otherwise.
108+
func ConvertMCPCallToolResult(value any) (ToolResult, bool) {
109+
m, ok := value.(map[string]any)
110+
if !ok {
111+
jsonBytes, err := json.Marshal(value)
112+
if err != nil {
113+
return ToolResult{}, false
114+
}
115+
116+
if err := json.Unmarshal(jsonBytes, &m); err != nil {
117+
return ToolResult{}, false
118+
}
119+
}
120+
121+
contentRaw, exists := m["content"]
122+
if !exists {
123+
return ToolResult{}, false
124+
}
125+
126+
contentSlice, ok := contentRaw.([]any)
127+
if !ok {
128+
return ToolResult{}, false
129+
}
130+
131+
// Verify every element has a string "type" field
132+
for _, item := range contentSlice {
133+
block, ok := item.(map[string]any)
134+
if !ok {
135+
return ToolResult{}, false
136+
}
137+
if _, ok := block["type"].(string); !ok {
138+
return ToolResult{}, false
139+
}
140+
}
141+
142+
var textParts []string
143+
var binaryResults []ToolBinaryResult
144+
145+
for _, item := range contentSlice {
146+
block := item.(map[string]any)
147+
blockType := block["type"].(string)
148+
149+
switch blockType {
150+
case "text":
151+
if text, ok := block["text"].(string); ok {
152+
textParts = append(textParts, text)
153+
}
154+
case "image":
155+
data, _ := block["data"].(string)
156+
mimeType, _ := block["mimeType"].(string)
157+
if data == "" {
158+
continue
159+
}
160+
binaryResults = append(binaryResults, ToolBinaryResult{
161+
Data: data,
162+
MimeType: mimeType,
163+
Type: "image",
164+
})
165+
case "resource":
166+
if resRaw, ok := block["resource"].(map[string]any); ok {
167+
if text, ok := resRaw["text"].(string); ok && text != "" {
168+
textParts = append(textParts, text)
169+
}
170+
if blob, ok := resRaw["blob"].(string); ok && blob != "" {
171+
mimeType, _ := resRaw["mimeType"].(string)
172+
if mimeType == "" {
173+
mimeType = "application/octet-stream"
174+
}
175+
uri, _ := resRaw["uri"].(string)
176+
binaryResults = append(binaryResults, ToolBinaryResult{
177+
Data: blob,
178+
MimeType: mimeType,
179+
Type: "resource",
180+
Description: uri,
181+
})
182+
}
183+
}
184+
}
185+
}
186+
187+
resultType := "success"
188+
if isErr, ok := m["isError"].(bool); ok && isErr {
189+
resultType = "failure"
190+
}
191+
192+
tr := ToolResult{
193+
TextResultForLLM: strings.Join(textParts, "\n"),
194+
ResultType: resultType,
195+
}
196+
if len(binaryResults) > 0 {
197+
tr.BinaryResultsForLLM = binaryResults
198+
}
199+
return tr, true
200+
}
201+
102202
// generateSchemaForType generates a JSON schema map from a Go type using reflection.
103203
// Panics if schema generation fails, as this indicates a programming error.
104204
func generateSchemaForType(t reflect.Type) map[string]any {

0 commit comments

Comments
 (0)