Skip to content

Commit 7b97c4e

Browse files
committed
Build CallToolResult directly in convert_result
Have FuncMetadata.convert_result return a CallToolResult instead of the intermediate (content, structured_content) tuple / bare content sequence shapes. MCPServer.call_tool becomes a passthrough, and the ToolResult alias plus the convert_result overloads on ToolManager.call_tool are no longer needed.
1 parent 12e77aa commit 7b97c4e

5 files changed

Lines changed: 27 additions & 58 deletions

File tree

src/mcp/server/mcpserver/server.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -384,13 +384,7 @@ async def call_tool(
384384
"""Call a tool by name with arguments."""
385385
if context is None:
386386
context = Context(mcp_server=self)
387-
result = await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
388-
if isinstance(result, CallToolResult):
389-
return result
390-
if isinstance(result, tuple):
391-
content, structured_content = result
392-
return CallToolResult(content=list(content), structured_content=structured_content)
393-
return CallToolResult(content=list(result))
387+
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
394388

395389
async def list_resources(self) -> list[MCPResource]:
396390
"""List all available resources."""

src/mcp/server/mcpserver/tools/tool_manager.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable
4-
from typing import TYPE_CHECKING, Any, Literal, overload
4+
from typing import TYPE_CHECKING, Any
55

66
from mcp.server.mcpserver.exceptions import ToolError
77
from mcp.server.mcpserver.tools.base import Tool
8-
from mcp.server.mcpserver.utilities.func_metadata import ToolResult
98
from mcp.server.mcpserver.utilities.logging import get_logger
109
from mcp.types import Icon, ToolAnnotations
1110

@@ -72,22 +71,6 @@ def remove_tool(self, name: str) -> None:
7271
raise ToolError(f"Unknown tool: {name}")
7372
del self._tools[name]
7473

75-
@overload
76-
async def call_tool(
77-
self,
78-
name: str,
79-
arguments: dict[str, Any],
80-
context: Context[LifespanContextT, RequestT],
81-
convert_result: Literal[True],
82-
) -> ToolResult: ...
83-
@overload
84-
async def call_tool(
85-
self,
86-
name: str,
87-
arguments: dict[str, Any],
88-
context: Context[LifespanContextT, RequestT],
89-
convert_result: Literal[False] = False,
90-
) -> Any: ...
9174
async def call_tool(
9275
self,
9376
name: str,

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections.abc import Awaitable, Callable, Sequence
55
from itertools import chain
66
from types import GenericAlias
7-
from typing import Annotated, Any, TypeAlias, cast, get_args, get_origin, get_type_hints
7+
from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
88

99
import anyio
1010
import anyio.to_thread
@@ -28,8 +28,6 @@
2828

2929
logger = get_logger(__name__)
3030

31-
ToolResult: TypeAlias = CallToolResult | list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]
32-
3331

3432
class StrictJsonSchema(GenerateJsonSchema):
3533
"""A JSON schema generator that raises exceptions instead of emitting warnings.
@@ -90,14 +88,10 @@ async def call_fn_with_arg_validation(
9088
else:
9189
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))
9290

93-
def convert_result(self, result: Any) -> ToolResult:
94-
"""Convert a function call result to the format for the lowlevel tool call handler.
95-
96-
- If output_model is None, return the unstructured content directly.
97-
- If output_model is not None, convert the result to structured output format
98-
(dict[str, Any]) and return both unstructured and structured content.
91+
def convert_result(self, result: Any) -> CallToolResult:
92+
"""Convert a function call result into a `CallToolResult`.
9993
100-
Note: we return unstructured content here **even though the lowlevel server
94+
Note: we build unstructured content here **even though the lowlevel server
10195
tool call handler provides generic backwards compatibility serialization of
10296
structured content**. This is for MCPServer backwards compatibility: we need to
10397
retain MCPServer's ad hoc conversion logic for constructing unstructured output
@@ -113,16 +107,16 @@ def convert_result(self, result: Any) -> ToolResult:
113107
unstructured_content = _convert_to_content(result)
114108

115109
if self.output_schema is None:
116-
return unstructured_content
117-
else:
118-
if self.wrap_output:
119-
result = {"result": result}
110+
return CallToolResult(content=unstructured_content)
111+
112+
if self.wrap_output:
113+
result = {"result": result}
120114

121-
assert self.output_model is not None, "Output model must be set if output schema is defined"
122-
validated = self.output_model.model_validate(result)
123-
structured_content = validated.model_dump(mode="json", by_alias=True)
115+
assert self.output_model is not None, "Output model must be set if output schema is defined"
116+
validated = self.output_model.model_validate(result)
117+
structured_content = validated.model_dump(mode="json", by_alias=True)
124118

125-
return (unstructured_content, structured_content)
119+
return CallToolResult(content=unstructured_content, structured_content=structured_content)
126120

127121
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
128122
"""Pre-parse data from JSON.

tests/server/mcpserver/test_func_metadata.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,9 +1038,8 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
10381038

10391039
# Check that the actual output uses aliases too
10401040
result = ModelWithAliases(**{"first": "hello", "second": "world"})
1041-
converted = meta.convert_result(result)
1042-
assert isinstance(converted, tuple)
1043-
_, structured_content = converted
1041+
structured_content = meta.convert_result(result).structured_content
1042+
assert structured_content is not None
10441043

10451044
# The structured content should use aliases to match the schema
10461045
assert "first" in structured_content
@@ -1052,9 +1051,8 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
10521051

10531052
# Also test the case where we have a model with defaults to ensure aliases work in all cases
10541053
result_with_defaults = ModelWithAliases() # Uses default None values
1055-
converted_defaults = meta.convert_result(result_with_defaults)
1056-
assert isinstance(converted_defaults, tuple)
1057-
_, structured_content_defaults = converted_defaults
1054+
structured_content_defaults = meta.convert_result(result_with_defaults).structured_content
1055+
assert structured_content_defaults is not None
10581056

10591057
# Even with defaults, should use aliases in output
10601058
assert "first" in structured_content_defaults

tests/server/mcpserver/test_tool_manager.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp.server.mcpserver.exceptions import ToolError
1212
from mcp.server.mcpserver.tools import Tool, ToolManager
1313
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
14-
from mcp.types import TextContent, ToolAnnotations
14+
from mcp.types import CallToolResult, TextContent, ToolAnnotations
1515

1616

1717
class TestAddTools:
@@ -455,8 +455,8 @@ def get_user(user_id: int) -> UserOutput:
455455
manager.add_tool(get_user)
456456
result = await manager.call_tool("get_user", {"user_id": 1}, Context(), convert_result=True)
457457
# don't test unstructured output here, just the structured conversion
458-
assert isinstance(result, tuple)
459-
assert result[1] == {"name": "John", "age": 30}
458+
assert isinstance(result, CallToolResult)
459+
assert result.structured_content == {"name": "John", "age": 30}
460460

461461
@pytest.mark.anyio
462462
async def test_tool_with_primitive_output(self):
@@ -471,8 +471,8 @@ def double_number(n: int) -> int:
471471
result = await manager.call_tool("double_number", {"n": 5}, Context())
472472
assert result == 10
473473
result = await manager.call_tool("double_number", {"n": 5}, Context(), convert_result=True)
474-
assert isinstance(result, tuple)
475-
assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10}
474+
assert isinstance(result, CallToolResult)
475+
assert isinstance(result.content[0], TextContent) and result.structured_content == {"result": 10}
476476

477477
@pytest.mark.anyio
478478
async def test_tool_with_typeddict_output(self):
@@ -512,8 +512,8 @@ def get_person() -> Person:
512512
manager.add_tool(get_person)
513513
result = await manager.call_tool("get_person", {}, Context(), convert_result=True)
514514
# don't test unstructured output here, just the structured conversion
515-
assert isinstance(result, tuple)
516-
assert result[1] == expected_output
515+
assert isinstance(result, CallToolResult)
516+
assert result.structured_content == expected_output
517517

518518
@pytest.mark.anyio
519519
async def test_tool_with_list_output(self):
@@ -531,8 +531,8 @@ def get_numbers() -> list[int]:
531531
result = await manager.call_tool("get_numbers", {}, Context())
532532
assert result == expected_list
533533
result = await manager.call_tool("get_numbers", {}, Context(), convert_result=True)
534-
assert isinstance(result, tuple)
535-
assert isinstance(result[0][0], TextContent) and result[1] == expected_output
534+
assert isinstance(result, CallToolResult)
535+
assert isinstance(result.content[0], TextContent) and result.structured_content == expected_output
536536

537537
@pytest.mark.anyio
538538
async def test_tool_without_structured_output(self):

0 commit comments

Comments
 (0)