From ae0f5be7966fcf76f1f2edaa01607c3b7a769416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=9F=BA=E9=AD=81?= <1412414664@qq.com> Date: Mon, 8 Jun 2026 09:02:24 +0800 Subject: [PATCH] fix: correct MCPServer call_tool result type --- docs/migration.md | 11 ++++++++++ src/mcp/server/mcpserver/server.py | 30 +++++++++++++-------------- tests/server/mcpserver/test_server.py | 25 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 0f5fc91c3..d62c0c8b6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -8,6 +8,17 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t ## Breaking Changes +### `MCPServer.call_tool()` return annotation corrected + +`MCPServer.call_tool()` no longer advertises a raw `dict[str, Any]` +return. On v2 it returns exactly the shapes produced by the MCPServer +tool conversion path: a direct `CallToolResult`, a sequence of +`ContentBlock` values for unstructured tools, or a +`(content, structured_content)` tuple for structured tools. + +If you subclass `MCPServer` or annotate wrappers around `call_tool()`, +update those annotations to match the corrected return shape. + ### `streamablehttp_client` removed The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index ec2365810..f989ab6be 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,11 +4,10 @@ import base64 import inspect -import json import re from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import Any, Generic, Literal, TypeVar, overload +from typing import Any, Generic, Literal, TypeAlias, TypeVar, cast, overload import anyio import pydantic_core @@ -76,6 +75,8 @@ _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) +ToolResult: TypeAlias = CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]] + class Settings(BaseSettings, Generic[LifespanResultT]): """MCPServer settings. @@ -317,18 +318,10 @@ async def _handle_call_tool( 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. + unstructured_content, structured_content = cast(tuple[Sequence[ContentBlock], dict[str, Any]], result) return CallToolResult( - content=[TextContent(type="text", text=json.dumps(result, indent=2))], - structured_content=result, + content=list(unstructured_content), + structured_content=structured_content, ) return CallToolResult(content=list(result)) @@ -399,8 +392,15 @@ 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.""" + ) -> ToolResult: + """Call a tool by name with arguments. + + Returns: + The tool result converted for the low-level handler: + - a `CallToolResult` returned directly by the tool, + - a sequence of content blocks for unstructured tools, or + - a `(content, structured_content)` tuple for tools with structured output. + """ 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/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 21352b5f2..41f5f83db 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -21,6 +21,7 @@ from mcp.types import ( AudioContent, BlobResourceContents, + CallToolResult, Completion, CompletionArgument, CompletionContext, @@ -304,6 +305,30 @@ async def test_tool_return_value_conversion(self): assert result.structured_content is not None assert result.structured_content == {"result": 3} + async def test_call_tool_returns_declared_result_shapes(self): + mcp = MCPServer() + + @mcp.tool() + def direct_result() -> CallToolResult: + return CallToolResult(content=[TextContent(text="direct")]) + + @mcp.tool(structured_output=False) + def unstructured() -> str: + return "plain" + + @mcp.tool() + def structured() -> int: + return 3 + + direct = await mcp.call_tool("direct_result", {}) + assert direct == CallToolResult(content=[TextContent(text="direct")]) + + bare_content = await mcp.call_tool("unstructured", {}) + assert bare_content == [TextContent(text="plain")] + + structured_result = await mcp.call_tool("structured", {}) + assert structured_result == ([TextContent(text="3")], {"result": 3}) + async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png"