Skip to content

Commit ae0f5be

Browse files
committed
fix: correct MCPServer call_tool result type
1 parent ac96f88 commit ae0f5be

3 files changed

Lines changed: 51 additions & 15 deletions

File tree

docs/migration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ 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
12+
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.
18+
19+
If you subclass `MCPServer` or annotate wrappers around `call_tool()`,
20+
update those annotations to match the corrected return shape.
21+
1122
### `streamablehttp_client` removed
1223

1324
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.

src/mcp/server/mcpserver/server.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44

55
import base64
66
import inspect
7-
import json
87
import re
98
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
109
from contextlib import AbstractAsyncContextManager, asynccontextmanager
11-
from typing import Any, Generic, Literal, TypeVar, overload
10+
from typing import Any, Generic, Literal, TypeAlias, TypeVar, cast, overload
1211

1312
import anyio
1413
import pydantic_core
@@ -76,6 +75,8 @@
7675

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

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

8081
class Settings(BaseSettings, Generic[LifespanResultT]):
8182
"""MCPServer settings.
@@ -317,18 +318,10 @@ async def _handle_call_tool(
317318
if isinstance(result, CallToolResult):
318319
return result
319320
if isinstance(result, tuple) and len(result) == 2:
320-
unstructured_content, structured_content = result
321-
return CallToolResult(
322-
content=list(unstructured_content), # type: ignore[arg-type]
323-
structured_content=structured_content, # type: ignore[arg-type]
324-
)
325-
if isinstance(result, dict): # pragma: no cover
326-
# TODO: this code path is unreachable — convert_result never returns a raw dict.
327-
# The call_tool return type (Sequence[ContentBlock] | dict[str, Any]) is wrong
328-
# and needs to be cleaned up.
321+
unstructured_content, structured_content = cast(tuple[Sequence[ContentBlock], dict[str, Any]], result)
329322
return CallToolResult(
330-
content=[TextContent(type="text", text=json.dumps(result, indent=2))],
331-
structured_content=result,
323+
content=list(unstructured_content),
324+
structured_content=structured_content,
332325
)
333326
return CallToolResult(content=list(result))
334327

@@ -399,8 +392,15 @@ async def list_tools(self) -> list[MCPTool]:
399392

400393
async def call_tool(
401394
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
402-
) -> Sequence[ContentBlock] | dict[str, Any]:
403-
"""Call a tool by name with arguments."""
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+
"""
404404
if context is None:
405405
context = Context(mcp_server=self)
406406
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)

tests/server/mcpserver/test_server.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from mcp.types import (
2222
AudioContent,
2323
BlobResourceContents,
24+
CallToolResult,
2425
Completion,
2526
CompletionArgument,
2627
CompletionContext,
@@ -304,6 +305,30 @@ async def test_tool_return_value_conversion(self):
304305
assert result.structured_content is not None
305306
assert result.structured_content == {"result": 3}
306307

308+
async def test_call_tool_returns_declared_result_shapes(self):
309+
mcp = MCPServer()
310+
311+
@mcp.tool()
312+
def direct_result() -> CallToolResult:
313+
return CallToolResult(content=[TextContent(text="direct")])
314+
315+
@mcp.tool(structured_output=False)
316+
def unstructured() -> str:
317+
return "plain"
318+
319+
@mcp.tool()
320+
def structured() -> int:
321+
return 3
322+
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})
331+
307332
async def test_tool_image_helper(self, tmp_path: Path):
308333
# Create a test image
309334
image_path = tmp_path / "test.png"

0 commit comments

Comments
 (0)