Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t

## Breaking Changes

### `MCPServer.call_tool()` return annotation corrected

`MCPServer.call_tool()` no longer advertises a raw `dict[str, Any]`
return. On v2 it returns exactly the shapes produced by the MCPServer
tool conversion path: a direct `CallToolResult`, a sequence of
`ContentBlock` values for unstructured tools, or a
`(content, structured_content)` tuple for structured tools.

If you subclass `MCPServer` or annotate wrappers around `call_tool()`,
update those annotations to match the corrected return shape.

### `streamablehttp_client` removed

The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.
Expand Down
30 changes: 15 additions & 15 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@

import base64
import inspect
import json
import re
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Generic, Literal, TypeVar, overload
from typing import Any, Generic, Literal, TypeAlias, TypeVar, cast, overload

import anyio
import pydantic_core
Expand Down Expand Up @@ -76,6 +75,8 @@

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

ToolResult: TypeAlias = CallToolResult | Sequence[ContentBlock] | tuple[Sequence[ContentBlock], dict[str, Any]]


class Settings(BaseSettings, Generic[LifespanResultT]):
"""MCPServer settings.
Expand Down Expand Up @@ -317,18 +318,10 @@ async def _handle_call_tool(
if isinstance(result, CallToolResult):
return result
if isinstance(result, tuple) and len(result) == 2:
unstructured_content, structured_content = result
return CallToolResult(
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.
unstructured_content, structured_content = cast(tuple[Sequence[ContentBlock], dict[str, Any]], result)
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(result, indent=2))],
structured_content=result,
content=list(unstructured_content),
structured_content=structured_content,
)
return CallToolResult(content=list(result))

Expand Down Expand Up @@ -399,8 +392,15 @@ 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]:
"""Call a tool by name with arguments."""
) -> ToolResult:
"""Call a tool by name with arguments.

Returns:
The tool result converted for the low-level handler:
- a `CallToolResult` returned directly by the tool,
- a sequence of content blocks for unstructured tools, or
- a `(content, structured_content)` tuple for tools with structured output.
"""
if context is None:
context = Context(mcp_server=self)
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
Expand Down
25 changes: 25 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from mcp.types import (
AudioContent,
BlobResourceContents,
CallToolResult,
Completion,
CompletionArgument,
CompletionContext,
Expand Down Expand Up @@ -304,6 +305,30 @@ async def test_tool_return_value_conversion(self):
assert result.structured_content is not None
assert result.structured_content == {"result": 3}

async def test_call_tool_returns_declared_result_shapes(self):
mcp = MCPServer()

@mcp.tool()
def direct_result() -> CallToolResult:
return CallToolResult(content=[TextContent(text="direct")])

@mcp.tool(structured_output=False)
def unstructured() -> str:
return "plain"

@mcp.tool()
def structured() -> int:
return 3

direct = await mcp.call_tool("direct_result", {})
assert direct == CallToolResult(content=[TextContent(text="direct")])

bare_content = await mcp.call_tool("unstructured", {})
assert bare_content == [TextContent(text="plain")]

structured_result = await mcp.call_tool("structured", {})
assert structured_result == ([TextContent(text="3")], {"result": 3})

async def test_tool_image_helper(self, tmp_path: Path):
# Create a test image
image_path = tmp_path / "test.png"
Expand Down
Loading