Skip to content

Commit 0695b4b

Browse files
committed
rebase on lowlevel schema validation, address comments
1 parent 16c42f8 commit 0695b4b

File tree

11 files changed

+477
-791
lines changed

11 files changed

+477
-791
lines changed

README.md

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,31 @@ async def fetch_weather(city: str) -> str:
252252

253253
#### Structured Output
254254

255-
Tools can return structured data with automatic validation using the `structured_output=True` parameter. This ensures your tools return well-typed, validated data that clients can easily process:
255+
Tools will return structured results by default, if their return type
256+
annotation is compatible. Otherwise, they will return unstructured results.
257+
258+
Structured output supports these return types:
259+
- Pydantic models (BaseModel subclasses)
260+
- TypedDicts
261+
- Dataclasses
262+
- NamedTuples
263+
- `dict[str, T]`
264+
- Primitive types (str, int, float, bool) - wrapped in `{"result": value}`
265+
- Generic types (list, tuple, set) - wrapped in `{"result": value}`
266+
- Union types and Optional - wrapped in `{"result": value}`
267+
268+
Structured results are automatically validated against the output schema
269+
generated from the annotation. This ensures the tool returns well-typed,
270+
validated data that clients can easily process:
271+
272+
**Note:** For backward compatibility, unstructured results are also
273+
returned. Unstructured results are provided strictly for compatibility
274+
with previous versions of FastMCP.
275+
276+
**Note:** In cases where a tool function's return type annotation
277+
causes the tool to be classified as structured _and this is undesirable_,
278+
the classification can be suppressed by passing `structured_output=False`
279+
to the `@tool` decorator.
256280

257281
```python
258282
from mcp.server.fastmcp import FastMCP
@@ -270,7 +294,7 @@ class WeatherData(BaseModel):
270294
wind_speed: float
271295

272296

273-
@mcp.tool(structured_output=True)
297+
@mcp.tool()
274298
def get_weather(city: str) -> WeatherData:
275299
"""Get structured weather data"""
276300
return WeatherData(
@@ -285,43 +309,34 @@ class LocationInfo(TypedDict):
285309
name: str
286310

287311

288-
@mcp.tool(structured_output=True)
312+
@mcp.tool()
289313
def get_location(address: str) -> LocationInfo:
290314
"""Get location coordinates"""
291315
return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK")
292316

293317

294318
# Using dict[str, Any] for flexible schemas
295-
@mcp.tool(structured_output=True)
319+
@mcp.tool()
296320
def get_statistics(data_type: str) -> dict[str, float]:
297321
"""Get various statistics"""
298322
return {"mean": 42.5, "median": 40.0, "std_dev": 5.2}
299323

300324

301325
# Lists and other types are wrapped automatically
302-
@mcp.tool(structured_output=True)
326+
@mcp.tool()
303327
def list_cities() -> list[str]:
304328
"""Get a list of cities"""
305329
return ["London", "Paris", "Tokyo"]
306330
# Returns: {"result": ["London", "Paris", "Tokyo"]}
307331

308332

309-
@mcp.tool(structured_output=True)
333+
@mcp.tool()
310334
def get_temperature(city: str) -> float:
311335
"""Get temperature as a simple float"""
312336
return 22.5
313337
# Returns: {"result": 22.5}
314338
```
315339

316-
Structured output supports:
317-
- Pydantic models (BaseModel subclasses) - used directly
318-
- TypedDict for dictionary structures - used directly
319-
- Dataclasses and NamedTuples - converted to dictionaries
320-
- `dict[str, T]` for flexible key-value pairs - used directly
321-
- Primitive types (str, int, float, bool) - wrapped in `{"result": value}`
322-
- Generic types (list, tuple, set) - wrapped in `{"result": value}`
323-
- Union types and Optional - wrapped in `{"result": value}`
324-
325340
### Prompts
326341

327342
Prompts are reusable templates that help LLMs interact with your server effectively:

examples/fastmcp/weather_structured.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class WeatherData(BaseModel):
3333
timestamp: datetime = Field(default_factory=datetime.now, description="Observation time")
3434

3535

36-
@mcp.tool(structured_output=True)
36+
@mcp.tool()
3737
def get_weather(city: str) -> WeatherData:
3838
"""Get current weather for a city with full structured data"""
3939
# In a real implementation, this would fetch from a weather API
@@ -49,14 +49,14 @@ class WeatherSummary(TypedDict):
4949
description: str
5050

5151

52-
@mcp.tool(structured_output=True)
52+
@mcp.tool()
5353
def get_weather_summary(city: str) -> WeatherSummary:
5454
"""Get a brief weather summary for a city"""
5555
return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze")
5656

5757

5858
# Example 3: Using dict[str, Any] for flexible schemas
59-
@mcp.tool(structured_output=True)
59+
@mcp.tool()
6060
def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]:
6161
"""Get weather metrics for multiple cities
6262
@@ -81,7 +81,7 @@ class WeatherAlert:
8181
valid_until: datetime
8282

8383

84-
@mcp.tool(structured_output=True)
84+
@mcp.tool()
8585
def get_weather_alerts(region: str) -> list[WeatherAlert]:
8686
"""Get active weather alerts for a region"""
8787
# In production, this would fetch real alerts
@@ -106,11 +106,11 @@ def get_weather_alerts(region: str) -> list[WeatherAlert]:
106106

107107

108108
# Example 5: Returning primitives with structured output
109-
@mcp.tool(structured_output=True)
109+
@mcp.tool()
110110
def get_temperature(city: str, unit: str = "celsius") -> float:
111111
"""Get just the temperature for a city
112112
113-
When returning primitives with structured_output=True,
113+
When returning primitives as structured output,
114114
the result is wrapped in {"result": value}
115115
"""
116116
base_temp = 22.5
@@ -138,7 +138,7 @@ class WeatherStats(BaseModel):
138138
precipitation_mm: float = Field(description="Total precipitation in millimeters")
139139

140140

141-
@mcp.tool(structured_output=True)
141+
@mcp.tool()
142142
def get_weather_stats(city: str, days: int = 7) -> WeatherStats:
143143
"""Get weather statistics for the past N days"""
144144
return WeatherStats(

src/mcp/server/fastmcp/server.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
AbstractAsyncContextManager,
1010
asynccontextmanager,
1111
)
12+
from itertools import chain
1213
from typing import Any, Generic, Literal
1314

1415
import anyio
@@ -37,6 +38,7 @@
3738
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
3839
from mcp.server.fastmcp.tools import Tool, ToolManager
3940
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
41+
from mcp.server.fastmcp.utilities.types import Image
4042
from mcp.server.lowlevel.helper_types import ReadResourceContents
4143
from mcp.server.lowlevel.server import LifespanResultT
4244
from mcp.server.lowlevel.server import Server as MCPServer
@@ -52,6 +54,7 @@
5254
AnyFunction,
5355
ContentBlock,
5456
GetPromptResult,
57+
TextContent,
5558
ToolAnnotations,
5659
)
5760
from mcp.types import Prompt as MCPPrompt
@@ -232,7 +235,10 @@ def run(
232235
def _setup_handlers(self) -> None:
233236
"""Set up core MCP protocol handlers."""
234237
self._mcp_server.list_tools()(self.list_tools)
235-
self._mcp_server.call_tool()(self.call_tool)
238+
# Note: we disable the lowlevel server's input validation.
239+
# FastMCP does ad hoc conversion of incoming data before validating -
240+
# for now we preserve this for backwards compatibility.
241+
self._mcp_server.call_tool(validate_input=False)(self.call_tool)
236242
self._mcp_server.list_resources()(self.list_resources)
237243
self._mcp_server.read_resource()(self.read_resource)
238244
self._mcp_server.list_prompts()(self.list_prompts)
@@ -268,7 +274,7 @@ def get_context(self) -> Context[ServerSession, object, Request]:
268274
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]:
269275
"""Call a tool by name with arguments."""
270276
context = self.get_context()
271-
return await self._tool_manager.call_tool_and_convert_result(name, arguments, context=context)
277+
return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True)
272278

273279
async def list_resources(self) -> list[MCPResource]:
274280
"""List all available resources."""
@@ -318,7 +324,7 @@ def add_tool(
318324
title: str | None = None,
319325
description: str | None = None,
320326
annotations: ToolAnnotations | None = None,
321-
structured_output: bool = False,
327+
structured_output: bool | None = None,
322328
) -> None:
323329
"""Add a tool to the server.
324330
@@ -331,7 +337,10 @@ def add_tool(
331337
title: Optional human-readable title for the tool
332338
description: Optional description of what the tool does
333339
annotations: Optional ToolAnnotations providing additional tool information
334-
structured_output: If True, validates the tool's output against its return type annotation
340+
structured_output: Controls whether the tool's output is structured or unstructured
341+
- If None, auto-detects based on the function's return type annotation
342+
- If True, unconditionally creates a structured tool (return type annotation permitting)
343+
- If False, unconditionally creates an unstructured tool
335344
"""
336345
self._tool_manager.add_tool(
337346
fn,
@@ -348,7 +357,7 @@ def tool(
348357
title: str | None = None,
349358
description: str | None = None,
350359
annotations: ToolAnnotations | None = None,
351-
structured_output: bool = False,
360+
structured_output: bool | None = None,
352361
) -> Callable[[AnyFunction], AnyFunction]:
353362
"""Decorator to register a tool.
354363
@@ -361,7 +370,10 @@ def tool(
361370
title: Optional human-readable title for the tool
362371
description: Optional description of what the tool does
363372
annotations: Optional ToolAnnotations providing additional tool information
364-
structured_output: If True, validates the tool's output against its return type annotation
373+
structured_output: Controls whether the tool's output is structured or unstructured
374+
- If None, auto-detects based on the function's return type annotation
375+
- If True, unconditionally creates a structured tool (return type annotation permitting)
376+
- If False, unconditionally creates an unstructured tool
365377
366378
Example:
367379
@server.tool()
@@ -956,6 +968,28 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -
956968
raise ValueError(str(e))
957969

958970

971+
def _convert_to_content(
972+
result: Any,
973+
) -> Sequence[ContentBlock]:
974+
"""Convert a result to a sequence of content objects."""
975+
if result is None:
976+
return []
977+
978+
if isinstance(result, ContentBlock):
979+
return [result]
980+
981+
if isinstance(result, Image):
982+
return [result.to_image_content()]
983+
984+
if isinstance(result, list | tuple):
985+
return list(chain.from_iterable(_convert_to_content(item) for item in result)) # type: ignore[reportUnknownVariableType]
986+
987+
if not isinstance(result, str):
988+
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
989+
990+
return [TextContent(type="text", text=result)]
991+
992+
959993
class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
960994
"""Context object providing access to MCP capabilities.
961995

src/mcp/server/fastmcp/tools/base.py

Lines changed: 18 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,15 @@
22

33
import functools
44
import inspect
5-
from collections.abc import Callable, Sequence
5+
from collections.abc import Callable
66
from functools import cached_property
7-
from itertools import chain
87
from typing import TYPE_CHECKING, Any, get_origin
98

10-
import pydantic_core
119
from pydantic import BaseModel, Field
1210

1311
from mcp.server.fastmcp.exceptions import ToolError
1412
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
15-
from mcp.server.fastmcp.utilities.types import Image
16-
from mcp.types import ContentBlock, TextContent, ToolAnnotations
13+
from mcp.types import ToolAnnotations
1714

1815
if TYPE_CHECKING:
1916
from mcp.server.fastmcp.server import Context
@@ -38,10 +35,14 @@ class Tool(BaseModel):
3835

3936
@cached_property
4037
def output_schema(self) -> dict[str, Any] | None:
41-
if self.fn_metadata.output_model:
38+
if self.fn_metadata.output_model is not None:
4239
return self.fn_metadata.output_model.model_json_schema()
4340
return None
4441

42+
@property
43+
def is_structured(self) -> bool:
44+
return self.fn_metadata.output_model is not None
45+
4546
@classmethod
4647
def from_function(
4748
cls,
@@ -51,7 +52,7 @@ def from_function(
5152
description: str | None = None,
5253
context_kwarg: str | None = None,
5354
annotations: ToolAnnotations | None = None,
54-
structured_output: bool = False,
55+
structured_output: bool | None = None,
5556
) -> Tool:
5657
"""Create a Tool from a function."""
5758
from mcp.server.fastmcp.server import Context
@@ -73,23 +74,20 @@ def from_function(
7374
context_kwarg = param_name
7475
break
7576

76-
fn_metadata = func_metadata(
77+
func_arg_metadata = func_metadata(
7778
fn,
7879
skip_names=[context_kwarg] if context_kwarg is not None else [],
7980
structured_output=structured_output,
8081
)
81-
parameters = fn_metadata.arg_model.model_json_schema()
82-
83-
if structured_output:
84-
assert fn_metadata.output_model is not None
82+
parameters = func_arg_metadata.arg_model.model_json_schema()
8583

8684
return cls(
8785
fn=fn,
8886
name=func_name,
8987
title=title,
9088
description=func_doc,
9189
parameters=parameters,
92-
fn_metadata=fn_metadata,
90+
fn_metadata=func_arg_metadata,
9391
is_async=is_async,
9492
context_kwarg=context_kwarg,
9593
annotations=annotations,
@@ -99,41 +97,23 @@ async def run(
9997
self,
10098
arguments: dict[str, Any],
10199
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
100+
convert_result: bool = False,
102101
) -> Any:
103102
"""Run the tool with arguments."""
104103
try:
105-
return await self.fn_metadata.call_fn_with_arg_validation(
104+
result = await self.fn_metadata.call_fn_with_arg_validation(
106105
self.fn,
107106
self.is_async,
108107
arguments,
109108
{self.context_kwarg: context} if self.context_kwarg is not None else None,
110109
)
111-
except Exception as e:
112-
raise ToolError(f"Error executing tool {self.name}: {e}") from e
113-
114-
def convert_result(self, result: Any) -> Sequence[ContentBlock] | dict[str, Any]:
115-
"""Validate tool result and convert to appropriate output format."""
116-
output_model = self.fn_metadata.output_model
117-
if output_model:
118-
# This will raise a ToolError if validation fails
119-
return self.fn_metadata.to_validated_dict(result)
120-
else:
121-
if result is None:
122-
return []
123110

124-
if isinstance(result, ContentBlock):
125-
return [result]
111+
if convert_result:
112+
result = self.fn_metadata.convert_result(result)
126113

127-
if isinstance(result, Image):
128-
return [result.to_image_content()]
129-
130-
if isinstance(result, list | tuple):
131-
return list(chain.from_iterable(self.convert_result(item) for item in result)) # type: ignore[reportUnknownVariableType]
132-
133-
if not isinstance(result, str):
134-
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
135-
136-
return [TextContent(type="text", text=result)]
114+
return result
115+
except Exception as e:
116+
raise ToolError(f"Error executing tool {self.name}: {e}") from e
137117

138118

139119
def _is_async_callable(obj: Any) -> bool:

0 commit comments

Comments
 (0)