Skip to content

Fix CallToolResult handling across all SDKs#1049

Open
stephentoub wants to merge 9 commits intomainfrom
stephentoub/fix-call-tool-result-handling
Open

Fix CallToolResult handling across all SDKs#1049
stephentoub wants to merge 9 commits intomainfrom
stephentoub/fix-call-tool-result-handling

Conversation

@stephentoub
Copy link
Copy Markdown
Collaborator

Problem

When a tool handler returns an MCP CallToolResult object ({ 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 CallToolResult shape before the JSON-serialize fallback and converts it to the SDK's native ToolResultObject format:

  • Text content blocks → textResultForLlm (multiple blocks joined with \n)
  • Image content blocks → binaryResultsForLlm with type: "image"
  • Resource content blocks → text goes to textResultForLlm, blob goes to binaryResultsForLlm
  • isError: trueresultType: "failure"

The .NET SDK additionally handles Microsoft.Extensions.AI content types (TextContent, DataContent, and unknown AIContent subtypes via AIJsonUtilities serialization), since the MCP C# SDK's McpClientTool returns these types from InvokeCoreAsync.

Changes by SDK

Node.js (nodejs/src/types.ts, session.ts, client.ts)

  • isCallToolResult() type guard + convertCallToolResult() converter
  • Integrated into both _executeToolAndRespond (session API) and normalizeToolResultV2 (client API)
  • Defensive guards for malformed input (optional chaining on resource, typeof check on text)

Python (python/copilot/tools.py)

  • _is_call_tool_result() + _convert_call_tool_result() (private helpers)
  • Integrated into _normalize_result

Go (go/definetool.go)

  • convertCallToolResult() with JSON marshal/unmarshal fallback for typed Go structs
  • Integrated into normalizeResult

.NET (dotnet/src/Types.cs, Session.cs, Client.cs)

  • ToolResultObject.TryConvertFromCallToolResult(object?) — detects JsonElement with CallToolResult shape
  • ToolResultObject.TryConvertFromAIContent(object?) — handles TextContent, DataContent, IEnumerable<AIContent>, with fallback serialization for unknown subtypes
  • Integrated into both ExecuteToolAndRespondAsync (session) and client v2 tool response path

Tests

  • Node.js: 21 unit tests in nodejs/test/call-tool-result.test.ts
  • Go: 9 subtests in TestConvertCallToolResult
  • Python: 13 tests in TestCallToolResult class
  • Coverage includes: text-only, multiple text, isError mapping, image content, resource text/blob, mixed content, empty content, malformed input rejection, and edge cases

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>
@stephentoub stephentoub requested a review from a team as a code owner April 9, 2026 00:51
Copilot AI review requested due to automatic review settings April 9, 2026 00:51
stephentoub and others added 2 commits April 8, 2026 20:54
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1049

@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1049

Copy link
Copy Markdown
Collaborator Author

@stephentoub stephentoub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the inconsistency.

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>
@github-actions

This comment has been minimized.

Co-Authored-By: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com>
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #1049

@SteveSandersonMS
Copy link
Copy Markdown
Contributor

SteveSandersonMS commented Apr 9, 2026

If I understand correctly, the reason why people might want conversion of CallToolResult from custom tools is that they are implementing their own MCP host and exposing the MCP tools as custom tools, e.g.,

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 CallToolResult converter as a utility that's optional but still very easy to use? I think it would be a relatively small change, leading to APIs something like:

// 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>
@github-actions

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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Cross-SDK Consistency Review

Thanks for fixing this across all four SDKs! After reviewing the changes, I found one behavioral inconsistency worth flagging.

Auto-detection vs. explicit opt-in

The PR description states:

Each SDK's tool result normalization path now detects the CallToolResult shape before the JSON-serialize fallback

This holds for .NET, where ToolResultObject.ConvertFromInvocationResult() is wired into both Session.cs and Client.cs and automatically handles AIContent/TextContent/DataContent types returned by McpClientTool. A user returning an MCP tool result gets proper handling without any extra work.

However, for Go, Node.js, and Python, the normalization paths are not changed:

SDK Normalization function Auto-detects CallToolResult?
.NET ConvertFromInvocationResult (called automatically) ✅ Yes (AIContent types)
Go normalizeResult ❌ No — unchanged
Node.js _executeToolAndRespond / normalizeToolResultV2 ❌ No — unchanged
Python _normalize_result ❌ No — unchanged

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 CallToolResult-shaped object from their tool handler still hit the original bug — the LLM sees the raw serialized JSON rather than the extracted content. The new public helpers (ConvertMCPCallToolResult, convertMcpCallToolResult, convert_mcp_call_tool_result) only help users who know to call them explicitly.

Suggestion

To 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 ConvertMCPCallToolResult (verify "content" exists as an array, every item has a "type" string) is a solid heuristic to detect the shape without false positives. An analogous change would be:

Go (go/definetool.gonormalizeResult, after the ToolResult passthrough check):

if tr, ok := ConvertMCPCallToolResult(result); ok {
    return tr, nil
}

Node.js (nodejs/src/session.ts_executeToolAndRespond and nodejs/src/client.tsnormalizeToolResultV2):

// before the JSON.stringify fallback
const mcpConverted = tryConvertMcpCallToolResult(rawResult);
if (mcpConverted !== null) {
    result = mcpConverted;
}

Python (python/copilot/tools.py_normalize_result):

# 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.

Generated by SDK Consistency Review Agent for issue #1049 ·

@stephentoub
Copy link
Copy Markdown
Collaborator Author

@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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDK does not accept CallToolResult type of MCP/SDK type for Tool Response

3 participants