Skip to content

Commit bbefa61

Browse files
stephentoubCopilot
andcommitted
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>
1 parent 6029b37 commit bbefa61

12 files changed

Lines changed: 989 additions & 16 deletions

File tree

dotnet/src/Client.cs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,13 +1549,29 @@ 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
1552+
ToolResultObject toolResultObject;
1553+
if (result is ToolResultAIContent trac)
15531554
{
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-
};
1555+
toolResultObject = trac.Result;
1556+
}
1557+
else if (ToolResultObject.TryConvertFromAIContent(result) is { } aiConverted)
1558+
{
1559+
toolResultObject = aiConverted;
1560+
}
1561+
else if (ToolResultObject.TryConvertFromCallToolResult(result) is { } converted)
1562+
{
1563+
toolResultObject = converted;
1564+
}
1565+
else
1566+
{
1567+
toolResultObject = new ToolResultObject
1568+
{
1569+
ResultType = "success",
1570+
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
1571+
? je.GetString()!
1572+
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
1573+
};
1574+
}
15591575
return new ToolCallResponseV2(toolResultObject);
15601576
}
15611577
catch (Exception ex)

dotnet/src/Session.cs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -568,13 +568,29 @@ 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
571+
ToolResultObject toolResultObject;
572+
if (result is ToolResultAIContent trac)
572573
{
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-
};
574+
toolResultObject = trac.Result;
575+
}
576+
else if (ToolResultObject.TryConvertFromAIContent(result) is { } aiConverted)
577+
{
578+
toolResultObject = aiConverted;
579+
}
580+
else if (ToolResultObject.TryConvertFromCallToolResult(result) is { } converted)
581+
{
582+
toolResultObject = converted;
583+
}
584+
else
585+
{
586+
toolResultObject = new ToolResultObject
587+
{
588+
ResultType = "success",
589+
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } strJe
590+
? strJe.GetString()!
591+
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
592+
};
593+
}
578594

579595
await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null);
580596
}

dotnet/src/Types.cs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,177 @@ public class ToolResultObject
324324
/// </summary>
325325
[JsonPropertyName("toolTelemetry")]
326326
public Dictionary<string, object>? ToolTelemetry { get; set; }
327+
328+
/// <summary>
329+
/// Attempts to interpret the result as an MCP <c>CallToolResult</c>
330+
/// (shape: <c>{ content: [...], isError?: bool }</c>) and converts it to a
331+
/// <see cref="ToolResultObject"/>. Returns <see langword="null"/> if the value does
332+
/// not match the expected shape.
333+
/// </summary>
334+
internal static ToolResultObject? TryConvertFromCallToolResult(object? result)
335+
{
336+
if (result is not JsonElement element)
337+
{
338+
return null;
339+
}
340+
341+
if (element.ValueKind != JsonValueKind.Object)
342+
{
343+
return null;
344+
}
345+
346+
if (!element.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.Array)
347+
{
348+
return null;
349+
}
350+
351+
// Validate every element has a string "type" field
352+
foreach (var item in contentProp.EnumerateArray())
353+
{
354+
if (item.ValueKind != JsonValueKind.Object ||
355+
!item.TryGetProperty("type", out var typeProp) ||
356+
typeProp.ValueKind != JsonValueKind.String)
357+
{
358+
return null;
359+
}
360+
}
361+
362+
List<string>? textParts = null;
363+
List<ToolBinaryResult>? binaryResults = null;
364+
365+
foreach (var block in contentProp.EnumerateArray())
366+
{
367+
var blockType = block.GetProperty("type").GetString();
368+
369+
switch (blockType)
370+
{
371+
case "text":
372+
if (block.TryGetProperty("text", out var textProp) && textProp.ValueKind == JsonValueKind.String)
373+
{
374+
(textParts ??= []).Add(textProp.GetString()!);
375+
}
376+
break;
377+
378+
case "image":
379+
(binaryResults ??= []).Add(new ToolBinaryResult
380+
{
381+
Data = block.TryGetProperty("data", out var imgData) && imgData.ValueKind == JsonValueKind.String ? imgData.GetString() ?? "" : "",
382+
MimeType = block.TryGetProperty("mimeType", out var imgMime) && imgMime.ValueKind == JsonValueKind.String ? imgMime.GetString() ?? "" : "",
383+
Type = "image",
384+
});
385+
break;
386+
387+
case "resource":
388+
if (block.TryGetProperty("resource", out var resProp) && resProp.ValueKind == JsonValueKind.Object)
389+
{
390+
if (resProp.TryGetProperty("text", out var resText) && resText.ValueKind == JsonValueKind.String)
391+
{
392+
var text = resText.GetString();
393+
if (!string.IsNullOrEmpty(text))
394+
{
395+
(textParts ??= []).Add(text!);
396+
}
397+
}
398+
399+
if (resProp.TryGetProperty("blob", out var resBlob) && resBlob.ValueKind == JsonValueKind.String)
400+
{
401+
var blob = resBlob.GetString();
402+
if (!string.IsNullOrEmpty(blob))
403+
{
404+
var mimeType = resProp.TryGetProperty("mimeType", out var resMime) && resMime.ValueKind == JsonValueKind.String
405+
? resMime.GetString() ?? "application/octet-stream"
406+
: "application/octet-stream";
407+
var uri = resProp.TryGetProperty("uri", out var resUri) && resUri.ValueKind == JsonValueKind.String
408+
? resUri.GetString()
409+
: null;
410+
411+
(binaryResults ??= []).Add(new ToolBinaryResult
412+
{
413+
Data = blob!,
414+
MimeType = mimeType,
415+
Type = "resource",
416+
Description = uri,
417+
});
418+
}
419+
}
420+
}
421+
break;
422+
}
423+
}
424+
425+
var isError = element.TryGetProperty("isError", out var isErrorProp) &&
426+
isErrorProp.ValueKind == JsonValueKind.True;
427+
428+
return new ToolResultObject
429+
{
430+
TextResultForLlm = textParts is not null ? string.Join("\n", textParts) : "",
431+
ResultType = isError ? "failure" : "success",
432+
BinaryResultsForLlm = binaryResults,
433+
};
434+
}
435+
436+
/// <summary>
437+
/// Attempts to convert a result from an <see cref="AIFunction"/> invocation into a
438+
/// <see cref="ToolResultObject"/>. Handles <see cref="TextContent"/>,
439+
/// <see cref="DataContent"/>, and collections of <see cref="AIContent"/>.
440+
/// Returns <see langword="null"/> if the value is not a recognized <see cref="AIContent"/> type.
441+
/// </summary>
442+
internal static ToolResultObject? TryConvertFromAIContent(object? result)
443+
{
444+
if (result is AIContent singleContent)
445+
{
446+
return ConvertAIContents([singleContent]);
447+
}
448+
449+
if (result is IEnumerable<AIContent> contentList)
450+
{
451+
return ConvertAIContents(contentList);
452+
}
453+
454+
return null;
455+
}
456+
457+
private static ToolResultObject ConvertAIContents(IEnumerable<AIContent> contents)
458+
{
459+
List<string>? textParts = null;
460+
List<ToolBinaryResult>? binaryResults = null;
461+
462+
foreach (var content in contents)
463+
{
464+
switch (content)
465+
{
466+
case TextContent textContent:
467+
if (textContent.Text is { } text)
468+
{
469+
(textParts ??= []).Add(text);
470+
}
471+
break;
472+
473+
case DataContent dataContent:
474+
(binaryResults ??= []).Add(new ToolBinaryResult
475+
{
476+
Data = dataContent.Base64Data.ToString(),
477+
MimeType = dataContent.MediaType ?? "application/octet-stream",
478+
Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource",
479+
});
480+
break;
481+
482+
default:
483+
(textParts ??= []).Add(SerializeAIContent(content));
484+
break;
485+
}
486+
}
487+
488+
return new ToolResultObject
489+
{
490+
TextResultForLlm = textParts is not null ? string.Join("\n", textParts) : "",
491+
ResultType = "success",
492+
BinaryResultsForLlm = binaryResults,
493+
};
494+
}
495+
496+
private static string SerializeAIContent(AIContent content) =>
497+
JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent)));
327498
}
328499

329500
/// <summary>

go/definetool.go

Lines changed: 103 additions & 2 deletions
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, CallToolResult is
70+
// converted, and other types are JSON-serialized.
6971
func normalizeResult(result any) (ToolResult, error) {
7072
if result == nil {
7173
return ToolResult{
@@ -87,6 +89,11 @@ func normalizeResult(result any) (ToolResult, error) {
8789
}, nil
8890
}
8991

92+
// MCP CallToolResult shape: { content: [...], isError?: bool }
93+
if tr, ok := convertCallToolResult(result); ok {
94+
return tr, nil
95+
}
96+
9097
// Everything else gets JSON-serialized
9198
jsonBytes, err := json.Marshal(result)
9299
if err != nil {
@@ -99,7 +106,101 @@ func normalizeResult(result any) (ToolResult, error) {
99106
}, nil
100107
}
101108

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

0 commit comments

Comments
 (0)