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
13 changes: 3 additions & 10 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import base64
import inspect
import json
import re
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
Expand Down Expand Up @@ -76,6 +75,8 @@

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

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


class Settings(BaseSettings, Generic[LifespanResultT]):
"""MCPServer settings.
Expand Down Expand Up @@ -322,14 +323,6 @@ async def _handle_call_tool(
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.
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(result, indent=2))],
structured_content=result,
)
return CallToolResult(content=list(result))

async def _handle_list_resources(
Expand Down Expand Up @@ -399,7 +392,7 @@ 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]:
) -> ToolResult:
"""Call a tool by name with arguments."""
if context is None:
context = Context(mcp_server=self)
Expand Down
62 changes: 61 additions & 1 deletion tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import base64
from pathlib import Path
from typing import Any
from typing import Any, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand All @@ -15,12 +15,14 @@
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.prompts.base import Message, UserMessage
from mcp.server.mcpserver.resources import FileResource, FunctionResource
from mcp.server.mcpserver.server import ToolResult
from mcp.server.mcpserver.utilities.types import Audio, Image
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.types import (
AudioContent,
BlobResourceContents,
CallToolResult,
Completion,
CompletionArgument,
CompletionContext,
Expand Down Expand Up @@ -304,6 +306,64 @@ async def test_tool_return_value_conversion(self):
assert result.structured_content is not None
assert result.structured_content == {"result": 3}

def test_call_tool_return_annotation_matches_reachable_shapes(self):
hints = get_type_hints(MCPServer.call_tool)
assert hints["return"] == ToolResult

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

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

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

async with Client(mcp) as client:
handled = await client.call_tool("direct_result", {})
assert handled.content == result.content
assert handled.structured_content is None

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

def raw_blocks() -> list[ContentBlock]:
return [TextContent(type="text", text="raw")]

mcp.add_tool(raw_blocks, structured_output=False)

result = await mcp.call_tool("raw_blocks", {})
assert isinstance(result, list)
assert all(isinstance(item, ContentBlock) for item in result)

async with Client(mcp) as client:
handled = await client.call_tool("raw_blocks", {})
assert handled.content == result
assert handled.structured_content is None

async def test_call_tool_wraps_structured_tuple_result(self):
class UserOutput(BaseModel):
name: str
age: int

def get_user() -> UserOutput:
return UserOutput(name="John Doe", age=30)

mcp = MCPServer()
mcp.add_tool(get_user)

result = await mcp.call_tool("get_user", {})
assert isinstance(result, tuple)
unstructured_content, structured_content = result
assert structured_content == {"name": "John Doe", "age": 30}

async with Client(mcp) as client:
handled = await client.call_tool("get_user", {})
assert handled.content == list(unstructured_content)
assert handled.structured_content == structured_content

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