diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py index 3c41cc376..d25f5132c 100644 --- a/src/fastmcp/client/mixins/tools.py +++ b/src/fastmcp/client/mixins/tools.py @@ -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}" diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index fae63c08d..f2700b3a4 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -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: diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index a720d262f..36c3e49a9 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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 diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index 8210eb94b..d0d44bd2a 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -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 diff --git a/tests/server/providers/local_provider_tools/test_output_schema.py b/tests/server/providers/local_provider_tools/test_output_schema.py index f9136492e..0558dd1f6 100644 --- a/tests/server/providers/local_provider_tools/test_output_schema.py +++ b/tests/server/providers/local_provider_tools/test_output_schema.py @@ -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() diff --git a/tests/tools/tool/test_output_schema.py b/tests/tools/tool/test_output_schema.py index fc9468a04..bf01781a2 100644 --- a/tests/tools/tool/test_output_schema.py +++ b/tests/tools/tool/test_output_schema.py @@ -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