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
24 changes: 24 additions & 0 deletions examples/fastmcp/direct_call_tool_result_return.py
Original file line number Diff line number Diff line change
@@ -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"}
)
16 changes: 15 additions & 1 deletion src/mcp/server/fastmcp/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand Down
44 changes: 44 additions & 0 deletions tests/server/fastmcp/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"""

Expand Down
41 changes: 41 additions & 0 deletions tests/server/test_lowlevel_output_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
17 changes: 17 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading