Skip to content

Commit a973fae

Browse files
committed
fix: handle CallToolResult in _convert_to_content to prevent double-serialization
When a tool returns a CallToolResult nested inside a list (or any other sequence), _convert_to_content would fall through to the pydantic_core.to_json fallback, serializing the entire CallToolResult object as a JSON string and wrapping it in a TextContent. This caused the client to receive: TextContent(text='{"_meta": null, "content": [...], "isError": false}') instead of the actual content blocks from the CallToolResult. The fix adds an explicit guard in _convert_to_content for CallToolResult objects: when one is encountered, its content blocks are extracted and flattened into the result sequence rather than being JSON-serialized. The direct return case (tool returning CallToolResult at the top level) was already correctly handled by the isinstance check in convert_result. Reported-by: wilson-urdaneta Github-Issue: #592 Reported-by: wilson-urdaneta Github-Issue: #592
1 parent 0fe16dd commit a973fae

File tree

3 files changed

+57
-1
lines changed

3 files changed

+57
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ mcp = "mcp.cli:app [cli]"
5252

5353
[tool.uv]
5454
default-groups = ["dev", "docs"]
55-
required-version = ">=0.9.5"
55+
required-version = ">=0.8.17"
5656

5757
[dependency-groups]
5858
dev = [

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,9 @@ def _convert_to_content(result: Any) -> Sequence[ContentBlock]:
503503
if isinstance(result, ContentBlock):
504504
return [result]
505505

506+
if isinstance(result, CallToolResult):
507+
return list(chain.from_iterable(_convert_to_content(item) for item in result.content))
508+
506509
if isinstance(result, Image):
507510
return [result.to_image_content()]
508511

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Regression test for issue #592.
2+
3+
When a tool returns a CallToolResult (directly or nested in a list), the client
4+
should receive the TextContent.text value as-is, not as the JSON serialization
5+
of the entire CallToolResult object.
6+
"""
7+
8+
import pytest
9+
10+
from mcp.client.client import Client
11+
from mcp.server.mcpserver import MCPServer
12+
from mcp.types import CallToolResult, TextContent
13+
14+
pytestmark = pytest.mark.anyio
15+
16+
17+
@pytest.fixture
18+
def app() -> MCPServer:
19+
server = MCPServer("test")
20+
21+
@server.tool("echo_direct")
22+
async def echo_direct(message: int = 0) -> CallToolResult:
23+
"""Return CallToolResult directly with a primitive text value."""
24+
return CallToolResult(content=[TextContent(type="text", text=str(message))])
25+
26+
@server.tool("echo_in_list")
27+
async def echo_in_list(message: int = 0): # type: ignore[return]
28+
"""Return CallToolResult nested inside a list."""
29+
return [CallToolResult(content=[TextContent(type="text", text=str(message))])]
30+
31+
return server
32+
33+
34+
async def test_call_tool_result_direct_returns_primitive_text(app: MCPServer) -> None:
35+
"""A tool returning CallToolResult directly should preserve TextContent.text."""
36+
async with Client(app) as client:
37+
result = await client.call_tool("echo_direct", {"message": 42})
38+
assert len(result.content) == 1
39+
text_content = result.content[0]
40+
assert isinstance(text_content, TextContent)
41+
assert text_content.text == "42"
42+
43+
44+
async def test_call_tool_result_in_list_returns_primitive_text(app: MCPServer) -> None:
45+
"""A tool returning [CallToolResult(...)] should preserve TextContent.text, not
46+
serialize the entire CallToolResult object as JSON into the text field."""
47+
async with Client(app) as client:
48+
result = await client.call_tool("echo_in_list", {"message": 42})
49+
assert len(result.content) == 1
50+
text_content = result.content[0]
51+
assert isinstance(text_content, TextContent)
52+
# Before the fix, this would be the full JSON of the CallToolResult object
53+
assert text_content.text == "42"

0 commit comments

Comments
 (0)