Skip to content

Commit 03932bb

Browse files
committed
refactor: add ToolResult alias and call_tool regression test
Define the call_tool return union once as the ToolResult type alias and reference it from _handle_call_tool, call_tool, and the migration guide so the signature can't drift across locations. Add a regression test that drives a tool with an output schema through _handle_call_tool and asserts the resulting CallToolResult has both content and structured_content populated, pinning the reachable tuple path.
1 parent 73ec962 commit 03932bb

3 files changed

Lines changed: 62 additions & 9 deletions

File tree

docs/migration.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,11 @@ The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `Reso
430430

431431
### `MCPServer.call_tool()` return type corrected
432432

433-
`MCPServer.call_tool()`'s return type signature has been corrected from `Sequence[ContentBlock] | dict[str, Any]` to `CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]` to match what the internal tool manager actually returns when converting tool results.
433+
`MCPServer.call_tool()`'s return type signature has been corrected from `Sequence[ContentBlock] | dict[str, Any]` to match what the internal tool manager actually returns when converting tool results. The union is now defined once as the `ToolResult` type alias (`mcp.server.mcpserver.server.ToolResult`), so the signature has a single source of truth:
434+
435+
```python
436+
ToolResult: TypeAlias = CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]
437+
```
434438

435439
**Before (v1):**
436440

@@ -445,7 +449,7 @@ async def call_tool(
445449
```python
446450
async def call_tool(
447451
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
448-
) -> CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]:
452+
) -> ToolResult:
449453
```
450454

451455
### Registering lowlevel handlers on `MCPServer` (workaround)

src/mcp/server/mcpserver/server.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import re
88
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
99
from contextlib import AbstractAsyncContextManager, asynccontextmanager
10-
from typing import Any, Generic, Literal, TypeVar, overload
10+
from typing import Any, Generic, Literal, TypeAlias, TypeVar, overload
1111

1212
import anyio
1313
import pydantic_core
@@ -75,6 +75,15 @@
7575

7676
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
7777

78+
ToolResult: TypeAlias = CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]
79+
"""Result of invoking a tool via `MCPServer.call_tool`. One of:
80+
81+
- `CallToolResult`: the tool returned a `CallToolResult` directly.
82+
- `Sequence[ContentBlock]`: unstructured content from a tool with no output schema.
83+
- `tuple[Sequence[ContentBlock], dict[str, Any]]`: unstructured content paired with
84+
structured content from a tool that has an output schema.
85+
"""
86+
7887

7988
class Settings(BaseSettings, Generic[LifespanResultT]):
8089
"""MCPServer settings.
@@ -308,7 +317,7 @@ async def _handle_call_tool(
308317
) -> CallToolResult:
309318
context = Context(request_context=ctx, mcp_server=self)
310319
try:
311-
result = await self.call_tool(params.name, params.arguments or {}, context)
320+
result: ToolResult = await self.call_tool(params.name, params.arguments or {}, context)
312321
except MCPError:
313322
raise
314323
except Exception as e:
@@ -390,14 +399,14 @@ async def list_tools(self) -> list[MCPTool]:
390399

391400
async def call_tool(
392401
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
393-
) -> CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]:
402+
) -> ToolResult:
394403
"""Call a tool by name with arguments.
395404
396405
Returns:
397-
CallToolResult: If the tool returned a CallToolResult directly.
398-
Sequence[ContentBlock]: If the tool returned unstructured content and has no output schema.
399-
tuple[Sequence[ContentBlock], dict[str, Any]]: If the tool has an output schema,
400-
returning both unstructured content and structured content.
406+
ToolResult: One of a `CallToolResult` (returned directly by the tool), a
407+
`Sequence[ContentBlock]` (unstructured content from a tool with no output schema),
408+
or a `tuple[Sequence[ContentBlock], dict[str, Any]]` (unstructured content paired
409+
with structured content from a tool that has an output schema).
401410
"""
402411
if context is None:
403412
context = Context(mcp_server=self)

tests/server/mcpserver/test_server.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from mcp.types import (
2323
AudioContent,
2424
BlobResourceContents,
25+
CallToolRequestParams,
26+
CallToolResult,
2527
Completion,
2628
CompletionArgument,
2729
CompletionContext,
@@ -1516,3 +1518,41 @@ async def test_report_progress_passes_related_request_id():
15161518
message="halfway",
15171519
related_request_id="req-abc-123",
15181520
)
1521+
1522+
1523+
async def test_handle_call_tool_populates_content_and_structured_content():
1524+
"""A tool with an output schema flows through the tuple branch of `_handle_call_tool`.
1525+
1526+
The resulting `CallToolResult` must have both `content` and `structured_content`
1527+
populated. This pins the reachable tuple path: the converter never returns a raw
1528+
dict, so the `isinstance(result, dict)` branch removed in #2695 is dead. If that
1529+
dead branch is ever reintroduced and starts intercepting this path, the structured
1530+
content would be dropped and this test fails.
1531+
"""
1532+
1533+
class Point(BaseModel):
1534+
x: int
1535+
y: int
1536+
1537+
def make_point(x: int, y: int) -> Point:
1538+
return Point(x=x, y=y)
1539+
1540+
mcp = MCPServer()
1541+
mcp.add_tool(make_point)
1542+
1543+
request_context = ServerRequestContext(
1544+
session=AsyncMock(),
1545+
lifespan_context=None,
1546+
experimental=Experimental(),
1547+
)
1548+
1549+
result = await mcp._handle_call_tool(
1550+
request_context,
1551+
CallToolRequestParams(name="make_point", arguments={"x": 1, "y": 2}),
1552+
)
1553+
1554+
assert isinstance(result, CallToolResult)
1555+
assert result.is_error is False
1556+
assert result.structured_content == {"x": 1, "y": 2}
1557+
assert len(result.content) == 1
1558+
assert isinstance(result.content[0], TextContent)

0 commit comments

Comments
 (0)