From beec178235619a4da5db8fc9a6b6cf8d2f01eebc Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Thu, 11 Jun 2026 10:14:39 +0200 Subject: [PATCH] fix MCPServer tool result shapes --- src/mcp/server/mcpserver/server.py | 13 ++---- tests/server/mcpserver/test_server.py | 62 ++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index fdb69571d..811f28bd4 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 @@ -76,6 +75,8 @@ _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) +ToolResult = CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]] + class Settings(BaseSettings, Generic[LifespanResultT]): """MCPServer settings. @@ -322,14 +323,6 @@ async def _handle_call_tool( 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 +392,7 @@ 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]: + ) -> ToolResult: """Call a tool by name with arguments.""" if context is None: context = Context(mcp_server=self) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 21352b5f2..37b3c335f 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1,6 +1,6 @@ import base64 from pathlib import Path -from typing import Any +from typing import Any, get_type_hints from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -15,12 +15,14 @@ from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource +from mcp.server.mcpserver.server import ToolResult from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.types import ( AudioContent, BlobResourceContents, + CallToolResult, Completion, CompletionArgument, CompletionContext, @@ -304,6 +306,64 @@ async def test_tool_return_value_conversion(self): assert result.structured_content is not None assert result.structured_content == {"result": 3} + def test_call_tool_return_annotation_matches_reachable_shapes(self): + hints = get_type_hints(MCPServer.call_tool) + assert hints["return"] == ToolResult + + async def test_call_tool_preserves_direct_call_tool_result(self): + mcp = MCPServer() + + @mcp.tool() + def direct_result() -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text="direct")]) + + result = await mcp.call_tool("direct_result", {}) + assert isinstance(result, CallToolResult) + assert result.content == [TextContent(type="text", text="direct")] + + async with Client(mcp) as client: + handled = await client.call_tool("direct_result", {}) + assert handled.content == result.content + assert handled.structured_content is None + + async def test_call_tool_wraps_bare_content_sequence(self): + mcp = MCPServer() + + def raw_blocks() -> list[ContentBlock]: + return [TextContent(type="text", text="raw")] + + mcp.add_tool(raw_blocks, structured_output=False) + + result = await mcp.call_tool("raw_blocks", {}) + assert isinstance(result, list) + assert all(isinstance(item, ContentBlock) for item in result) + + async with Client(mcp) as client: + handled = await client.call_tool("raw_blocks", {}) + assert handled.content == result + assert handled.structured_content is None + + async def test_call_tool_wraps_structured_tuple_result(self): + class UserOutput(BaseModel): + name: str + age: int + + def get_user() -> UserOutput: + return UserOutput(name="John Doe", age=30) + + mcp = MCPServer() + mcp.add_tool(get_user) + + result = await mcp.call_tool("get_user", {}) + assert isinstance(result, tuple) + unstructured_content, structured_content = result + assert structured_content == {"name": "John Doe", "age": 30} + + async with Client(mcp) as client: + handled = await client.call_tool("get_user", {}) + assert handled.content == list(unstructured_content) + assert handled.structured_content == structured_content + async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png"