diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 9c7105a7b..e5c98ee0a 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,6 @@ import base64 import inspect -import json import re from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager @@ -308,20 +307,6 @@ async def _handle_call_tool( return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True) if isinstance(result, CallToolResult): return result - if isinstance(result, tuple) and len(result) == 2: - unstructured_content, structured_content = result - return CallToolResult( - content=list(unstructured_content), # type: ignore[arg-type] - structured_content=structured_content, # type: ignore[arg-type] - ) - if isinstance(result, dict): # pragma: no cover - # TODO: this code path is unreachable — convert_result never returns a raw dict. - # The call_tool return type (Sequence[ContentBlock] | dict[str, Any]) is wrong - # and needs to be cleaned up. - return CallToolResult( - content=[TextContent(type="text", text=json.dumps(result, indent=2))], - structured_content=result, - ) return CallToolResult(content=list(result)) async def _handle_list_resources( @@ -399,7 +384,7 @@ def get_context(self) -> Context[LifespanResultT, Request]: request_context = None return Context(request_context=request_context, mcp_server=self) - async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]: + async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResult | Sequence[ContentBlock]: """Call a tool by name with arguments.""" context = self.get_context() return await self._tool_manager.call_tool(name, arguments, context=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 062b47d0f..960bb9ddf 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -91,9 +91,9 @@ async def call_fn_with_arg_validation( def convert_result(self, result: Any) -> 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 output_model is None, return the unstructured content as a `Sequence[ContentBlock]`. + - If output_model is not None, return a `CallToolResult` with both unstructured + and structured content. Note: we return unstructured content here **even though the lowlevel server tool call handler provides generic backwards compatibility serialization of @@ -120,7 +120,7 @@ def convert_result(self, result: Any) -> Any: validated = self.output_model.model_validate(result) structured_content = validated.model_dump(mode="json", by_alias=True) - return (unstructured_content, structured_content) + return CallToolResult(content=list(unstructured_content), structured_content=structured_content) def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: """Pre-parse data from JSON. diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f..384cca624 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1038,7 +1038,7 @@ 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) + structured_content = meta.convert_result(result).structured_content # The structured content should use aliases to match the schema assert "first" in structured_content @@ -1050,7 +1050,7 @@ 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 = meta.convert_result(result_with_defaults).structured_content # Even with defaults, should use aliases in output assert "first" in structured_content_defaults diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 550bba50a..8bfa39026 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -12,7 +12,7 @@ from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata from mcp.server.session import ServerSessionT -from mcp.types import TextContent, ToolAnnotations +from mcp.types import CallToolResult, TextContent, ToolAnnotations class TestAddTools: @@ -473,7 +473,7 @@ def get_user(user_id: int) -> UserOutput: manager.add_tool(get_user) result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True) # don't test unstructured output here, just the structured conversion - assert len(result) == 2 and result[1] == {"name": "John", "age": 30} + assert isinstance(result, CallToolResult) and result.structured_content == {"name": "John", "age": 30} @pytest.mark.anyio async def test_tool_with_primitive_output(self): @@ -488,7 +488,8 @@ def double_number(n: int) -> int: result = await manager.call_tool("double_number", {"n": 5}) assert result == 10 result = await manager.call_tool("double_number", {"n": 5}, convert_result=True) - assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10} + assert isinstance(result, CallToolResult) + assert isinstance(result.content[0], TextContent) and result.structured_content == {"result": 10} @pytest.mark.anyio async def test_tool_with_typeddict_output(self): @@ -528,7 +529,7 @@ def get_person() -> Person: manager.add_tool(get_person) result = await manager.call_tool("get_person", {}, convert_result=True) # don't test unstructured output here, just the structured conversion - assert len(result) == 2 and result[1] == expected_output + assert isinstance(result, CallToolResult) and result.structured_content == expected_output @pytest.mark.anyio async def test_tool_with_list_output(self): @@ -546,7 +547,8 @@ def get_numbers() -> list[int]: result = await manager.call_tool("get_numbers", {}) assert result == expected_list result = await manager.call_tool("get_numbers", {}, convert_result=True) - assert isinstance(result[0][0], TextContent) and result[1] == expected_output + assert isinstance(result, CallToolResult) + assert isinstance(result.content[0], TextContent) and result.structured_content == expected_output @pytest.mark.anyio async def test_tool_without_structured_output(self):