From c86b8f35083342453081cec61b56dd2cff4e33ca Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Wed, 29 Apr 2026 22:46:15 -0700 Subject: [PATCH 1/3] fix(types): correct FastMCP.call_tool and convert_result return types (#1251) Signed-off-by: SAY-5 --- src/mcp/server/mcpserver/server.py | 14 ++++++++++++-- .../server/mcpserver/utilities/func_metadata.py | 13 +++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index be77705da..b21ca3d82 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -399,8 +399,18 @@ async def list_tools(self) -> list[MCPTool]: async def call_tool( self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None - ) -> Sequence[ContentBlock] | dict[str, Any]: - """Call a tool by name with arguments.""" + ) -> Sequence[ContentBlock] | CallToolResult | tuple[Sequence[ContentBlock], dict[str, Any]]: + """Call a tool by name with arguments. + + Because ``convert_result=True`` is always passed to the tool manager, the + return value comes from ``FuncMetadata.convert_result``: + + - ``Sequence[ContentBlock]`` when the tool has no output schema and + returned a regular value + - ``CallToolResult`` when the tool returned a ``CallToolResult`` directly + - ``tuple[Sequence[ContentBlock], dict[str, Any]]`` when the tool has + an output schema, where the second element is the structured content + """ if context is None: context = Context(mcp_server=self) return await self._tool_manager.call_tool(name, arguments, context, convert_result=True) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 4a7610637..59506eb3d 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -88,12 +88,17 @@ async def call_fn_with_arg_validation( else: return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict)) - def convert_result(self, result: Any) -> Any: + def convert_result( + self, result: Any + ) -> Sequence[ContentBlock] | CallToolResult | tuple[Sequence[ContentBlock], dict[str, Any]]: """Convert a function call result to the format for the lowlevel tool call handler. - - If output_model is None, return the unstructured content directly. - - If output_model is not None, convert the result to structured output format - (dict[str, Any]) and return both unstructured and structured content. + - If the function returned a ``CallToolResult`` directly, return it unchanged. + - If ``output_model`` is None, return the unstructured content + (``Sequence[ContentBlock]``) directly. + - If ``output_model`` is not None, convert the result to structured output + format (``dict[str, Any]``) and return both unstructured and structured + content as a tuple. Note: we return unstructured content here **even though the lowlevel server tool call handler provides generic backwards compatibility serialization of From c37062a9a523c51fe40299d812a1620a7a462d5e Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Thu, 30 Apr 2026 01:36:24 -0700 Subject: [PATCH 2/3] test(func_metadata): narrow convert_result to tuple before unpack Signed-off-by: SAY-5 --- tests/server/mcpserver/test_func_metadata.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f..2ad33e1cb 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1038,7 +1038,9 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover # Check that the actual output uses aliases too result = ModelWithAliases(**{"first": "hello", "second": "world"}) - _, structured_content = meta.convert_result(result) + converted = meta.convert_result(result) + assert isinstance(converted, tuple) + _, structured_content = converted # The structured content should use aliases to match the schema assert "first" in structured_content @@ -1050,7 +1052,9 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover # Also test the case where we have a model with defaults to ensure aliases work in all cases result_with_defaults = ModelWithAliases() # Uses default None values - _, structured_content_defaults = meta.convert_result(result_with_defaults) + converted_defaults = meta.convert_result(result_with_defaults) + assert isinstance(converted_defaults, tuple) + _, structured_content_defaults = converted_defaults # Even with defaults, should use aliases in output assert "first" in structured_content_defaults From 46926cd92e9dc6e4f7239d5b83704b7215259db6 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Thu, 30 Apr 2026 01:55:23 -0700 Subject: [PATCH 3/3] test(func_metadata): cast convert_result to tuple for pyright union narrowing Signed-off-by: SAY-5 --- tests/server/mcpserver/test_func_metadata.py | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 2ad33e1cb..f052d0ed0 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1036,11 +1036,18 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover assert "field_first" not in meta.output_schema["properties"] assert "field_second" not in meta.output_schema["properties"] - # Check that the actual output uses aliases too + # Check that the actual output uses aliases too. ``convert_result`` + # returns either a sequence, a CallToolResult, or a 2-tuple of + # (unstructured, structured) — for a model with an output schema the + # 2-tuple branch fires, so cast accordingly to keep pyright happy + # without runtime narrowing surprises. + from typing import cast + result = ModelWithAliases(**{"first": "hello", "second": "world"}) - converted = meta.convert_result(result) - assert isinstance(converted, tuple) - _, structured_content = converted + structured_content = cast( + "tuple[Any, dict[str, Any]]", + meta.convert_result(result), + )[1] # The structured content should use aliases to match the schema assert "first" in structured_content @@ -1052,9 +1059,10 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover # Also test the case where we have a model with defaults to ensure aliases work in all cases result_with_defaults = ModelWithAliases() # Uses default None values - converted_defaults = meta.convert_result(result_with_defaults) - assert isinstance(converted_defaults, tuple) - _, structured_content_defaults = converted_defaults + structured_content_defaults = cast( + "tuple[Any, dict[str, Any]]", + meta.convert_result(result_with_defaults), + )[1] # Even with defaults, should use aliases in output assert "first" in structured_content_defaults