Skip to content

Commit 43bb24f

Browse files
authored
feat: Add structured output support for tool functions (#993)
1 parent c8bbfc0 commit 43bb24f

File tree

12 files changed

+1774
-61
lines changed

12 files changed

+1774
-61
lines changed

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- [Server](#server)
2828
- [Resources](#resources)
2929
- [Tools](#tools)
30+
- [Structured Output](#structured-output)
3031
- [Prompts](#prompts)
3132
- [Images](#images)
3233
- [Context](#context)
@@ -249,6 +250,127 @@ async def fetch_weather(city: str) -> str:
249250
return response.text
250251
```
251252

253+
#### Structured Output
254+
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 and other classes with type hints
262+
- `dict[str, T]` (where T is any JSON-serializable type)
263+
- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}`
264+
- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}`
265+
266+
Classes without type hints cannot be serialized for structured output. Only
267+
classes with properly annotated attributes will be converted to Pydantic models
268+
for schema generation and validation.
269+
270+
Structured results are automatically validated against the output schema
271+
generated from the annotation. This ensures the tool returns well-typed,
272+
validated data that clients can easily process.
273+
274+
**Note:** For backward compatibility, unstructured results are also
275+
returned. Unstructured results are provided for backward compatibility
276+
with previous versions of the MCP specification, and are quirks-compatible
277+
with previous versions of FastMCP in the current version of the SDK.
278+
279+
**Note:** In cases where a tool function's return type annotation
280+
causes the tool to be classified as structured _and this is undesirable_,
281+
the classification can be suppressed by passing `structured_output=False`
282+
to the `@tool` decorator.
283+
284+
```python
285+
from mcp.server.fastmcp import FastMCP
286+
from pydantic import BaseModel, Field
287+
from typing import TypedDict
288+
289+
mcp = FastMCP("Weather Service")
290+
291+
292+
# Using Pydantic models for rich structured data
293+
class WeatherData(BaseModel):
294+
temperature: float = Field(description="Temperature in Celsius")
295+
humidity: float = Field(description="Humidity percentage")
296+
condition: str
297+
wind_speed: float
298+
299+
300+
@mcp.tool()
301+
def get_weather(city: str) -> WeatherData:
302+
"""Get structured weather data"""
303+
return WeatherData(
304+
temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3
305+
)
306+
307+
308+
# Using TypedDict for simpler structures
309+
class LocationInfo(TypedDict):
310+
latitude: float
311+
longitude: float
312+
name: str
313+
314+
315+
@mcp.tool()
316+
def get_location(address: str) -> LocationInfo:
317+
"""Get location coordinates"""
318+
return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK")
319+
320+
321+
# Using dict[str, Any] for flexible schemas
322+
@mcp.tool()
323+
def get_statistics(data_type: str) -> dict[str, float]:
324+
"""Get various statistics"""
325+
return {"mean": 42.5, "median": 40.0, "std_dev": 5.2}
326+
327+
328+
# Ordinary classes with type hints work for structured output
329+
class UserProfile:
330+
name: str
331+
age: int
332+
email: str | None = None
333+
334+
def __init__(self, name: str, age: int, email: str | None = None):
335+
self.name = name
336+
self.age = age
337+
self.email = email
338+
339+
340+
@mcp.tool()
341+
def get_user(user_id: str) -> UserProfile:
342+
"""Get user profile - returns structured data"""
343+
return UserProfile(name="Alice", age=30, email="alice@example.com")
344+
345+
346+
# Classes WITHOUT type hints cannot be used for structured output
347+
class UntypedConfig:
348+
def __init__(self, setting1, setting2):
349+
self.setting1 = setting1
350+
self.setting2 = setting2
351+
352+
353+
@mcp.tool()
354+
def get_config() -> UntypedConfig:
355+
"""This returns unstructured output - no schema generated"""
356+
return UntypedConfig("value1", "value2")
357+
358+
359+
# Lists and other types are wrapped automatically
360+
@mcp.tool()
361+
def list_cities() -> list[str]:
362+
"""Get a list of cities"""
363+
return ["London", "Paris", "Tokyo"]
364+
# Returns: {"result": ["London", "Paris", "Tokyo"]}
365+
366+
367+
@mcp.tool()
368+
def get_temperature(city: str) -> float:
369+
"""Get temperature as a simple float"""
370+
return 22.5
371+
# Returns: {"result": 22.5}
372+
```
373+
252374
### Prompts
253375

254376
Prompts are reusable templates that help LLMs interact with your server effectively:
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
FastMCP Weather Example with Structured Output
3+
4+
Demonstrates how to use structured output with tools to return
5+
well-typed, validated data that clients can easily process.
6+
"""
7+
8+
import asyncio
9+
import json
10+
import sys
11+
from dataclasses import dataclass
12+
from datetime import datetime
13+
from typing import TypedDict
14+
15+
from pydantic import BaseModel, Field
16+
17+
from mcp.server.fastmcp import FastMCP
18+
from mcp.shared.memory import create_connected_server_and_client_session as client_session
19+
20+
# Create server
21+
mcp = FastMCP("Weather Service")
22+
23+
24+
# Example 1: Using a Pydantic model for structured output
25+
class WeatherData(BaseModel):
26+
"""Structured weather data response"""
27+
28+
temperature: float = Field(description="Temperature in Celsius")
29+
humidity: float = Field(description="Humidity percentage (0-100)")
30+
condition: str = Field(description="Weather condition (sunny, cloudy, rainy, etc.)")
31+
wind_speed: float = Field(description="Wind speed in km/h")
32+
location: str = Field(description="Location name")
33+
timestamp: datetime = Field(default_factory=datetime.now, description="Observation time")
34+
35+
36+
@mcp.tool()
37+
def get_weather(city: str) -> WeatherData:
38+
"""Get current weather for a city with full structured data"""
39+
# In a real implementation, this would fetch from a weather API
40+
return WeatherData(temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3, location=city)
41+
42+
43+
# Example 2: Using TypedDict for a simpler structure
44+
class WeatherSummary(TypedDict):
45+
"""Simple weather summary"""
46+
47+
city: str
48+
temp_c: float
49+
description: str
50+
51+
52+
@mcp.tool()
53+
def get_weather_summary(city: str) -> WeatherSummary:
54+
"""Get a brief weather summary for a city"""
55+
return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze")
56+
57+
58+
# Example 3: Using dict[str, Any] for flexible schemas
59+
@mcp.tool()
60+
def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]:
61+
"""Get weather metrics for multiple cities
62+
63+
Returns a dictionary mapping city names to their metrics
64+
"""
65+
# Returns nested dictionaries with weather metrics
66+
return {
67+
city: {"temperature": 20.0 + i * 2, "humidity": 60.0 + i * 5, "pressure": 1013.0 + i * 0.5}
68+
for i, city in enumerate(cities)
69+
}
70+
71+
72+
# Example 4: Using dataclass for weather alerts
73+
@dataclass
74+
class WeatherAlert:
75+
"""Weather alert information"""
76+
77+
severity: str # "low", "medium", "high"
78+
title: str
79+
description: str
80+
affected_areas: list[str]
81+
valid_until: datetime
82+
83+
84+
@mcp.tool()
85+
def get_weather_alerts(region: str) -> list[WeatherAlert]:
86+
"""Get active weather alerts for a region"""
87+
# In production, this would fetch real alerts
88+
if region.lower() == "california":
89+
return [
90+
WeatherAlert(
91+
severity="high",
92+
title="Heat Wave Warning",
93+
description="Temperatures expected to exceed 40°C",
94+
affected_areas=["Los Angeles", "San Diego", "Riverside"],
95+
valid_until=datetime(2024, 7, 15, 18, 0),
96+
),
97+
WeatherAlert(
98+
severity="medium",
99+
title="Air Quality Advisory",
100+
description="Poor air quality due to wildfire smoke",
101+
affected_areas=["San Francisco Bay Area"],
102+
valid_until=datetime(2024, 7, 14, 12, 0),
103+
),
104+
]
105+
return []
106+
107+
108+
# Example 5: Returning primitives with structured output
109+
@mcp.tool()
110+
def get_temperature(city: str, unit: str = "celsius") -> float:
111+
"""Get just the temperature for a city
112+
113+
When returning primitives as structured output,
114+
the result is wrapped in {"result": value}
115+
"""
116+
base_temp = 22.5
117+
if unit.lower() == "fahrenheit":
118+
return base_temp * 9 / 5 + 32
119+
return base_temp
120+
121+
122+
# Example 6: Weather statistics with nested models
123+
class DailyStats(BaseModel):
124+
"""Statistics for a single day"""
125+
126+
high: float
127+
low: float
128+
mean: float
129+
130+
131+
class WeatherStats(BaseModel):
132+
"""Weather statistics over a period"""
133+
134+
location: str
135+
period_days: int
136+
temperature: DailyStats
137+
humidity: DailyStats
138+
precipitation_mm: float = Field(description="Total precipitation in millimeters")
139+
140+
141+
@mcp.tool()
142+
def get_weather_stats(city: str, days: int = 7) -> WeatherStats:
143+
"""Get weather statistics for the past N days"""
144+
return WeatherStats(
145+
location=city,
146+
period_days=days,
147+
temperature=DailyStats(high=28.5, low=15.2, mean=21.8),
148+
humidity=DailyStats(high=85.0, low=45.0, mean=65.0),
149+
precipitation_mm=12.4,
150+
)
151+
152+
153+
if __name__ == "__main__":
154+
155+
async def test() -> None:
156+
"""Test the tools by calling them through the server as a client would"""
157+
print("Testing Weather Service Tools (via MCP protocol)\n")
158+
print("=" * 80)
159+
160+
async with client_session(mcp._mcp_server) as client:
161+
# Test get_weather
162+
result = await client.call_tool("get_weather", {"city": "London"})
163+
print("\nWeather in London:")
164+
print(json.dumps(result.structuredContent, indent=2))
165+
166+
# Test get_weather_summary
167+
result = await client.call_tool("get_weather_summary", {"city": "Paris"})
168+
print("\nWeather summary for Paris:")
169+
print(json.dumps(result.structuredContent, indent=2))
170+
171+
# Test get_weather_metrics
172+
result = await client.call_tool("get_weather_metrics", {"cities": ["Tokyo", "Sydney", "Mumbai"]})
173+
print("\nWeather metrics:")
174+
print(json.dumps(result.structuredContent, indent=2))
175+
176+
# Test get_weather_alerts
177+
result = await client.call_tool("get_weather_alerts", {"region": "California"})
178+
print("\nWeather alerts for California:")
179+
print(json.dumps(result.structuredContent, indent=2))
180+
181+
# Test get_temperature
182+
result = await client.call_tool("get_temperature", {"city": "Berlin", "unit": "fahrenheit"})
183+
print("\nTemperature in Berlin:")
184+
print(json.dumps(result.structuredContent, indent=2))
185+
186+
# Test get_weather_stats
187+
result = await client.call_tool("get_weather_stats", {"city": "Seattle", "days": 30})
188+
print("\nWeather stats for Seattle (30 days):")
189+
print(json.dumps(result.structuredContent, indent=2))
190+
191+
# Also show the text content for comparison
192+
print("\nText content for last result:")
193+
for content in result.content:
194+
if content.type == "text":
195+
print(content.text)
196+
197+
async def print_schemas() -> None:
198+
"""Print all tool schemas"""
199+
print("Tool Schemas for Weather Service\n")
200+
print("=" * 80)
201+
202+
tools = await mcp.list_tools()
203+
for tool in tools:
204+
print(f"\nTool: {tool.name}")
205+
print(f"Description: {tool.description}")
206+
print("Input Schema:")
207+
print(json.dumps(tool.inputSchema, indent=2))
208+
209+
if tool.outputSchema:
210+
print("Output Schema:")
211+
print(json.dumps(tool.outputSchema, indent=2))
212+
else:
213+
print("Output Schema: None (returns unstructured content)")
214+
215+
print("-" * 80)
216+
217+
# Check command line arguments
218+
if len(sys.argv) > 1 and sys.argv[1] == "--schemas":
219+
asyncio.run(print_schemas())
220+
else:
221+
print("Usage:")
222+
print(" python weather_structured.py # Run tool tests")
223+
print(" python weather_structured.py --schemas # Print tool schemas")
224+
print()
225+
asyncio.run(test())

0 commit comments

Comments
 (0)