Skip to content

Propagate x-fastmcp-wrap-result in tool result _meta#3490

Merged
jlowin merged 7 commits intomainfrom
fix/wrap-result-meta
Mar 14, 2026
Merged

Propagate x-fastmcp-wrap-result in tool result _meta#3490
jlowin merged 7 commits intomainfrom
fix/wrap-result-meta

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Mar 14, 2026

When FastMCP wraps non-object tool results in a {"result": ...} envelope for structuredContent, consumers like the Prefab renderer need to know whether to unwrap it. Currently this is only discoverable via the tool's outputSchema, which requires a listTools call and isn't available in all contexts (e.g., tool calls initiated from within a UI). This change propagates the x-fastmcp-wrap-result flag directly on each tool result's _meta, making it self-describing. The client-side parser now checks _meta first before falling back to the schema lookup.

@server.tool
def get_items() -> list[dict]:
    return [{"id": 1}, {"id": 2}]

result = await server.call_tool("get_items", {})
result.meta  # {"x-fastmcp-wrap-result": True}
result.structured_content  # {"result": [{"id": 1}, {"id": 2}]}

🤖 Generated with Claude Code

Co-authored-by: Claude <[email protected]>
@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality. client Related to the FastMCP client SDK or client-side functionality. labels Mar 14, 2026
jlowin and others added 2 commits March 14, 2026 09:08
🤖 Generated with Claude Code

Co-authored-by: Claude <[email protected]>
🤖 Generated with Claude Code

Co-authored-by: Claude <[email protected]>
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…allToolResult import, extract fastmcp_meta

🤖 Generated with Claude Code

Co-authored-by: Claude <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71f3b7567f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/client/mixins/tools.py Outdated
Comment on lines +396 to +399
if wrap_from_meta:
# The result is self-describing — unwrap directly without
# a listTools round-trip or schema lookup.
data = result.structuredContent.get("result")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve schema-based decoding for wrapped tool results

When _meta.fastmcp.wrap_result is true (now emitted for wrapped FastMCP tool outputs), this branch returns result.structuredContent["result"] directly and bypasses the existing schema/type-adapter validation path. That changes result.data from validated Python types to raw JSON shapes for wrapped schemas (for example, set[int] now comes back as list[int], and AnyUrl as str), which is a behavioral regression for client.call_tool() consumers that rely on typed decoding.

Useful? React with 👍 / 👎.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Mar 14, 2026

Test Failure Analysis

(Updated — analysis confirmed by latest run 23088861015)

Summary: 2 tests in tests/server/tasks/test_task_return_types.py fail because the new wrap_from_meta fast-path in _parse_call_tool_result skips the schema-based type coercion that pydantic was previously performing.

Root Cause: In src/fastmcp/client/mixins/tools.py, the new code takes a fast path when meta.fastmcp.wrap_result is True:

if wrap_from_meta:
    data = result.structuredContent.get("result")  # raw JSON value, no coercion
else:
    # ... schema lookup + pydantic validate_python(structured_content)

This skips the listTools round-trip (intentional), but also inadvertently skips type_adapter.validate_python(...). For types that don't round-trip cleanly through JSON, this means:

  • datetime → serialized as ISO string "2025-11-05T12:30:45", never coerced back to datetime
  • set[int] → serialized as JSON array [1, 2, 3], never coerced back to set

Suggested Solution: In src/fastmcp/client/mixins/tools.py, decouple the two behaviors the fast-path was trying to combine: skip listTools and parse using schema. The schema lookup should still run (and its result used for coercion) even when wrap_from_meta is True. Only the listTools call should be skipped:

# Skip listTools only if meta already tells us it's wrapped
if not wrap_from_meta and name not in tool_output_schemas:
    await list_tools_fn()

if name in tool_output_schemas:
    output_schema = tool_output_schemas.get(name)
    if output_schema:
        if output_schema.get("x-fastmcp-wrap-result") or wrap_from_meta:
            output_schema = output_schema.get("properties", {}).get("result")
            structured_content = result.structuredContent.get("result")
        else:
            structured_content = result.structuredContent
        output_type = json_schema_to_type(output_schema)
        type_adapter = get_cached_typeadapter(output_type)
        data = type_adapter.validate_python(structured_content)
    else:
        data = result.structuredContent
elif wrap_from_meta:
    # Schema unavailable but we know it's wrapped — unwrap without coercion
    data = result.structuredContent.get("result")

This preserves the optimization (no listTools call when wrap_result is in meta) while keeping type coercion via pydantic for types like datetime and set.

Detailed Analysis

Failing tests:

  • tests/server/tasks/test_task_return_types.py::test_task_binary_types[return_datetime-datetime-<lambda>]
  • tests/server/tasks/test_task_return_types.py::test_task_collection_types[return_set-set-expected_value1]

Failure 1 — datetime:

E  assert False
E   +  where False = isinstance('2025-11-05T12:30:45', <class 'datetime.datetime'>)
E   +    where '2025-11-05T12:30:45' = ...CallToolResult(..., meta={'fastmcp': {'wrap_result': True}}, data='2025-11-05T12:30:45', ...).data

datetime is serialized to an ISO string in JSON. Without type_adapter.validate_python(...), it stays a string instead of being parsed back to a datetime.

Failure 2 — set:

E  AssertionError: assert False
E   +  where False = isinstance([1, 2, 3], <class 'set'>)
E   +    where [1, 2, 3] = ...CallToolResult(..., meta={'fastmcp': {'wrap_result': True}}, data=[1, 2, 3], ...).data

set serializes to a JSON array. Without schema-guided coercion, [1, 2, 3] stays a list instead of being converted to a set.

Both failures are in the task code path (client.call_tool(tool_name, task=True)), where results flow through _parse_call_tool_result after task completion. The task result meta includes {'fastmcp': {'wrap_result': True}}, which triggers the new fast-path.

Related Files
  • src/fastmcp/client/mixins/tools.py_parse_call_tool_result function, the new wrap_from_meta fast-path is the source of the regression
  • tests/server/tasks/test_task_return_types.py:281test_task_binary_types (datetime case)
  • tests/server/tasks/test_task_return_types.py:332test_task_collection_types (set case)

🤖 Generated with Claude Code (edited — reflects latest run analysis)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 969aafbd3c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/client/mixins/tools.py Outdated
Comment on lines +393 to +394
fastmcp_meta = (result.meta or {}).get("fastmcp") or {}
wrap_from_meta = fastmcp_meta.get("wrap_result", False)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard fastmcp meta parsing against non-dict values

_meta is free-form, but this path assumes result.meta["fastmcp"] is always a dict and immediately calls .get() on it. If a server (or a tool returning ToolResult(meta=...)) sends "fastmcp" as a non-object value, this raises, gets swallowed by the broad except, and leaves result.data unset even when schema-based decoding would have succeeded. This makes client parsing fragile for mixed/third-party MCP servers and malformed-but-recoverable metadata.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22b7bbe791

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

fastmcp_meta = (
raw_fastmcp_meta if isinstance(raw_fastmcp_meta, dict) else {}
)
wrap_from_meta = fastmcp_meta.get("wrap_result", False)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require boolean wrap_result before unwrapping content

wrap_from_meta = fastmcp_meta.get("wrap_result", False) treats any truthy value (for example the string "false" or integer 1) as a signal to unwrap structuredContent["result"]. In that case the client will incorrectly unwrap valid object payloads and can fall into the exception path, leaving result.data unset even when schema-based decoding would otherwise succeed. This is a regression in mixed/third-party environments because _meta is free-form and not type-enforced for nested values.

Useful? React with 👍 / 👎.

@jlowin jlowin merged commit 139d2d8 into main Mar 14, 2026
6 of 9 checks passed
@jlowin jlowin deleted the fix/wrap-result-meta branch March 14, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client Related to the FastMCP client SDK or client-side functionality. enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant