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

* Propagate x-fastmcp-wrap-result flag in tool result _meta

🤖 Generated with Claude Code

Co-authored-by: Claude <noreply@anthropic.com>

* Skip listTools round-trip when _meta has x-fastmcp-wrap-result

🤖 Generated with Claude Code

Co-authored-by: Claude <noreply@anthropic.com>

* Use namespaced meta key: {"fastmcp": {"wrap_result": true}}

🤖 Generated with Claude Code

Co-authored-by: Claude <noreply@anthropic.com>

* Clean up _parse_call_tool_result: hoist cast import, document local CallToolResult import, extract fastmcp_meta

🤖 Generated with Claude Code

Co-authored-by: Claude <noreply@anthropic.com>

* Merge _meta in tasks result handler instead of overwriting

🤖 Generated with Claude Code

* Preserve type validation in meta-based unwrap path

🤖 Generated with Claude Code

* Fix type validation for wrapped task results, guard non-dict meta

🤖 Generated with Claude Code

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-03-14 11:09:46 -04:00 committed by GitHub
commit 139d2d8f96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 22 deletions

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
import weakref
from typing import TYPE_CHECKING, Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import mcp.types
from pydantic import RootModel
@ -379,8 +379,9 @@ async def _parse_call_tool_result(
Returns:
CallToolResult: Parsed result with structured data
"""
from typing import cast
# Local import: CallToolResult is under TYPE_CHECKING at module level to
# avoid a circular import (client.client -> mixins.tools -> client.client),
# but we need the concrete class here to construct the return value.
from fastmcp.client.client import CallToolResult
data = None
@ -389,23 +390,43 @@ async def _parse_call_tool_result(
raise ToolError(msg)
elif result.structuredContent:
try:
raw_fastmcp_meta = (result.meta or {}).get("fastmcp")
fastmcp_meta = (
raw_fastmcp_meta if isinstance(raw_fastmcp_meta, dict) else {}
)
wrap_from_meta = fastmcp_meta.get("wrap_result", False)
# Ensure the schema cache is populated for type validation.
# When meta tells us the result is wrapped we can skip the
# schema check for *wrap detection*, but we still need the
# schema for proper type coercion (e.g. list → set, str → datetime).
if name not in tool_output_schemas:
await list_tools_fn()
if name in tool_output_schemas:
if wrap_from_meta:
# Meta tells us the result is wrapped — unwrap and validate.
structured_content = result.structuredContent.get("result")
elif name in tool_output_schemas:
output_schema = tool_output_schemas.get(name)
if output_schema:
if output_schema.get("x-fastmcp-wrap-result"):
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)
if output_schema and output_schema.get("x-fastmcp-wrap-result"):
structured_content = result.structuredContent.get("result")
else:
data = result.structuredContent
structured_content = result.structuredContent
else:
structured_content = result.structuredContent
# Type-validate through the schema if available.
output_schema = tool_output_schemas.get(name)
if output_schema:
if wrap_from_meta or output_schema.get("x-fastmcp-wrap-result"):
output_schema = output_schema.get("properties", {}).get(
"result", output_schema
)
output_type = json_schema_to_type(output_schema)
type_adapter = get_cached_typeadapter(output_type)
data = type_adapter.validate_python(structured_content)
else:
data = structured_content
except Exception as e:
logger.error(
f"[{client_name or 'client'}] Error parsing structured content: {e}"

View file

@ -347,13 +347,15 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
}
}
# Convert based on component type
# Convert based on component type.
# Each branch merges related_task_meta with any existing _meta
# (e.g. fastmcp.wrap_result) rather than overwriting it.
if isinstance(component, Tool):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result()
# Ensure we have a CallToolResult and add metadata
if isinstance(mcp_result, mcp.types.CallToolResult):
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined]
elif isinstance(mcp_result, tuple):
content, structured_content = mcp_result
mcp_result = mcp.types.CallToolResult(
@ -371,19 +373,22 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
elif isinstance(component, Prompt):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_prompt_result()
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined]
return mcp_result
elif isinstance(component, ResourceTemplate):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result(component.uri_template)
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined]
return mcp_result
elif isinstance(component, Resource):
fastmcp_result = component.convert_result(raw_value)
mcp_result = fastmcp_result.to_mcp_result(str(component.uri))
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
merged = {**(mcp_result.meta or {}), **related_task_meta}
mcp_result._meta = merged # type: ignore[attr-defined]
return mcp_result
else:

View file

@ -301,6 +301,7 @@ class Tool(FastMCPComponent):
return ToolResult(
content=content,
structured_content={"result": structured} if wrap_result else structured,
meta={"fastmcp": {"wrap_result": True}} if wrap_result else None,
)
@overload

View file

@ -717,3 +717,35 @@ async def test_tagged_template_functionality(tagged_resources_server):
content_str = str(result[0])
assert '"id":"123"' in content_str
assert '"type":"template_data"' in content_str
async def test_client_unwraps_result_using_meta():
"""Client should unwrap wrapped results using _meta flag."""
server = FastMCP()
@server.tool
def list_tool() -> list[int]:
return [1, 2, 3]
client = Client(transport=FastMCPTransport(server))
async with client:
result = await client.call_tool("list_tool", {})
assert result.structured_content == {"result": [1, 2, 3]}
assert result.data == [1, 2, 3]
assert result.meta == {"fastmcp": {"wrap_result": True}}
async def test_client_does_not_unwrap_dict_result():
"""Client should not unwrap dict results that are not wrapped."""
server = FastMCP()
@server.tool
def dict_tool() -> dict[str, int]:
return {"a": 1}
client = Client(transport=FastMCPTransport(server))
async with client:
result = await client.call_tool("dict_tool", {})
assert result.structured_content == {"a": 1}
assert result.data == {"a": 1}
assert result.meta is None

View file

@ -268,6 +268,30 @@ class TestToolOutputSchema:
assert isinstance(result.content[2], TextContent)
assert result.content[2].text == "direct MCP content"
async def test_wrapped_result_includes_meta_flag(self):
"""Wrapped results include wrap_result in meta."""
server = FastMCP()
@server.tool
def list_tool() -> list[dict]:
return [{"a": 1}]
result = await server.call_tool("list_tool", {})
assert result.structured_content == {"result": [{"a": 1}]}
assert result.meta == {"fastmcp": {"wrap_result": True}}
async def test_unwrapped_result_has_no_meta_flag(self):
"""Unwrapped dict results do not include wrap_result in meta."""
server = FastMCP()
@server.tool
def dict_tool() -> dict[str, int]:
return {"value": 42}
result = await server.call_tool("dict_tool", {})
assert result.structured_content == {"value": 42}
assert result.meta is None
async def test_output_schema_serialization_edge_cases(self):
"""Test edge cases in output schema serialization."""
mcp = FastMCP()

View file

@ -532,3 +532,49 @@ class TestToolFromFunctionOutputSchema:
ValueError, match="Output schemas must represent object types"
):
Tool.from_function(func, output_schema=schema)
class TestWrapResultMeta:
async def test_list_return_includes_wrap_result_meta(self):
"""A tool returning list[dict] should set wrap_result in meta."""
def func() -> list[dict]:
return [{"a": 1}, {"b": 2}]
tool = Tool.from_function(func)
result = await tool.run({})
assert result.structured_content == {"result": [{"a": 1}, {"b": 2}]}
assert result.meta == {"fastmcp": {"wrap_result": True}}
async def test_int_return_includes_wrap_result_meta(self):
"""A tool returning int should set wrap_result in meta."""
def func() -> int:
return 42
tool = Tool.from_function(func)
result = await tool.run({})
assert result.structured_content == {"result": 42}
assert result.meta == {"fastmcp": {"wrap_result": True}}
async def test_dict_return_does_not_include_wrap_result_meta(self):
"""A tool returning dict should NOT set wrap_result in meta."""
def func() -> dict[str, int]:
return {"value": 42}
tool = Tool.from_function(func)
result = await tool.run({})
assert result.structured_content == {"value": 42}
assert result.meta is None
async def test_no_schema_dict_return_no_meta(self):
"""A tool without output schema returning dict should not set meta."""
def func():
return {"key": "val"}
tool = Tool.from_function(func)
result = await tool.run({})
assert result.structured_content == {"key": "val"}
assert result.meta is None