Skip to content

Commit e98fca2

Browse files
committed
Return CallToolResult from MCPServer.call_tool
Move the conversion of the internal tool-result shapes (bare content sequence and (content, structured_content) tuple) out of the low-level handler and into call_tool, so the public API has a single CallToolResult return type. Type the conversion chain with a ToolResult alias and convert_result overloads keyed on the convert_result flag, removing the cast and type: ignore the previous approach needed.
1 parent e598d91 commit e98fca2

8 files changed

Lines changed: 78 additions & 54 deletions

File tree

docs/migration.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t
88

99
## Breaking Changes
1010

11-
### `MCPServer.call_tool()` return annotation corrected
11+
### `MCPServer.call_tool()` returns `CallToolResult`
1212

13-
`MCPServer.call_tool()` no longer advertises a raw `dict[str, Any]`
14-
return. On v2 it returns exactly the shapes produced by the MCPServer
15-
tool conversion path: a direct `CallToolResult`, a sequence of
16-
`ContentBlock` values for unstructured tools, or a
17-
`(content, structured_content)` tuple for structured tools.
13+
`MCPServer.call_tool()` now always returns a `CallToolResult`. It previously
14+
advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal
15+
conversion shapes (a bare content sequence or a `(content, structured_content)`
16+
tuple), forcing callers to re-assemble a `CallToolResult` themselves.
1817

19-
If you subclass `MCPServer` or annotate wrappers around `call_tool()`,
20-
update those annotations to match the corrected return shape.
18+
If you call `MCPServer.call_tool()` directly, read `.content` and
19+
`.structured_content` off the returned `CallToolResult` instead of branching on
20+
the result type.
2121

2222
### `streamablehttp_client` removed
2323

src/mcp/server/mcpserver/server.py

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import base64
66
import inspect
77
import re
8-
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
8+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
99
from contextlib import AbstractAsyncContextManager, asynccontextmanager
10-
from typing import Any, Generic, Literal, TypeAlias, TypeVar, cast, overload
10+
from typing import Any, Generic, Literal, TypeVar, overload
1111

1212
import anyio
1313
import pydantic_core
@@ -50,7 +50,6 @@
5050
CompleteRequestParams,
5151
CompleteResult,
5252
Completion,
53-
ContentBlock,
5453
GetPromptRequestParams,
5554
GetPromptResult,
5655
Icon,
@@ -75,8 +74,6 @@
7574

7675
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
7776

78-
ToolResult: TypeAlias = CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]
79-
8077

8178
class Settings(BaseSettings, Generic[LifespanResultT]):
8279
"""MCPServer settings.
@@ -310,20 +307,11 @@ async def _handle_call_tool(
310307
) -> CallToolResult:
311308
context = Context(request_context=ctx, mcp_server=self)
312309
try:
313-
result = await self.call_tool(params.name, params.arguments or {}, context)
310+
return await self.call_tool(params.name, params.arguments or {}, context)
314311
except MCPError:
315312
raise
316313
except Exception as e:
317314
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
318-
if isinstance(result, CallToolResult):
319-
return result
320-
if isinstance(result, tuple) and len(result) == 2:
321-
unstructured_content, structured_content = cast(tuple[Sequence[ContentBlock], dict[str, Any]], result)
322-
return CallToolResult(
323-
content=list(unstructured_content),
324-
structured_content=structured_content,
325-
)
326-
return CallToolResult(content=list(result))
327315

328316
async def _handle_list_resources(
329317
self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None
@@ -392,18 +380,17 @@ async def list_tools(self) -> list[MCPTool]:
392380

393381
async def call_tool(
394382
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
395-
) -> ToolResult:
396-
"""Call a tool by name with arguments.
397-
398-
Returns:
399-
The tool result converted for the low-level handler:
400-
- a `CallToolResult` returned directly by the tool,
401-
- a sequence of content blocks for unstructured tools, or
402-
- a `(content, structured_content)` tuple for tools with structured output.
403-
"""
383+
) -> CallToolResult:
384+
"""Call a tool by name with arguments."""
404385
if context is None:
405386
context = Context(mcp_server=self)
406-
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
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))
407394

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

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
from collections.abc import Callable
44
from functools import cached_property
5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING, Any, Literal, overload
66

77
from pydantic import BaseModel, Field
88

99
from mcp.server.mcpserver.exceptions import ToolError
1010
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
11-
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
11+
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, ToolResult, func_metadata
1212
from mcp.shared._callable_inspection import is_async_callable
1313
from mcp.shared.exceptions import UrlElicitationRequiredError
1414
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
@@ -88,6 +88,17 @@ def from_function(
8888
meta=meta,
8989
)
9090

91+
@overload
92+
async def run(
93+
self, arguments: dict[str, Any], context: Context[LifespanContextT, RequestT], convert_result: Literal[True]
94+
) -> ToolResult: ...
95+
@overload
96+
async def run(
97+
self,
98+
arguments: dict[str, Any],
99+
context: Context[LifespanContextT, RequestT],
100+
convert_result: Literal[False] = False,
101+
) -> Any: ...
91102
async def run(
92103
self,
93104
arguments: dict[str, Any],

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

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

33
from collections.abc import Callable
4-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING, Any, Literal, overload
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
89
from mcp.server.mcpserver.utilities.logging import get_logger
910
from mcp.types import Icon, ToolAnnotations
1011

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

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: ...
7491
async def call_tool(
7592
self,
7693
name: str,

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

Lines changed: 5 additions & 3 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, cast, get_args, get_origin, get_type_hints
7+
from typing import Annotated, Any, TypeAlias, cast, get_args, get_origin, get_type_hints
88

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

2929
logger = get_logger(__name__)
3030

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

3234
class StrictJsonSchema(GenerateJsonSchema):
3335
"""A JSON schema generator that raises exceptions instead of emitting warnings.
@@ -88,7 +90,7 @@ async def call_fn_with_arg_validation(
8890
else:
8991
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))
9092

91-
def convert_result(self, result: Any) -> Any:
93+
def convert_result(self, result: Any) -> ToolResult:
9294
"""Convert a function call result to the format for the lowlevel tool call handler.
9395
9496
- If output_model is None, return the unstructured content directly.
@@ -496,7 +498,7 @@ class DictModel(RootModel[dict_annotation]):
496498
return DictModel
497499

498500

499-
def _convert_to_content(result: Any) -> Sequence[ContentBlock]:
501+
def _convert_to_content(result: Any) -> list[ContentBlock]:
500502
"""Convert a result to a sequence of content objects.
501503
502504
Note: This conversion logic comes from previous versions of MCPServer and is being

tests/server/mcpserver/test_func_metadata.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,9 @@ 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-
_, structured_content = meta.convert_result(result)
1041+
converted = meta.convert_result(result)
1042+
assert isinstance(converted, tuple)
1043+
_, structured_content = converted
10421044

10431045
# The structured content should use aliases to match the schema
10441046
assert "first" in structured_content
@@ -1050,7 +1052,9 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
10501052

10511053
# Also test the case where we have a model with defaults to ensure aliases work in all cases
10521054
result_with_defaults = ModelWithAliases() # Uses default None values
1053-
_, structured_content_defaults = meta.convert_result(result_with_defaults)
1055+
converted_defaults = meta.convert_result(result_with_defaults)
1056+
assert isinstance(converted_defaults, tuple)
1057+
_, structured_content_defaults = converted_defaults
10541058

10551059
# Even with defaults, should use aliases in output
10561060
assert "first" in structured_content_defaults

tests/server/mcpserver/test_server.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,12 @@ async def test_tool_return_value_conversion(self):
305305
assert result.structured_content is not None
306306
assert result.structured_content == {"result": 3}
307307

308-
async def test_call_tool_returns_declared_result_shapes(self):
308+
async def test_call_tool_always_returns_call_tool_result(self):
309309
mcp = MCPServer()
310310

311311
@mcp.tool()
312-
def direct_result() -> CallToolResult:
313-
return CallToolResult(content=[TextContent(text="direct")])
312+
def direct() -> CallToolResult:
313+
return CallToolResult(content=[TextContent(type="text", text="direct")])
314314

315315
@mcp.tool(structured_output=False)
316316
def unstructured() -> str:
@@ -320,14 +320,13 @@ def unstructured() -> str:
320320
def structured() -> int:
321321
return 3
322322

323-
direct = await mcp.call_tool("direct_result", {})
324-
assert direct == CallToolResult(content=[TextContent(text="direct")])
325-
326-
bare_content = await mcp.call_tool("unstructured", {})
327-
assert bare_content == [TextContent(text="plain")]
328-
329-
structured_result = await mcp.call_tool("structured", {})
330-
assert structured_result == ([TextContent(text="3")], {"result": 3})
323+
assert await mcp.call_tool("direct", {}) == CallToolResult(content=[TextContent(type="text", text="direct")])
324+
assert await mcp.call_tool("unstructured", {}) == CallToolResult(
325+
content=[TextContent(type="text", text="plain")]
326+
)
327+
assert await mcp.call_tool("structured", {}) == CallToolResult(
328+
content=[TextContent(type="text", text="3")], structured_content={"result": 3}
329+
)
331330

332331
async def test_tool_image_helper(self, tmp_path: Path):
333332
# Create a test image

tests/server/mcpserver/test_tool_manager.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +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 len(result) == 2 and result[1] == {"name": "John", "age": 30}
458+
assert isinstance(result, tuple)
459+
assert result[1] == {"name": "John", "age": 30}
459460

460461
@pytest.mark.anyio
461462
async def test_tool_with_primitive_output(self):
@@ -470,6 +471,7 @@ def double_number(n: int) -> int:
470471
result = await manager.call_tool("double_number", {"n": 5}, Context())
471472
assert result == 10
472473
result = await manager.call_tool("double_number", {"n": 5}, Context(), convert_result=True)
474+
assert isinstance(result, tuple)
473475
assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10}
474476

475477
@pytest.mark.anyio
@@ -510,7 +512,8 @@ def get_person() -> Person:
510512
manager.add_tool(get_person)
511513
result = await manager.call_tool("get_person", {}, Context(), convert_result=True)
512514
# don't test unstructured output here, just the structured conversion
513-
assert len(result) == 2 and result[1] == expected_output
515+
assert isinstance(result, tuple)
516+
assert result[1] == expected_output
514517

515518
@pytest.mark.anyio
516519
async def test_tool_with_list_output(self):
@@ -528,6 +531,7 @@ def get_numbers() -> list[int]:
528531
result = await manager.call_tool("get_numbers", {}, Context())
529532
assert result == expected_list
530533
result = await manager.call_tool("get_numbers", {}, Context(), convert_result=True)
534+
assert isinstance(result, tuple)
531535
assert isinstance(result[0][0], TextContent) and result[1] == expected_output
532536

533537
@pytest.mark.anyio

0 commit comments

Comments
 (0)