Skip to content

Commit fda4c54

Browse files
冯基魁Kludex
andauthored
fix: correct MCPServer call_tool result type (#2816)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
1 parent f253682 commit fda4c54

6 files changed

Lines changed: 63 additions & 45 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()` returns `CallToolResult`
12+
13+
`MCPServer.call_tool()` now always returns a `CallToolResult`. It previously
14+
advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal
15+
conversion shapes (a bare content sequence or a `(content, structured_content)`
16+
tuple), forcing callers to re-assemble a `CallToolResult` themselves.
17+
18+
If you call `MCPServer.call_tool()` directly, read `.content` and
19+
`.structured_content` off the returned `CallToolResult` instead of branching on
20+
the result type.
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: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44

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

@@ -53,7 +52,6 @@
5352
CompleteRequestParams,
5453
CompleteResult,
5554
Completion,
56-
ContentBlock,
5755
GetPromptRequestParams,
5856
GetPromptResult,
5957
Icon,
@@ -311,28 +309,11 @@ async def _handle_call_tool(
311309
) -> CallToolResult:
312310
context = Context(request_context=ctx, mcp_server=self)
313311
try:
314-
result = await self.call_tool(params.name, params.arguments or {}, context)
312+
return await self.call_tool(params.name, params.arguments or {}, context)
315313
except MCPError:
316314
raise
317315
except Exception as e:
318316
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
319-
if isinstance(result, CallToolResult):
320-
return result
321-
if isinstance(result, tuple) and len(result) == 2:
322-
unstructured_content, structured_content = result
323-
return CallToolResult(
324-
content=list(unstructured_content), # type: ignore[arg-type]
325-
structured_content=structured_content, # type: ignore[arg-type]
326-
)
327-
if isinstance(result, dict): # pragma: no cover
328-
# TODO: this code path is unreachable — convert_result never returns a raw dict.
329-
# The call_tool return type (Sequence[ContentBlock] | dict[str, Any]) is wrong
330-
# and needs to be cleaned up.
331-
return CallToolResult(
332-
content=[TextContent(type="text", text=json.dumps(result, indent=2))],
333-
structured_content=result,
334-
)
335-
return CallToolResult(content=list(result))
336317

337318
async def _handle_list_resources(
338319
self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None
@@ -406,7 +387,7 @@ async def list_tools(self) -> list[MCPTool]:
406387

407388
async def call_tool(
408389
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
409-
) -> Sequence[ContentBlock] | dict[str, Any]:
390+
) -> CallToolResult:
410391
"""Call a tool by name with arguments."""
411392
if context is None:
412393
context = Context(mcp_server=self)

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

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,10 @@ async def call_fn_with_arg_validation(
8888
else:
8989
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))
9090

91-
def convert_result(self, result: Any) -> Any:
92-
"""Convert a function call result to the format for the lowlevel tool call handler.
91+
def convert_result(self, result: Any) -> CallToolResult:
92+
"""Convert a function call result into a `CallToolResult`.
9393
94-
- If output_model is None, return the unstructured content directly.
95-
- If output_model is not None, convert the result to structured output format
96-
(dict[str, Any]) and return both unstructured and structured content.
97-
98-
Note: we return unstructured content here **even though the lowlevel server
94+
Note: we build unstructured content here **even though the lowlevel server
9995
tool call handler provides generic backwards compatibility serialization of
10096
structured content**. This is for MCPServer backwards compatibility: we need to
10197
retain MCPServer's ad hoc conversion logic for constructing unstructured output
@@ -111,16 +107,16 @@ def convert_result(self, result: Any) -> Any:
111107
unstructured_content = _convert_to_content(result)
112108

113109
if self.output_schema is None:
114-
return unstructured_content
115-
else:
116-
if self.wrap_output:
117-
result = {"result": result}
110+
return CallToolResult(content=unstructured_content)
111+
112+
if self.wrap_output:
113+
result = {"result": result}
118114

119-
assert self.output_model is not None, "Output model must be set if output schema is defined"
120-
validated = self.output_model.model_validate(result)
121-
structured_content = validated.model_dump(mode="json", by_alias=True)
115+
assert self.output_model is not None, "Output model must be set if output schema is defined"
116+
validated = self.output_model.model_validate(result)
117+
structured_content = validated.model_dump(mode="json", by_alias=True)
122118

123-
return (unstructured_content, structured_content)
119+
return CallToolResult(content=unstructured_content, structured_content=structured_content)
124120

125121
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
126122
"""Pre-parse data from JSON.
@@ -496,7 +492,7 @@ class DictModel(RootModel[dict_annotation]):
496492
return DictModel
497493

498494

499-
def _convert_to_content(result: Any) -> Sequence[ContentBlock]:
495+
def _convert_to_content(result: Any) -> list[ContentBlock]:
500496
"""Convert a result to a sequence of content objects.
501497
502498
Note: This conversion logic comes from previous versions of MCPServer and is being

tests/server/mcpserver/test_func_metadata.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,8 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
10381038

10391039
# Check that the actual output uses aliases too
10401040
result = ModelWithAliases(**{"first": "hello", "second": "world"})
1041-
_, structured_content = meta.convert_result(result)
1041+
structured_content = meta.convert_result(result).structured_content
1042+
assert structured_content is not None
10421043

10431044
# The structured content should use aliases to match the schema
10441045
assert "first" in structured_content
@@ -1050,7 +1051,8 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
10501051

10511052
# Also test the case where we have a model with defaults to ensure aliases work in all cases
10521053
result_with_defaults = ModelWithAliases() # Uses default None values
1053-
_, structured_content_defaults = meta.convert_result(result_with_defaults)
1054+
structured_content_defaults = meta.convert_result(result_with_defaults).structured_content
1055+
assert structured_content_defaults is not None
10541056

10551057
# Even with defaults, should use aliases in output
10561058
assert "first" in structured_content_defaults

tests/server/mcpserver/test_server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
INVALID_PARAMS,
2424
AudioContent,
2525
BlobResourceContents,
26+
CallToolResult,
2627
Completion,
2728
CompletionArgument,
2829
CompletionContext,
@@ -306,6 +307,29 @@ async def test_tool_return_value_conversion(self):
306307
assert result.structured_content is not None
307308
assert result.structured_content == {"result": 3}
308309

310+
async def test_call_tool_always_returns_call_tool_result(self):
311+
mcp = MCPServer()
312+
313+
@mcp.tool()
314+
def direct() -> CallToolResult:
315+
return CallToolResult(content=[TextContent(type="text", text="direct")])
316+
317+
@mcp.tool(structured_output=False)
318+
def unstructured() -> str:
319+
return "plain"
320+
321+
@mcp.tool()
322+
def structured() -> int:
323+
return 3
324+
325+
assert await mcp.call_tool("direct", {}) == CallToolResult(content=[TextContent(type="text", text="direct")])
326+
assert await mcp.call_tool("unstructured", {}) == CallToolResult(
327+
content=[TextContent(type="text", text="plain")]
328+
)
329+
assert await mcp.call_tool("structured", {}) == CallToolResult(
330+
content=[TextContent(type="text", text="3")], structured_content={"result": 3}
331+
)
332+
309333
async def test_tool_image_helper(self, tmp_path: Path):
310334
# Create a test image
311335
image_path = tmp_path / "test.png"

tests/server/mcpserver/test_tool_manager.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp.server.mcpserver.exceptions import ToolError
1212
from mcp.server.mcpserver.tools import Tool, ToolManager
1313
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
14-
from mcp.types import TextContent, ToolAnnotations
14+
from mcp.types import CallToolResult, TextContent, ToolAnnotations
1515

1616

1717
class TestAddTools:
@@ -455,7 +455,8 @@ def get_user(user_id: int) -> UserOutput:
455455
manager.add_tool(get_user)
456456
result = await manager.call_tool("get_user", {"user_id": 1}, Context(), convert_result=True)
457457
# don't test unstructured output here, just the structured conversion
458-
assert len(result) == 2 and result[1] == {"name": "John", "age": 30}
458+
assert isinstance(result, CallToolResult)
459+
assert result.structured_content == {"name": "John", "age": 30}
459460

460461
@pytest.mark.anyio
461462
async def test_tool_with_primitive_output(self):
@@ -470,7 +471,8 @@ def double_number(n: int) -> int:
470471
result = await manager.call_tool("double_number", {"n": 5}, Context())
471472
assert result == 10
472473
result = await manager.call_tool("double_number", {"n": 5}, Context(), convert_result=True)
473-
assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10}
474+
assert isinstance(result, CallToolResult)
475+
assert isinstance(result.content[0], TextContent) and result.structured_content == {"result": 10}
474476

475477
@pytest.mark.anyio
476478
async def test_tool_with_typeddict_output(self):
@@ -510,7 +512,8 @@ def get_person() -> Person:
510512
manager.add_tool(get_person)
511513
result = await manager.call_tool("get_person", {}, Context(), convert_result=True)
512514
# don't test unstructured output here, just the structured conversion
513-
assert len(result) == 2 and result[1] == expected_output
515+
assert isinstance(result, CallToolResult)
516+
assert result.structured_content == expected_output
514517

515518
@pytest.mark.anyio
516519
async def test_tool_with_list_output(self):
@@ -528,7 +531,8 @@ def get_numbers() -> list[int]:
528531
result = await manager.call_tool("get_numbers", {}, Context())
529532
assert result == expected_list
530533
result = await manager.call_tool("get_numbers", {}, Context(), convert_result=True)
531-
assert isinstance(result[0][0], TextContent) and result[1] == expected_output
534+
assert isinstance(result, CallToolResult)
535+
assert isinstance(result.content[0], TextContent) and result.structured_content == expected_output
532536

533537
@pytest.mark.anyio
534538
async def test_tool_without_structured_output(self):

0 commit comments

Comments
 (0)