Skip to content

Commit c23cbf8

Browse files
committed
Simplify with early return because output schemas won't work with a CallToolResult return type
1 parent 14a6eaa commit c23cbf8

File tree

6 files changed

+69
-113
lines changed

6 files changed

+69
-113
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
FastMCP Echo Server with direct CallToolResult return
3+
"""
4+
5+
from mcp.server.fastmcp import FastMCP
6+
from mcp.types import CallToolResult, TextContent
7+
8+
mcp = FastMCP("Echo Server")
9+
10+
11+
@mcp.tool()
12+
def echo(text: str) -> CallToolResult:
13+
"""Echo the input text with structure and metadata"""
14+
return CallToolResult(
15+
content=[TextContent(type="text", text=text)], structuredContent={"text": text}, _meta={"some": "metadata"}
16+
)

src/mcp/server/fastmcp/utilities/func_metadata.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from mcp.server.fastmcp.exceptions import InvalidSignature
2323
from mcp.server.fastmcp.utilities.logging import get_logger
2424
from mcp.server.fastmcp.utilities.types import Audio, Image
25-
from mcp.types import ContentBlock, TextContent
25+
from mcp.types import CallToolResult, ContentBlock, TextContent
2626

2727
logger = get_logger(__name__)
2828

@@ -104,6 +104,9 @@ def convert_result(self, result: Any) -> Any:
104104
from function return values, whereas the lowlevel server simply serializes
105105
the structured output.
106106
"""
107+
if isinstance(result, CallToolResult):
108+
return result
109+
107110
unstructured_content = _convert_to_content(result)
108111

109112
if self.output_schema is None:
@@ -268,6 +271,10 @@ def func_metadata(
268271
output_info = FieldInfo.from_annotation(_get_typed_annotation(sig.return_annotation, globalns))
269272
annotation = output_info.annotation
270273

274+
# if the typehint is CallToolResult, the user intends to return it directly
275+
if isinstance(annotation, type) and issubclass(annotation, CallToolResult):
276+
return FuncMetadata(arg_model=arguments_model)
277+
271278
output_model, output_schema, wrap_output = _try_create_model_and_schema(annotation, func.__name__, output_info)
272279

273280
if output_model is None and structured_output is True:

src/mcp/server/lowlevel/server.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -502,46 +502,45 @@ async def handler(req: types.CallToolRequest):
502502
results = await func(tool_name, arguments)
503503

504504
# output normalization
505-
casted_results: types.CallToolResult
506505
unstructured_content: UnstructuredContent
507506
maybe_structured_content: StructuredContent | None
508507
if isinstance(results, types.CallToolResult):
509-
casted_results = results
508+
# tool returned a CallToolResult so we'll skip further validation and return it directly
509+
return types.ServerResult(results)
510510
elif isinstance(results, tuple) and len(results) == 2:
511511
# tool returned both structured and unstructured content
512512
unstructured_content, maybe_structured_content = cast(CombinationContent, results)
513-
casted_results = types.CallToolResult(
514-
content=list(unstructured_content),
515-
structuredContent=maybe_structured_content,
516-
)
517513
elif isinstance(results, dict):
518514
# tool returned structured content only
519-
casted_results = types.CallToolResult(
520-
content=[types.TextContent(type="text", text=json.dumps(results, indent=2))],
521-
structuredContent=cast(StructuredContent, results),
522-
)
515+
maybe_structured_content = cast(StructuredContent, results)
516+
unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))]
523517
elif hasattr(results, "__iter__"):
524518
# tool returned unstructured content only
525-
casted_results = types.CallToolResult(
526-
content=list(cast(UnstructuredContent, results)),
527-
)
519+
unstructured_content = cast(UnstructuredContent, results)
520+
maybe_structured_content = None
528521
else:
529522
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")
530523

531524
# output validation
532525
if tool and tool.outputSchema is not None:
533-
if casted_results.structuredContent is None:
526+
if maybe_structured_content is None:
534527
return self._make_error_result(
535528
"Output validation error: outputSchema defined but no structured output returned"
536529
)
537530
else:
538531
try:
539-
jsonschema.validate(instance=casted_results.structuredContent, schema=tool.outputSchema)
532+
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
540533
except jsonschema.ValidationError as e:
541534
return self._make_error_result(f"Output validation error: {e.message}")
542535

543536
# result
544-
return types.ServerResult(casted_results)
537+
return types.ServerResult(
538+
types.CallToolResult(
539+
content=list(unstructured_content),
540+
structuredContent=maybe_structured_content,
541+
isError=False,
542+
)
543+
)
545544
except Exception as e:
546545
return self._make_error_result(str(e))
547546

tests/server/fastmcp/test_func_metadata.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pydantic import BaseModel, Field
1414

1515
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
16+
from mcp.types import CallToolResult
1617

1718

1819
class SomeInputModelA(BaseModel):
@@ -834,6 +835,16 @@ def func_returning_unannotated() -> UnannotatedClass:
834835
assert meta.output_schema is None
835836

836837

838+
def test_tool_call_result_is_unstructured_and_not_converted():
839+
def func_returning_call_tool_result() -> CallToolResult:
840+
return CallToolResult(content=[])
841+
842+
meta = func_metadata(func_returning_call_tool_result)
843+
844+
assert meta.output_schema is None
845+
assert isinstance(meta.convert_result(func_returning_call_tool_result()), CallToolResult)
846+
847+
837848
def test_structured_output_with_field_descriptions():
838849
"""Test that Field descriptions are preserved in structured output"""
839850

tests/server/test_lowlevel_output_validation.py

Lines changed: 2 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
392392

393393

394394
@pytest.mark.anyio
395-
async def test_tool_call_result_without_output_schema():
395+
async def test_tool_call_result():
396396
"""Test returning ToolCallResult when no outputSchema is defined."""
397397
tools = [
398398
Tool(
@@ -402,7 +402,7 @@ async def test_tool_call_result_without_output_schema():
402402
"type": "object",
403403
"properties": {},
404404
},
405-
# No outputSchema defined
405+
# No outputSchema for direct return of tool call result
406406
)
407407
]
408408

@@ -432,100 +432,6 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
432432
assert result.meta == {"some": "metadata"}
433433

434434

435-
@pytest.mark.anyio
436-
async def test_valid_tool_call_result_with_output_schema():
437-
"""Test returning ToolCallResult when no outputSchema is defined."""
438-
tools = [
439-
Tool(
440-
name="get_info",
441-
description="Get structured information",
442-
inputSchema={
443-
"type": "object",
444-
"properties": {},
445-
},
446-
outputSchema={
447-
"type": "object",
448-
"properties": {
449-
"name": {"type": "string"},
450-
"number": {"type": "integer"},
451-
},
452-
"required": ["name", "number"],
453-
},
454-
)
455-
]
456-
457-
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
458-
if name == "get_info":
459-
return CallToolResult(
460-
content=[TextContent(type="text", text="Results calculated")],
461-
structuredContent={"name": "Brandon", "number": 29},
462-
_meta={"some": "metadata"},
463-
)
464-
else:
465-
raise ValueError(f"Unknown tool: {name}")
466-
467-
async def test_callback(client_session: ClientSession) -> CallToolResult:
468-
return await client_session.call_tool("get_info", {})
469-
470-
result = await run_tool_test(tools, call_tool_handler, test_callback)
471-
472-
# Verify results
473-
assert result is not None
474-
assert not result.isError
475-
assert len(result.content) == 1
476-
assert result.content[0].type == "text"
477-
assert result.content[0].text == "Results calculated"
478-
assert isinstance(result.content[0], TextContent)
479-
assert result.structuredContent == {"name": "Brandon", "number": 29}
480-
assert result.meta == {"some": "metadata"}
481-
482-
483-
@pytest.mark.anyio
484-
async def test_invalid_tool_call_result_with_output_schema():
485-
"""Test returning ToolCallResult when no outputSchema is defined."""
486-
tools = [
487-
Tool(
488-
name="get_info",
489-
description="Get structured information",
490-
inputSchema={
491-
"type": "object",
492-
"properties": {},
493-
},
494-
outputSchema={
495-
"type": "object",
496-
"properties": {
497-
"name": {"type": "string"},
498-
"number": {"type": "integer"},
499-
},
500-
"required": ["name", "number"],
501-
},
502-
)
503-
]
504-
505-
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
506-
if name == "get_info":
507-
return CallToolResult(
508-
content=[TextContent(type="text", text="Results calculated")],
509-
structuredContent={"name": "Brandon"},
510-
_meta={"some": "metadata"},
511-
)
512-
else:
513-
raise ValueError(f"Unknown tool: {name}")
514-
515-
async def test_callback(client_session: ClientSession) -> CallToolResult:
516-
return await client_session.call_tool("get_info", {})
517-
518-
result = await run_tool_test(tools, call_tool_handler, test_callback)
519-
520-
assert result is not None
521-
assert result.isError
522-
assert len(result.content) == 1
523-
assert result.content[0].type == "text"
524-
assert isinstance(result.content[0], TextContent)
525-
assert "Output validation error:" in result.content[0].text
526-
assert "'number' is a required property" in result.content[0].text
527-
528-
529435
@pytest.mark.anyio
530436
async def test_output_schema_type_validation():
531437
"""Test outputSchema validates types correctly."""

tests/test_examples.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ async def test_complex_inputs():
4444
assert result.content[2].text == "charlie"
4545

4646

47+
@pytest.mark.anyio
48+
async def test_direct_call_tool_result_return():
49+
"""Test the CallToolResult echo server"""
50+
from examples.fastmcp.direct_call_tool_result_return import mcp
51+
52+
async with client_session(mcp._mcp_server) as client:
53+
result = await client.call_tool("echo", {"text": "hello"})
54+
assert len(result.content) == 1
55+
content = result.content[0]
56+
assert isinstance(content, TextContent)
57+
assert content.text == "hello"
58+
assert result.structuredContent
59+
assert result.structuredContent["text"] == "hello"
60+
assert isinstance(result.meta, dict)
61+
assert result.meta["some"] == "metadata"
62+
63+
4764
@pytest.mark.anyio
4865
async def test_desktop(monkeypatch: pytest.MonkeyPatch):
4966
"""Test the desktop server"""

0 commit comments

Comments
 (0)