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 diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f..f052d0ed0 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1036,9 +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"}) - _, structured_content = meta.convert_result(result) + 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 @@ -1050,7 +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 - _, structured_content_defaults = meta.convert_result(result_with_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