Fix CallToolResult handling across all SDKs#1049
Conversation
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>
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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes tool result normalization so MCP CallToolResult objects are converted into each SDK’s native ToolResultObject shape (instead of being JSON-stringified and shown to the LLM as raw JSON).
Changes:
- Add CallToolResult detection + conversion logic in Node, Python, Go, and .NET tool-result normalization paths.
- Integrate conversion into both “session” and “client v2” execution paths (where applicable).
- Add/extend unit tests for CallToolResult scenarios (Node/Python/Go).
Show a summary per file
| File | Description |
|---|---|
| python/e2e/test_tools_unit.py | Adds Python unit tests covering CallToolResult normalization behavior. |
| python/copilot/tools.py | Implements CallToolResult shape detection and conversion in Python normalization. |
| nodejs/test/call-tool-result.test.ts | Adds Node unit tests for CallToolResult type guard + conversion. |
| nodejs/src/types.ts | Introduces CallToolResult type, isCallToolResult, and convertCallToolResult. |
| nodejs/src/session.ts | Converts CallToolResult in the session tool execution path before JSON stringify fallback. |
| nodejs/src/index.ts | Exposes CallToolResult helpers/types in the public entrypoint exports. |
| nodejs/src/client.ts | Converts CallToolResult in the client v2 tool result normalization path. |
| go/definetool.go | Adds CallToolResult conversion in Go normalization logic. |
| go/definetool_test.go | Adds Go tests for CallToolResult conversion behavior. |
| dotnet/src/Types.cs | Adds conversion helpers for CallToolResult (JsonElement) and AIContent tool results. |
| dotnet/src/Session.cs | Integrates new conversion helpers into session tool execution. |
| dotnet/src/Client.cs | Integrates new conversion helpers into client v2 tool-call handling. |
Copilot's findings
- Files reviewed: 12/12 changed files
- Comments generated: 7
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1049
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1049
Sort imports in copilot.tools import block to satisfy ruff I001 rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1049
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>
This comment has been minimized.
This comment has been minimized.
Co-Authored-By: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1049
|
If I understand correctly, the reason why people might want conversion of defineTool("inner_tool", {
handler: async (params) => {
return await someMcpClient.callTool("inner_tool", params);
},
});Is that right? If that's the case, then what would you think about tweaking this slightly so that we expose the // TypeScript
handler: async ({ query }) => {
const mcpResult = await mcpClient.callTool("inner_tool", { query });
return convertCallToolResult(mcpResult);
} // C#
async (string query) => {
var mcpResult = await mcpClient.CallToolAsync("inner_tool", new { query });
return ToolResultObject.FromCallToolResult(mcpResult);
} # Python
@define_tool("my_tool", description="Delegates to an MCP server")
async def my_tool(params: MyParams, invocation: ToolInvocation) -> ToolResult:
mcp_result = await mcp_client.call_tool("inner_tool", {"query": params.query})
return convert_call_tool_result(mcp_result) // Go
func(params MyParams, inv copilot.ToolInvocation) (copilot.ToolResult, error) {
mcpResult, err := mcpClient.CallTool(ctx, "inner_tool", params)
if err != nil {
return copilot.ToolResult{}, err
}
return copilot.ConvertCallToolResult(mcpResult)
}I'm wondering because using custom tools to wrap MCP client calls is a valid scenario but not universal, and having duck-typed auto-conversion could lead to surprises if people's custom tool outputs happen to be a similar shape, e.g.: defineTool("list_components", {
description: "Lists UI components in a directory",
parameters: z.object({ dir: z.string() }),
handler: async ({ dir }) => {
return {
content: [
{ type: "button", label: "Submit", file: "submit.tsx" },
{ type: "input", label: "Search", file: "search.tsx" },
],
};
},
});Hopefully having conversion as a utility wouldn't make things meaningfully harder for people passing through MCP client calls, and clarifies the separation between custom tools and MCP. What do you think? Am I understanding the scenario? |
… 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>
This comment has been minimized.
This comment has been minimized.
- 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>
Cross-SDK Consistency ReviewThanks for fixing this across all four SDKs! After reviewing the changes, I found one behavioral inconsistency worth flagging. Auto-detection vs. explicit opt-inThe PR description states:
This holds for .NET, where However, for Go, Node.js, and Python, the normalization paths are not changed:
The Python test even makes this explicit: def test_call_tool_result_dict_is_json_serialized_by_normalize(self):
"""_normalize_result does NOT auto-detect MCP results; it JSON-serializes them."""This means users of Go, Node.js, and Python who return a SuggestionTo fully fix the bug and maintain consistent behavior across SDKs, consider integrating the detection into the normalization paths for the three remaining SDKs. The guard logic already written in Go's Go ( if tr, ok := ConvertMCPCallToolResult(result); ok {
return tr, nil
}Node.js ( // before the JSON.stringify fallback
const mcpConverted = tryConvertMcpCallToolResult(rawResult);
if (mcpConverted !== null) {
result = mcpConverted;
}Python ( # before the json.dumps fallback
if _is_call_tool_result(result):
return convert_mcp_call_tool_result(result)If the design intent is to keep these as explicit helpers (e.g., to avoid accidentally misidentifying arbitrary dicts), that's a reasonable call — but it would be worth updating the PR description and documenting the difference so users know they need to call the converter manually in Go/Node.js/Python. Cross-SDK consistency review — no other issues found. The conversion logic itself (text/image/resource/isError mapping, empty content, malformed input guards) is consistent across all four SDKs.
|
|
@SteveSandersonMS, thanks for the feedback. I've addressed it by making the conversions explicit (except for .NET, where M.E.AI that we already reference includes a type we can special-case). |
Problem
When a tool handler returns an MCP
CallToolResultobject ({ content: [{type, text/data/resource}], isError?: boolean }), all four SDKs JSON-serialize it and send the raw JSON string over RPC. The LLM then sees'{"content":[{"type":"text","text":"actual result"}]}'instead of the actual tool output.Fixes #937
Approach
Each SDK's tool result normalization path now detects the
CallToolResultshape before the JSON-serialize fallback and converts it to the SDK's nativeToolResultObjectformat:textResultForLlm(multiple blocks joined with\n)binaryResultsForLlmwithtype: "image"textResultForLlm, blob goes tobinaryResultsForLlmisError: true→resultType: "failure"The .NET SDK additionally handles Microsoft.Extensions.AI content types (
TextContent,DataContent, and unknownAIContentsubtypes viaAIJsonUtilitiesserialization), since the MCP C# SDK'sMcpClientToolreturns these types fromInvokeCoreAsync.Changes by SDK
Node.js (
nodejs/src/types.ts,session.ts,client.ts)isCallToolResult()type guard +convertCallToolResult()converter_executeToolAndRespond(session API) andnormalizeToolResultV2(client API)Python (
python/copilot/tools.py)_is_call_tool_result()+_convert_call_tool_result()(private helpers)_normalize_resultGo (
go/definetool.go)convertCallToolResult()with JSON marshal/unmarshal fallback for typed Go structsnormalizeResult.NET (
dotnet/src/Types.cs,Session.cs,Client.cs)ToolResultObject.TryConvertFromCallToolResult(object?)— detectsJsonElementwith CallToolResult shapeToolResultObject.TryConvertFromAIContent(object?)— handlesTextContent,DataContent,IEnumerable<AIContent>, with fallback serialization for unknown subtypesExecuteToolAndRespondAsync(session) and client v2 tool response pathTests
nodejs/test/call-tool-result.test.tsTestConvertCallToolResultTestCallToolResultclass