Skip to content

Commit 86410cd

Browse files
authored
Merge branch 'main' into upgrade-github-actions
2 parents 3a92e7b + 35a9ccd commit 86410cd

File tree

11 files changed

+449
-9
lines changed

11 files changed

+449
-9
lines changed

.github/workflows/shared.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- uses: astral-sh/setup-uv@v7
1919
with:
2020
enable-cache: true
21-
version: 0.9.2
21+
version: 0.9.5
2222

2323
- name: Install dependencies
2424
run: uv sync --frozen --all-extras --python 3.10
@@ -46,7 +46,7 @@ jobs:
4646
uses: astral-sh/setup-uv@v7
4747
with:
4848
enable-cache: true
49-
version: 0.9.2
49+
version: 0.9.5
5050

5151
- name: Install the project
5252
run: uv sync --frozen --all-extras --python ${{ matrix.python-version }} --resolution ${{ matrix.dep-resolution }}
@@ -62,7 +62,7 @@ jobs:
6262
- uses: astral-sh/setup-uv@v7
6363
with:
6464
enable-cache: true
65-
version: 0.9.2
65+
version: 0.9.5
6666

6767
- name: Install dependencies
6868
run: uv sync --frozen --all-extras --python 3.10

README.md

Lines changed: 135 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,93 @@ 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+
from typing import Any
1849+
1850+
import mcp.server.stdio
1851+
import mcp.types as types
1852+
from mcp.server.lowlevel import NotificationOptions, Server
1853+
from mcp.server.models import InitializationOptions
1854+
1855+
server = Server("example-server")
1856+
1857+
1858+
@server.list_tools()
1859+
async def list_tools() -> list[types.Tool]:
1860+
"""List available tools."""
1861+
return [
1862+
types.Tool(
1863+
name="advanced_tool",
1864+
description="Tool with full control including _meta field",
1865+
inputSchema={
1866+
"type": "object",
1867+
"properties": {"message": {"type": "string"}},
1868+
"required": ["message"],
1869+
},
1870+
)
1871+
]
1872+
1873+
1874+
@server.call_tool()
1875+
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
1876+
"""Handle tool calls by returning CallToolResult directly."""
1877+
if name == "advanced_tool":
1878+
message = str(arguments.get("message", ""))
1879+
return types.CallToolResult(
1880+
content=[types.TextContent(type="text", text=f"Processed: {message}")],
1881+
structuredContent={"result": "success", "message": message},
1882+
_meta={"hidden": "data for client applications only"},
1883+
)
1884+
1885+
raise ValueError(f"Unknown tool: {name}")
1886+
1887+
1888+
async def run():
1889+
"""Run the server."""
1890+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
1891+
await server.run(
1892+
read_stream,
1893+
write_stream,
1894+
InitializationOptions(
1895+
server_name="example",
1896+
server_version="0.1.0",
1897+
capabilities=server.get_capabilities(
1898+
notification_options=NotificationOptions(),
1899+
experimental_capabilities={},
1900+
),
1901+
),
1902+
)
1903+
1904+
1905+
if __name__ == "__main__":
1906+
asyncio.run(run())
1907+
```
1908+
1909+
_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)_
1910+
<!-- /snippet-source -->
1911+
1912+
**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself.
1913+
17801914
### Pagination (Advanced)
17811915

17821916
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
FastMCP Echo Server with direct CallToolResult return
3+
"""
4+
5+
from typing import Annotated
6+
7+
from pydantic import BaseModel
8+
9+
from mcp.server.fastmcp import FastMCP
10+
from mcp.types import CallToolResult, TextContent
11+
12+
mcp = FastMCP("Echo Server")
13+
14+
15+
class EchoResponse(BaseModel):
16+
text: str
17+
18+
19+
@mcp.tool()
20+
def echo(text: str) -> Annotated[CallToolResult, EchoResponse]:
21+
"""Echo the input text with structure and metadata"""
22+
return CallToolResult(
23+
content=[TextContent(type="text", text=text)], structuredContent={"text": text}, _meta={"some": "metadata"}
24+
)
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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
from typing import Any
8+
9+
import mcp.server.stdio
10+
import mcp.types as types
11+
from mcp.server.lowlevel import NotificationOptions, Server
12+
from mcp.server.models import InitializationOptions
13+
14+
server = Server("example-server")
15+
16+
17+
@server.list_tools()
18+
async def list_tools() -> list[types.Tool]:
19+
"""List available tools."""
20+
return [
21+
types.Tool(
22+
name="advanced_tool",
23+
description="Tool with full control including _meta field",
24+
inputSchema={
25+
"type": "object",
26+
"properties": {"message": {"type": "string"}},
27+
"required": ["message"],
28+
},
29+
)
30+
]
31+
32+
33+
@server.call_tool()
34+
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
35+
"""Handle tool calls by returning CallToolResult directly."""
36+
if name == "advanced_tool":
37+
message = str(arguments.get("message", ""))
38+
return types.CallToolResult(
39+
content=[types.TextContent(type="text", text=f"Processed: {message}")],
40+
structuredContent={"result": "success", "message": message},
41+
_meta={"hidden": "data for client applications only"},
42+
)
43+
44+
raise ValueError(f"Unknown tool: {name}")
45+
46+
47+
async def run():
48+
"""Run the server."""
49+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
50+
await server.run(
51+
read_stream,
52+
write_stream,
53+
InitializationOptions(
54+
server_name="example",
55+
server_version="0.1.0",
56+
capabilities=server.get_capabilities(
57+
notification_options=NotificationOptions(),
58+
experimental_capabilities={},
59+
),
60+
),
61+
)
62+
63+
64+
if __name__ == "__main__":
65+
asyncio.run(run())

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ mcp = "mcp.cli:app [cli]"
4545

4646
[tool.uv]
4747
default-groups = ["dev", "docs"]
48-
required-version = ">=0.9.2"
48+
required-version = ">=0.9.5"
4949

5050
[dependency-groups]
5151
dev = [

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

Lines changed: 29 additions & 2 deletions
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 (
@@ -22,7 +23,7 @@
2223
from mcp.server.fastmcp.exceptions import InvalidSignature
2324
from mcp.server.fastmcp.utilities.logging import get_logger
2425
from mcp.server.fastmcp.utilities.types import Audio, Image
25-
from mcp.types import ContentBlock, TextContent
26+
from mcp.types import CallToolResult, ContentBlock, TextContent
2627

2728
logger = get_logger(__name__)
2829

@@ -104,6 +105,12 @@ def convert_result(self, result: Any) -> Any:
104105
from function return values, whereas the lowlevel server simply serializes
105106
the structured output.
106107
"""
108+
if isinstance(result, CallToolResult):
109+
if self.output_schema is not None:
110+
assert self.output_model is not None, "Output model must be set if output schema is defined"
111+
self.output_model.model_validate(result.structuredContent)
112+
return result
113+
107114
unstructured_content = _convert_to_content(result)
108115

109116
if self.output_schema is None:
@@ -268,6 +275,26 @@ def func_metadata(
268275
output_info = FieldInfo.from_annotation(_get_typed_annotation(sig.return_annotation, globalns))
269276
annotation = output_info.annotation
270277

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+
290+
# if the typehint is CallToolResult, the user either intends to return without validation
291+
# or they provided validation as Annotated metadata
292+
if isinstance(annotation, type) and issubclass(annotation, CallToolResult):
293+
if output_info.metadata:
294+
annotation = output_info.metadata[0]
295+
else:
296+
return FuncMetadata(arg_model=arguments_model)
297+
271298
output_model, output_schema, wrap_output = _try_create_model_and_schema(annotation, func.__name__, output_info)
272299

273300
if output_model is None and structured_output is True:

0 commit comments

Comments
 (0)