Skip to content

Commit beec178

Browse files
committed
fix MCPServer tool result shapes
1 parent e196857 commit beec178

2 files changed

Lines changed: 64 additions & 11 deletions

File tree

src/mcp/server/mcpserver/server.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
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
@@ -76,6 +75,8 @@
7675

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

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

8081
class Settings(BaseSettings, Generic[LifespanResultT]):
8182
"""MCPServer settings.
@@ -322,14 +323,6 @@ async def _handle_call_tool(
322323
content=list(unstructured_content), # type: ignore[arg-type]
323324
structured_content=structured_content, # type: ignore[arg-type]
324325
)
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.
329-
return CallToolResult(
330-
content=[TextContent(type="text", text=json.dumps(result, indent=2))],
331-
structured_content=result,
332-
)
333326
return CallToolResult(content=list(result))
334327

335328
async def _handle_list_resources(
@@ -399,7 +392,7 @@ 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]:
395+
) -> ToolResult:
403396
"""Call a tool by name with arguments."""
404397
if context is None:
405398
context = Context(mcp_server=self)

tests/server/mcpserver/test_server.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import base64
22
from pathlib import Path
3-
from typing import Any
3+
from typing import Any, get_type_hints
44
from unittest.mock import AsyncMock, MagicMock, patch
55

66
import pytest
@@ -15,12 +15,14 @@
1515
from mcp.server.mcpserver.exceptions import ToolError
1616
from mcp.server.mcpserver.prompts.base import Message, UserMessage
1717
from mcp.server.mcpserver.resources import FileResource, FunctionResource
18+
from mcp.server.mcpserver.server import ToolResult
1819
from mcp.server.mcpserver.utilities.types import Audio, Image
1920
from mcp.server.transport_security import TransportSecuritySettings
2021
from mcp.shared.exceptions import MCPError
2122
from mcp.types import (
2223
AudioContent,
2324
BlobResourceContents,
25+
CallToolResult,
2426
Completion,
2527
CompletionArgument,
2628
CompletionContext,
@@ -304,6 +306,64 @@ async def test_tool_return_value_conversion(self):
304306
assert result.structured_content is not None
305307
assert result.structured_content == {"result": 3}
306308

309+
def test_call_tool_return_annotation_matches_reachable_shapes(self):
310+
hints = get_type_hints(MCPServer.call_tool)
311+
assert hints["return"] == ToolResult
312+
313+
async def test_call_tool_preserves_direct_call_tool_result(self):
314+
mcp = MCPServer()
315+
316+
@mcp.tool()
317+
def direct_result() -> CallToolResult:
318+
return CallToolResult(content=[TextContent(type="text", text="direct")])
319+
320+
result = await mcp.call_tool("direct_result", {})
321+
assert isinstance(result, CallToolResult)
322+
assert result.content == [TextContent(type="text", text="direct")]
323+
324+
async with Client(mcp) as client:
325+
handled = await client.call_tool("direct_result", {})
326+
assert handled.content == result.content
327+
assert handled.structured_content is None
328+
329+
async def test_call_tool_wraps_bare_content_sequence(self):
330+
mcp = MCPServer()
331+
332+
def raw_blocks() -> list[ContentBlock]:
333+
return [TextContent(type="text", text="raw")]
334+
335+
mcp.add_tool(raw_blocks, structured_output=False)
336+
337+
result = await mcp.call_tool("raw_blocks", {})
338+
assert isinstance(result, list)
339+
assert all(isinstance(item, ContentBlock) for item in result)
340+
341+
async with Client(mcp) as client:
342+
handled = await client.call_tool("raw_blocks", {})
343+
assert handled.content == result
344+
assert handled.structured_content is None
345+
346+
async def test_call_tool_wraps_structured_tuple_result(self):
347+
class UserOutput(BaseModel):
348+
name: str
349+
age: int
350+
351+
def get_user() -> UserOutput:
352+
return UserOutput(name="John Doe", age=30)
353+
354+
mcp = MCPServer()
355+
mcp.add_tool(get_user)
356+
357+
result = await mcp.call_tool("get_user", {})
358+
assert isinstance(result, tuple)
359+
unstructured_content, structured_content = result
360+
assert structured_content == {"name": "John Doe", "age": 30}
361+
362+
async with Client(mcp) as client:
363+
handled = await client.call_tool("get_user", {})
364+
assert handled.content == list(unstructured_content)
365+
assert handled.structured_content == structured_content
366+
307367
async def test_tool_image_helper(self, tmp_path: Path):
308368
# Create a test image
309369
image_path = tmp_path / "test.png"

0 commit comments

Comments
 (0)