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
14 changes: 12 additions & 2 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,18 @@ 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."""
) -> Sequence[ContentBlock] | CallToolResult | tuple[Sequence[ContentBlock], dict[str, Any]]:
"""Call a tool by name with arguments.

Because ``convert_result=True`` is always passed to the tool manager, the
return value comes from ``FuncMetadata.convert_result``:

- ``Sequence[ContentBlock]`` when the tool has no output schema and
returned a regular value
- ``CallToolResult`` when the tool returned a ``CallToolResult`` directly
- ``tuple[Sequence[ContentBlock], dict[str, Any]]`` when the tool has
an output schema, where the second element is the structured content
"""
if context is None:
context = Context(mcp_server=self)
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
Expand Down
13 changes: 9 additions & 4 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,17 @@ async def call_fn_with_arg_validation(
else:
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))

def convert_result(self, result: Any) -> Any:
def convert_result(
self, result: Any
) -> Sequence[ContentBlock] | CallToolResult | tuple[Sequence[ContentBlock], dict[str, Any]]:
"""Convert a function call result to the format for the lowlevel tool call handler.

- If output_model is None, return the unstructured content directly.
- If output_model is not None, convert the result to structured output format
(dict[str, Any]) and return both unstructured and structured content.
- If the function returned a ``CallToolResult`` directly, return it unchanged.
- If ``output_model`` is None, return the unstructured content
(``Sequence[ContentBlock]``) directly.
- If ``output_model`` is not None, convert the result to structured output
format (``dict[str, Any]``) and return both unstructured and structured
content as a tuple.

Note: we return unstructured content here **even though the lowlevel server
tool call handler provides generic backwards compatibility serialization of
Expand Down
18 changes: 15 additions & 3 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1036,9 +1036,18 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
assert "field_first" not in meta.output_schema["properties"]
assert "field_second" not in meta.output_schema["properties"]

# Check that the actual output uses aliases too
# Check that the actual output uses aliases too. ``convert_result``
# returns either a sequence, a CallToolResult, or a 2-tuple of
# (unstructured, structured) — for a model with an output schema the
# 2-tuple branch fires, so cast accordingly to keep pyright happy
# without runtime narrowing surprises.
from typing import cast

result = ModelWithAliases(**{"first": "hello", "second": "world"})
_, structured_content = meta.convert_result(result)
structured_content = cast(
"tuple[Any, dict[str, Any]]",
meta.convert_result(result),
)[1]

# The structured content should use aliases to match the schema
assert "first" in structured_content
Expand All @@ -1050,7 +1059,10 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover

# Also test the case where we have a model with defaults to ensure aliases work in all cases
result_with_defaults = ModelWithAliases() # Uses default None values
_, structured_content_defaults = meta.convert_result(result_with_defaults)
structured_content_defaults = cast(
"tuple[Any, dict[str, Any]]",
meta.convert_result(result_with_defaults),
)[1]

# Even with defaults, should use aliases in output
assert "first" in structured_content_defaults
Expand Down
Loading