Skip to content

Commit 3e018d6

Browse files
committed
feat: Add structured output support for tool functions
- Add support for tool functions to return structured data with validation - Functions can now use structured_output=True to enable output validation - Add outputSchema field to Tool type in MCP protocol - Implement client-side validation of structured content - Add comprehensive tests for all supported types - Add documentation and examples
1 parent 727660f commit 3e018d6

File tree

12 files changed

+2113
-50
lines changed

12 files changed

+2113
-50
lines changed

README.md

Lines changed: 73 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,78 @@ async def fetch_weather(city: str) -> str:
249250
return response.text
250251
```
251252

253+
#### Structured Output
254+
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:
256+
257+
```python
258+
from mcp.server.fastmcp import FastMCP
259+
from pydantic import BaseModel, Field
260+
from typing import TypedDict
261+
262+
mcp = FastMCP("Weather Service")
263+
264+
265+
# Using Pydantic models for rich structured data
266+
class WeatherData(BaseModel):
267+
temperature: float = Field(description="Temperature in Celsius")
268+
humidity: float = Field(description="Humidity percentage")
269+
condition: str
270+
wind_speed: float
271+
272+
273+
@mcp.tool(structured_output=True)
274+
def get_weather(city: str) -> WeatherData:
275+
"""Get structured weather data"""
276+
return WeatherData(
277+
temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3
278+
)
279+
280+
281+
# Using TypedDict for simpler structures
282+
class LocationInfo(TypedDict):
283+
latitude: float
284+
longitude: float
285+
name: str
286+
287+
288+
@mcp.tool(structured_output=True)
289+
def get_location(address: str) -> LocationInfo:
290+
"""Get location coordinates"""
291+
return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK")
292+
293+
294+
# Using dict[str, Any] for flexible schemas
295+
@mcp.tool(structured_output=True)
296+
def get_statistics(data_type: str) -> dict[str, float]:
297+
"""Get various statistics"""
298+
return {"mean": 42.5, "median": 40.0, "std_dev": 5.2}
299+
300+
301+
# Lists and other types are wrapped automatically
302+
@mcp.tool(structured_output=True)
303+
def list_cities() -> list[str]:
304+
"""Get a list of cities"""
305+
return ["London", "Paris", "Tokyo"]
306+
# Returns: {"result": ["London", "Paris", "Tokyo"]}
307+
308+
309+
@mcp.tool(structured_output=True)
310+
def get_temperature(city: str) -> float:
311+
"""Get temperature as a simple float"""
312+
return 22.5
313+
# Returns: {"result": 22.5}
314+
```
315+
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+
252325
### Prompts
253326

254327
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(structured_output=True)
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(structured_output=True)
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(structured_output=True)
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(structured_output=True)
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(structured_output=True)
110+
def get_temperature(city: str, unit: str = "celsius") -> float:
111+
"""Get just the temperature for a city
112+
113+
When returning primitives with structured_output=True,
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(structured_output=True)
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)