Skip to content

Commit d8b704b

Browse files
committed
feat: reject CallToolResult in Union/Optional types
Add validation to prevent CallToolResult from being used with Union or Optional types, which creates ambiguous behavior and validation issues.
1 parent 42562fc commit d8b704b

File tree

5 files changed

+298
-2
lines changed

5 files changed

+298
-2
lines changed

README.md

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,61 @@ causes the tool to be classified as structured _and this is undesirable_,
383383
the classification can be suppressed by passing `structured_output=False`
384384
to the `@tool` decorator.
385385

386+
##### Advanced: Direct CallToolResult
387+
388+
For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly:
389+
390+
<!-- snippet-source examples/snippets/servers/direct_call_tool_result.py -->
391+
```python
392+
"""Example showing direct CallToolResult return for advanced control."""
393+
394+
from typing import Annotated
395+
396+
from pydantic import BaseModel
397+
398+
from mcp.server.fastmcp import FastMCP
399+
from mcp.types import CallToolResult, TextContent
400+
401+
mcp = FastMCP("CallToolResult Example")
402+
403+
404+
class ValidationModel(BaseModel):
405+
"""Model for validating structured output."""
406+
407+
status: str
408+
data: dict[str, int]
409+
410+
411+
@mcp.tool()
412+
def advanced_tool() -> CallToolResult:
413+
"""Return CallToolResult directly for full control including _meta field."""
414+
return CallToolResult(
415+
content=[TextContent(type="text", text="Response visible to the model")],
416+
_meta={"hidden": "data for client applications only"},
417+
)
418+
419+
420+
@mcp.tool()
421+
def validated_tool() -> Annotated[CallToolResult, ValidationModel]:
422+
"""Return CallToolResult with structured output validation."""
423+
return CallToolResult(
424+
content=[TextContent(type="text", text="Validated response")],
425+
structuredContent={"status": "success", "data": {"result": 42}},
426+
_meta={"internal": "metadata"},
427+
)
428+
429+
430+
@mcp.tool()
431+
def empty_result_tool() -> CallToolResult:
432+
"""For empty results, return CallToolResult with empty content."""
433+
return CallToolResult(content=[])
434+
```
435+
436+
_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_
437+
<!-- /snippet-source -->
438+
439+
**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`.
440+
386441
<!-- snippet-source examples/snippets/servers/structured_output.py -->
387442
```python
388443
"""Example showing structured output with tools."""
@@ -1769,14 +1824,92 @@ if __name__ == "__main__":
17691824
_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_
17701825
<!-- /snippet-source -->
17711826

1772-
Tools can return data in three ways:
1827+
Tools can return data in four ways:
17731828

17741829
1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18)
17751830
2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18)
17761831
3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility
1832+
4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field)
17771833

17781834
When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early.
17791835

1836+
##### Returning CallToolResult Directly
1837+
1838+
For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly:
1839+
1840+
<!-- snippet-source examples/snippets/servers/lowlevel/direct_call_tool_result.py -->
1841+
```python
1842+
"""
1843+
Run from the repository root:
1844+
uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py
1845+
"""
1846+
1847+
import asyncio
1848+
1849+
import mcp.server.stdio
1850+
import mcp.types as types
1851+
from mcp.server.lowlevel import NotificationOptions, Server
1852+
from mcp.server.models import InitializationOptions
1853+
1854+
server = Server("example-server")
1855+
1856+
1857+
@server.list_tools()
1858+
async def list_tools() -> list[types.Tool]:
1859+
"""List available tools."""
1860+
return [
1861+
types.Tool(
1862+
name="advanced_tool",
1863+
description="Tool with full control including _meta field",
1864+
inputSchema={
1865+
"type": "object",
1866+
"properties": {"message": {"type": "string"}},
1867+
"required": ["message"],
1868+
},
1869+
)
1870+
]
1871+
1872+
1873+
@server.call_tool()
1874+
async def handle_call_tool(name: str, arguments: dict[str, object]) -> types.CallToolResult:
1875+
"""Handle tool calls by returning CallToolResult directly."""
1876+
if name == "advanced_tool":
1877+
message = str(arguments.get("message", ""))
1878+
return types.CallToolResult(
1879+
content=[types.TextContent(type="text", text=f"Processed: {message}")],
1880+
structuredContent={"result": "success", "message": message},
1881+
_meta={"hidden": "data for client applications only"},
1882+
)
1883+
1884+
raise ValueError(f"Unknown tool: {name}")
1885+
1886+
1887+
async def run():
1888+
"""Run the server."""
1889+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
1890+
await server.run(
1891+
read_stream,
1892+
write_stream,
1893+
InitializationOptions(
1894+
server_name="example",
1895+
server_version="0.1.0",
1896+
capabilities=server.get_capabilities(
1897+
notification_options=NotificationOptions(),
1898+
experimental_capabilities={},
1899+
),
1900+
),
1901+
)
1902+
1903+
1904+
if __name__ == "__main__":
1905+
asyncio.run(run())
1906+
```
1907+
1908+
_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_
1909+
<!-- /snippet-source -->
1910+
1911+
**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself.
1912+
17801913
### Pagination (Advanced)
17811914

17821915
For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Example showing direct CallToolResult return for advanced control."""
2+
3+
from typing import Annotated
4+
5+
from pydantic import BaseModel
6+
7+
from mcp.server.fastmcp import FastMCP
8+
from mcp.types import CallToolResult, TextContent
9+
10+
mcp = FastMCP("CallToolResult Example")
11+
12+
13+
class ValidationModel(BaseModel):
14+
"""Model for validating structured output."""
15+
16+
status: str
17+
data: dict[str, int]
18+
19+
20+
@mcp.tool()
21+
def advanced_tool() -> CallToolResult:
22+
"""Return CallToolResult directly for full control including _meta field."""
23+
return CallToolResult(
24+
content=[TextContent(type="text", text="Response visible to the model")],
25+
_meta={"hidden": "data for client applications only"},
26+
)
27+
28+
29+
@mcp.tool()
30+
def validated_tool() -> Annotated[CallToolResult, ValidationModel]:
31+
"""Return CallToolResult with structured output validation."""
32+
return CallToolResult(
33+
content=[TextContent(type="text", text="Validated response")],
34+
structuredContent={"status": "success", "data": {"result": 42}},
35+
_meta={"internal": "metadata"},
36+
)
37+
38+
39+
@mcp.tool()
40+
def empty_result_tool() -> CallToolResult:
41+
"""For empty results, return CallToolResult with empty content."""
42+
return CallToolResult(content=[])
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Run from the repository root:
3+
uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py
4+
"""
5+
6+
import asyncio
7+
8+
import mcp.server.stdio
9+
import mcp.types as types
10+
from mcp.server.lowlevel import NotificationOptions, Server
11+
from mcp.server.models import InitializationOptions
12+
13+
server = Server("example-server")
14+
15+
16+
@server.list_tools()
17+
async def list_tools() -> list[types.Tool]:
18+
"""List available tools."""
19+
return [
20+
types.Tool(
21+
name="advanced_tool",
22+
description="Tool with full control including _meta field",
23+
inputSchema={
24+
"type": "object",
25+
"properties": {"message": {"type": "string"}},
26+
"required": ["message"],
27+
},
28+
)
29+
]
30+
31+
32+
@server.call_tool()
33+
async def handle_call_tool(name: str, arguments: dict[str, object]) -> types.CallToolResult:
34+
"""Handle tool calls by returning CallToolResult directly."""
35+
if name == "advanced_tool":
36+
message = str(arguments.get("message", ""))
37+
return types.CallToolResult(
38+
content=[types.TextContent(type="text", text=f"Processed: {message}")],
39+
structuredContent={"result": "success", "message": message},
40+
_meta={"hidden": "data for client applications only"},
41+
)
42+
43+
raise ValueError(f"Unknown tool: {name}")
44+
45+
46+
async def run():
47+
"""Run the server."""
48+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
49+
await server.run(
50+
read_stream,
51+
write_stream,
52+
InitializationOptions(
53+
server_name="example",
54+
server_version="0.1.0",
55+
capabilities=server.get_capabilities(
56+
notification_options=NotificationOptions(),
57+
experimental_capabilities={},
58+
),
59+
),
60+
)
61+
62+
63+
if __name__ == "__main__":
64+
asyncio.run(run())

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import inspect
22
import json
3+
import types
34
from collections.abc import Awaitable, Callable, Sequence
45
from itertools import chain
56
from types import GenericAlias
6-
from typing import Annotated, Any, ForwardRef, cast, get_args, get_origin, get_type_hints
7+
from typing import Annotated, Any, ForwardRef, Union, cast, get_args, get_origin, get_type_hints
78

89
import pydantic_core
910
from pydantic import (
@@ -274,6 +275,18 @@ def func_metadata(
274275
output_info = FieldInfo.from_annotation(_get_typed_annotation(sig.return_annotation, globalns))
275276
annotation = output_info.annotation
276277

278+
# Reject CallToolResult in Union types (including Optional)
279+
# Handle both typing.Union (Union[X, Y]) and types.UnionType (X | Y)
280+
origin = get_origin(annotation)
281+
if origin is Union or origin is types.UnionType:
282+
args = get_args(annotation)
283+
# Check if CallToolResult appears in the union (excluding None for Optional check)
284+
if any(isinstance(arg, type) and issubclass(arg, CallToolResult) for arg in args if arg is not type(None)):
285+
raise InvalidSignature(
286+
f"Function {func.__name__}: CallToolResult cannot be used in Union or Optional types. "
287+
"To return empty results, use: CallToolResult(content=[])"
288+
)
289+
277290
# if the typehint is CallToolResult, the user either intends to return without validation
278291
# or they provided validation as Annotated metadata
279292
if isinstance(annotation, type) and issubclass(annotation, CallToolResult):

tests/server/fastmcp/test_func_metadata.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,50 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per
878878
meta.convert_result(func_returning_annotated_tool_call_result())
879879

880880

881+
def test_tool_call_result_in_optional_is_rejected():
882+
"""Test that Optional[CallToolResult] raises InvalidSignature"""
883+
884+
from mcp.server.fastmcp.exceptions import InvalidSignature
885+
886+
def func_optional_call_tool_result() -> CallToolResult | None:
887+
return CallToolResult(content=[])
888+
889+
with pytest.raises(InvalidSignature) as exc_info:
890+
func_metadata(func_optional_call_tool_result)
891+
892+
assert "Union or Optional" in str(exc_info.value)
893+
assert "CallToolResult" in str(exc_info.value)
894+
895+
896+
def test_tool_call_result_in_union_is_rejected():
897+
"""Test that Union[str, CallToolResult] raises InvalidSignature"""
898+
899+
from mcp.server.fastmcp.exceptions import InvalidSignature
900+
901+
def func_union_call_tool_result() -> str | CallToolResult:
902+
return CallToolResult(content=[])
903+
904+
with pytest.raises(InvalidSignature) as exc_info:
905+
func_metadata(func_union_call_tool_result)
906+
907+
assert "Union or Optional" in str(exc_info.value)
908+
assert "CallToolResult" in str(exc_info.value)
909+
910+
911+
def test_tool_call_result_in_pipe_union_is_rejected():
912+
"""Test that str | CallToolResult raises InvalidSignature"""
913+
from mcp.server.fastmcp.exceptions import InvalidSignature
914+
915+
def func_pipe_union_call_tool_result() -> str | CallToolResult:
916+
return CallToolResult(content=[])
917+
918+
with pytest.raises(InvalidSignature) as exc_info:
919+
func_metadata(func_pipe_union_call_tool_result)
920+
921+
assert "Union or Optional" in str(exc_info.value)
922+
assert "CallToolResult" in str(exc_info.value)
923+
924+
881925
def test_structured_output_with_field_descriptions():
882926
"""Test that Field descriptions are preserved in structured output"""
883927

0 commit comments

Comments
 (0)