diff --git a/examples/fastmcp/direct_call_tool_result_return.py b/examples/fastmcp/direct_call_tool_result_return.py new file mode 100644 index 000000000..a441769b2 --- /dev/null +++ b/examples/fastmcp/direct_call_tool_result_return.py @@ -0,0 +1,24 @@ +""" +FastMCP Echo Server with direct CallToolResult return +""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("Echo Server") + + +class EchoResponse(BaseModel): + text: str + + +@mcp.tool() +def echo(text: str) -> Annotated[CallToolResult, EchoResponse]: + """Echo the input text with structure and metadata""" + return CallToolResult( + content=[TextContent(type="text", text=text)], structuredContent={"text": text}, _meta={"some": "metadata"} + ) diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index 3289a5aa6..7b245fa97 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -22,7 +22,7 @@ from mcp.server.fastmcp.exceptions import InvalidSignature from mcp.server.fastmcp.utilities.logging import get_logger from mcp.server.fastmcp.utilities.types import Audio, Image -from mcp.types import ContentBlock, TextContent +from mcp.types import CallToolResult, ContentBlock, TextContent logger = get_logger(__name__) @@ -104,6 +104,12 @@ def convert_result(self, result: Any) -> Any: from function return values, whereas the lowlevel server simply serializes the structured output. """ + if isinstance(result, CallToolResult): + if self.output_schema is not None: + assert self.output_model is not None, "Output model must be set if output schema is defined" + self.output_model.model_validate(result.structuredContent) + return result + unstructured_content = _convert_to_content(result) if self.output_schema is None: @@ -268,6 +274,14 @@ def func_metadata( output_info = FieldInfo.from_annotation(_get_typed_annotation(sig.return_annotation, globalns)) annotation = output_info.annotation + # if the typehint is CallToolResult, the user either intends to return without validation + # or they provided validation as Annotated metadata + if isinstance(annotation, type) and issubclass(annotation, CallToolResult): + if output_info.metadata: + annotation = output_info.metadata[0] + else: + return FuncMetadata(arg_model=arguments_model) + output_model, output_schema, wrap_output = _try_create_model_and_schema(annotation, func.__name__, output_info) if output_model is None and structured_output is True: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 2fec3381b..9a4ae9c89 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -480,7 +480,7 @@ def call_tool(self, *, validate_input: bool = True): def decorator( func: Callable[ ..., - Awaitable[UnstructuredContent | StructuredContent | CombinationContent], + Awaitable[UnstructuredContent | StructuredContent | CombinationContent | types.CallToolResult], ], ): logger.debug("Registering handler for CallToolRequest") @@ -504,7 +504,9 @@ async def handler(req: types.CallToolRequest): # output normalization unstructured_content: UnstructuredContent maybe_structured_content: StructuredContent | None - if isinstance(results, tuple) and len(results) == 2: + if isinstance(results, types.CallToolResult): + return types.ServerResult(results) + elif isinstance(results, tuple) and len(results) == 2: # tool returned both structured and unstructured content unstructured_content, maybe_structured_content = cast(CombinationContent, results) elif isinstance(results, dict): diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 830cf816b..b2c9dbce0 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, Field from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.types import CallToolResult class SomeInputModelA(BaseModel): @@ -834,6 +835,49 @@ def func_returning_unannotated() -> UnannotatedClass: assert meta.output_schema is None +def test_tool_call_result_is_unstructured_and_not_converted(): + def func_returning_call_tool_result() -> CallToolResult: + return CallToolResult(content=[]) + + meta = func_metadata(func_returning_call_tool_result) + + assert meta.output_schema is None + assert isinstance(meta.convert_result(func_returning_call_tool_result()), CallToolResult) + + +def test_tool_call_result_annotated_is_structured_and_converted(): + class PersonClass(BaseModel): + name: str + + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: + return CallToolResult(content=[], structuredContent={"name": "Brandon"}) + + meta = func_metadata(func_returning_annotated_tool_call_result) + + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name"], + "title": "PersonClass", + } + assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult) + + +def test_tool_call_result_annotated_is_structured_and_invalid(): + class PersonClass(BaseModel): + name: str + + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: + return CallToolResult(content=[], structuredContent={"person": "Brandon"}) + + meta = func_metadata(func_returning_annotated_tool_call_result) + + with pytest.raises(ValueError): + meta.convert_result(func_returning_annotated_tool_call_result()) + + def test_structured_output_with_field_descriptions(): """Test that Field descriptions are preserved in structured output""" diff --git a/tests/server/test_lowlevel_output_validation.py b/tests/server/test_lowlevel_output_validation.py index 7bcdf59d3..04e8a93a9 100644 --- a/tests/server/test_lowlevel_output_validation.py +++ b/tests/server/test_lowlevel_output_validation.py @@ -391,6 +391,47 @@ async def test_callback(client_session: ClientSession) -> CallToolResult: assert result.structuredContent == {"sentiment": "positive", "confidence": 0.95} +@pytest.mark.anyio +async def test_tool_call_result(): + """Test returning ToolCallResult when no outputSchema is defined.""" + tools = [ + Tool( + name="get_info", + description="Get structured information", + inputSchema={ + "type": "object", + "properties": {}, + }, + # No outputSchema for direct return of tool call result + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult: + if name == "get_info": + return CallToolResult( + content=[TextContent(type="text", text="Results calculated")], + structuredContent={"status": "ok", "data": {"value": 42}}, + _meta={"some": "metadata"}, + ) + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("get_info", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Results calculated" + assert isinstance(result.content[0], TextContent) + assert result.structuredContent == {"status": "ok", "data": {"value": 42}} + assert result.meta == {"some": "metadata"} + + @pytest.mark.anyio async def test_output_schema_type_validation(): """Test outputSchema validates types correctly.""" diff --git a/tests/test_examples.py b/tests/test_examples.py index 59063f122..78f6d3402 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -44,6 +44,23 @@ async def test_complex_inputs(): assert result.content[2].text == "charlie" +@pytest.mark.anyio +async def test_direct_call_tool_result_return(): + """Test the CallToolResult echo server""" + from examples.fastmcp.direct_call_tool_result_return import mcp + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("echo", {"text": "hello"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "hello" + assert result.structuredContent + assert result.structuredContent["text"] == "hello" + assert isinstance(result.meta, dict) + assert result.meta["some"] == "metadata" + + @pytest.mark.anyio async def test_desktop(monkeypatch: pytest.MonkeyPatch): """Test the desktop server"""