Skip to content

Fix: Always return CallToolResult when structured_content exists (#3596)#3604

Closed
hnshah wants to merge 2 commits intoPrefectHQ:mainfrom
hnshah:fix/structured-data-3596
Closed

Fix: Always return CallToolResult when structured_content exists (#3596)#3604
hnshah wants to merge 2 commits intoPrefectHQ:mainfrom
hnshah:fix/structured-data-3596

Conversation

@hnshah
Copy link
Copy Markdown

@hnshah hnshah commented Mar 24, 2026

Fixes #3596

Problem

CallToolResult.data returns None when structured data is present.

Root Cause

to_mcp_result() returned tuple instead of CallToolResult when meta=None but structured_content exists.

Solution

Return CallToolResult when either meta or structured_content is present (OR condition).

MRE

result = await client.call_tool("tool_name", {})
assert result.data is not None  # Now passes

Fixes both:

Co-authored-by: marvin-context-protocol[bot]

…fectHQ#3596)

Problem:
In FastMCP 3.1, CallToolResult.data field returns None even when
structured data is present. This breaks existing code that relies
on result.data to access structured tool outputs.

Root Cause:
The to_mcp_result() method in ToolResult was returning a tuple
(content, structured_content) instead of a CallToolResult object
when meta=None but structured_content exists.

The client's _parse_call_tool_result() function expects a
CallToolResult with a structuredContent field to populate .data,
but when receiving a tuple, it couldn't extract the data properly.

Solution:
Always return CallToolResult when structured_content is not None,
regardless of whether meta is present. This ensures the client
consistently receives an object with the structuredContent field,
which gets properly parsed into the .data field.

The optimization of returning bare tuples was breaking the client
contract. Consistency is more important than micro-optimization here.

Test Case:
result = await client.call_tool("tool_name", {})
assert result.data is not None  # Now passes

Fixes PrefectHQ#3596
@marvin-context-protocol marvin-context-protocol Bot added the too-long Excessively verbose or unedited LLM output. Condense before triage. label Mar 24, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Thanks for the report. This issue goes beyond what our contributor guidelines ask for — we just need a short problem description and an MRE. Please see our contributing guidelines and condense this issue. We'll triage it once it's trimmed down.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The PR breaks an existing test by dropping meta from tool results that have meta but no structured_content.

Root Cause: The reordering of conditions in ToolResult.to_mcp_result() unintentionally drops the case where meta is not None but structured_content is None.

Original code (before PR):

if self.meta is not None:          # ← handles meta-only case
    return CallToolResult(...)
if self.structured_content is None:
    return self.content
return self.content, self.structured_content  # ← the bug being fixed

PR's new code:

if self.structured_content is not None:   # ← fixes the tuple bug...
    return CallToolResult(...)
return self.content                        # ← ...but drops meta when structured_content is None!

The failing test (test_call_tool_forwards_meta) calls a tool that returns ToolResult(content=..., meta={...}) with no structured_content. The PR causes to_mcp_result() to return bare self.content, silently discarding the meta.

Suggested Solution: Use a combined condition to return CallToolResult when either meta or structured_content is present:

def to_mcp_result(self):
    if self.meta is not None or self.structured_content is not None:
        return CallToolResult(
            structuredContent=self.structured_content,
            content=self.content,
            _meta=self.meta,  # type: ignore[call-arg]
        )
    return self.content

This preserves the fix for issue #3596 (structured_content without meta returns CallToolResult instead of a tuple) while also maintaining the existing behavior for meta-only results.

Detailed Analysis

Failing test (tests/server/providers/proxy/test_proxy_server.py:333):

@fastmcp_server.tool
def tool_with_meta(value: str) -> ToolResult:
    return ToolResult(
        content=f"Result: {value}",
        meta={"custom_key": "custom_value", "processed": True},
    )

result = await client.call_tool("tool_with_meta", {"value": "test"})
assert result.meta == {"custom_key": "custom_value", "processed": True}  # FAILS

Error from logs:

AssertionError: assert None == {'custom_key': 'custom_value', 'processed': True}
 +  where None = CallToolResult(content=[TextContent(...)], structured_content=None, meta=None, data=None, is_error=False).meta

The test fails on all matrix combinations (Python 3.10/3.13, ubuntu/windows, lowest-direct deps).

Related Files
  • src/fastmcp/tools/base.pyToolResult.to_mcp_result() is the source of the regression (lines 123–136 in the current file on main)
  • tests/server/providers/proxy/test_proxy_server.py:333TestTools.test_call_tool_forwards_meta is the failing test

marvin-context-protocol bot correctly identified that the previous fix
broke test_call_tool_forwards_meta by dropping the meta field when
structured_content is None.

Updated to use OR condition: return CallToolResult when EITHER meta
OR structured_content is present, not just when structured_content exists.

This fixes both:
- Original bug PrefectHQ#3596 (structured_content without meta)
- Regression (meta without structured_content)

Co-authored-by: marvin-context-protocol[bot]
@hnshah
Copy link
Copy Markdown
Author

hnshah commented Mar 24, 2026

Thanks @marvin-context-protocol! You're absolutely right - I broke the meta-only case.

Fixed in latest commit (c8cdef7):

The test should pass now. Apologies for the oversight!

@jlowin
Copy link
Copy Markdown
Member

jlowin commented Mar 24, 2026

See the note on #3596 please; this issue needs an MRE before it has a PR

@jlowin
Copy link
Copy Markdown
Member

jlowin commented Mar 25, 2026

Closing, see #3596

@jlowin jlowin closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

too-long Excessively verbose or unedited LLM output. Condense before triage.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Structured data no longer works in FastMCP 3.1

2 participants