Skip to content

Commit 0511acf

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 d0443a1 commit 0511acf

File tree

14 files changed

+2433
-62
lines changed

14 files changed

+2433
-62
lines changed

README.md

Lines changed: 83 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,88 @@ 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 pydantic import BaseModel, Field
259+
from typing import TypedDict
260+
261+
mcp = FastMCP("Weather Service")
262+
263+
264+
# Using Pydantic models for rich structured data
265+
class WeatherData(BaseModel):
266+
temperature: float = Field(description="Temperature in Celsius")
267+
humidity: float = Field(description="Humidity percentage")
268+
condition: str
269+
wind_speed: float
270+
271+
272+
@mcp.tool(structured_output=True)
273+
def get_weather(city: str) -> WeatherData:
274+
"""Get structured weather data"""
275+
return WeatherData(
276+
temperature=22.5,
277+
humidity=65.0,
278+
condition="partly cloudy",
279+
wind_speed=12.3
280+
)
281+
282+
283+
# Using TypedDict for simpler structures
284+
class LocationInfo(TypedDict):
285+
latitude: float
286+
longitude: float
287+
name: str
288+
289+
290+
@mcp.tool(structured_output=True)
291+
def get_location(address: str) -> LocationInfo:
292+
"""Get location coordinates"""
293+
return LocationInfo(
294+
latitude=51.5074,
295+
longitude=-0.1278,
296+
name="London, UK"
297+
)
298+
299+
300+
# Using dict[str, Any] for flexible schemas
301+
@mcp.tool(structured_output=True)
302+
def get_statistics(data_type: str) -> dict[str, float]:
303+
"""Get various statistics"""
304+
return {
305+
"mean": 42.5,
306+
"median": 40.0,
307+
"std_dev": 5.2
308+
}
309+
310+
311+
# Lists and other types are wrapped automatically
312+
@mcp.tool(structured_output=True)
313+
def list_cities() -> list[str]:
314+
"""Get a list of cities"""
315+
return ["London", "Paris", "Tokyo"]
316+
# Returns: {"result": ["London", "Paris", "Tokyo"]}
317+
318+
319+
@mcp.tool(structured_output=True)
320+
def get_temperature(city: str) -> float:
321+
"""Get temperature as a simple float"""
322+
return 22.5
323+
# Returns: {"result": 22.5}
324+
```
325+
326+
Structured output supports:
327+
- Pydantic models (BaseModel subclasses) - used directly
328+
- TypedDict for dictionary structures - used directly
329+
- Dataclasses and NamedTuples - converted to dictionaries
330+
- `dict[str, T]` for flexible key-value pairs - used directly
331+
- Primitive types (str, int, float, bool) - wrapped in `{"result": value}`
332+
- Generic types (list, tuple, set) - wrapped in `{"result": value}`
333+
- Union types and Optional - wrapped in `{"result": value}`
334+
252335
### Prompts
253336

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

examples/fastmcp/complex_inputs.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""
22
FastMCP Complex inputs Example
33
4-
Demonstrates validation via pydantic with complex models.
4+
Demonstrates validation via pydantic with complex models,
5+
and structured output for returning validated data.
56
"""
67

8+
from datetime import datetime
79
from typing import Annotated
810

911
from pydantic import BaseModel, Field
@@ -16,8 +18,12 @@
1618
class ShrimpTank(BaseModel):
1719
class Shrimp(BaseModel):
1820
name: Annotated[str, Field(max_length=10)]
21+
color: str = "red"
22+
age_days: int = 0
1923

2024
shrimp: list[Shrimp]
25+
temperature: float = Field(default=24.0, ge=20.0, le=28.0)
26+
ph_level: float = Field(default=7.0, ge=6.5, le=8.0)
2127

2228

2329
@mcp.tool()
@@ -28,3 +34,140 @@ def name_shrimp(
2834
) -> list[str]:
2935
"""List all shrimp names in the tank"""
3036
return [shrimp.name for shrimp in tank.shrimp] + extra_names
37+
38+
39+
# Structured output example - returns a validated tank analysis
40+
class TankAnalysis(BaseModel):
41+
"""Analysis of shrimp tank conditions"""
42+
total_shrimp: int
43+
temperature_status: str # "optimal", "too_cold", "too_hot"
44+
ph_status: str # "optimal", "too_acidic", "too_basic"
45+
shrimp_by_color: dict[str, int]
46+
oldest_shrimp: str | None
47+
recommendations: list[str]
48+
49+
50+
@mcp.tool(structured_output=True)
51+
def analyze_tank(tank: ShrimpTank) -> TankAnalysis:
52+
"""Analyze tank conditions and provide recommendations"""
53+
# Temperature analysis
54+
if tank.temperature < 22:
55+
temp_status = "too_cold"
56+
elif tank.temperature > 26:
57+
temp_status = "too_hot"
58+
else:
59+
temp_status = "optimal"
60+
61+
# pH analysis
62+
if tank.ph_level < 6.8:
63+
ph_status = "too_acidic"
64+
elif tank.ph_level > 7.5:
65+
ph_status = "too_basic"
66+
else:
67+
ph_status = "optimal"
68+
69+
# Count shrimp by color
70+
color_counts: dict[str, int] = {}
71+
for shrimp in tank.shrimp:
72+
color_counts[shrimp.color] = color_counts.get(shrimp.color, 0) + 1
73+
74+
# Find oldest shrimp
75+
oldest = None
76+
if tank.shrimp:
77+
oldest_shrimp_obj = max(tank.shrimp, key=lambda s: s.age_days)
78+
oldest = oldest_shrimp_obj.name
79+
80+
# Generate recommendations
81+
recommendations = []
82+
if temp_status == "too_cold":
83+
recommendations.append("Increase water temperature to 22-26°C")
84+
elif temp_status == "too_hot":
85+
recommendations.append("Decrease water temperature to 22-26°C")
86+
87+
if ph_status == "too_acidic":
88+
recommendations.append("Add crushed coral or baking soda to raise pH")
89+
elif ph_status == "too_basic":
90+
recommendations.append("Add Indian almond leaves or driftwood to lower pH")
91+
92+
if len(tank.shrimp) > 20:
93+
recommendations.append("Consider dividing colony to prevent overcrowding")
94+
95+
if not recommendations:
96+
recommendations.append("Tank conditions are optimal!")
97+
98+
return TankAnalysis(
99+
total_shrimp=len(tank.shrimp),
100+
temperature_status=temp_status,
101+
ph_status=ph_status,
102+
shrimp_by_color=color_counts,
103+
oldest_shrimp=oldest,
104+
recommendations=recommendations
105+
)
106+
107+
108+
# Another structured output example - breeding recommendations
109+
@mcp.tool(structured_output=True)
110+
def get_breeding_pairs(tank: ShrimpTank) -> dict[str, list[str]]:
111+
"""Suggest breeding pairs by color
112+
113+
Returns a dictionary mapping colors to lists of shrimp names
114+
that could be bred together.
115+
"""
116+
pairs_by_color: dict[str, list[str]] = {}
117+
118+
for shrimp in tank.shrimp:
119+
if shrimp.age_days >= 60: # Mature enough to breed
120+
if shrimp.color not in pairs_by_color:
121+
pairs_by_color[shrimp.color] = []
122+
pairs_by_color[shrimp.color].append(shrimp.name)
123+
124+
# Only return colors with at least 2 shrimp
125+
return {
126+
color: names
127+
for color, names in pairs_by_color.items()
128+
if len(names) >= 2
129+
}
130+
131+
132+
if __name__ == "__main__":
133+
# For testing the tools
134+
import asyncio
135+
136+
async def test():
137+
# Create a test tank
138+
tank = ShrimpTank(
139+
shrimp=[
140+
ShrimpTank.Shrimp(name="Rex", color="red", age_days=90),
141+
ShrimpTank.Shrimp(name="Blue", color="blue", age_days=45),
142+
ShrimpTank.Shrimp(name="Crimson", color="red", age_days=120),
143+
ShrimpTank.Shrimp(name="Azure", color="blue", age_days=80),
144+
ShrimpTank.Shrimp(name="Jade", color="green", age_days=30),
145+
ShrimpTank.Shrimp(name="Ruby", color="red", age_days=75),
146+
],
147+
temperature=23.5,
148+
ph_level=7.2
149+
)
150+
151+
# Test name_shrimp (non-structured output)
152+
names = name_shrimp(tank, ["Bonus1", "Bonus2"])
153+
print("Shrimp names:", names)
154+
155+
# Test analyze_tank (structured output)
156+
print("\nTank Analysis:")
157+
analysis = analyze_tank(tank)
158+
print(analysis.model_dump_json(indent=2))
159+
160+
# Test get_breeding_pairs (structured output returning dict)
161+
print("\nBreeding Pairs:")
162+
pairs = get_breeding_pairs(tank)
163+
print(f"Mature shrimp by color: {pairs}")
164+
165+
# Show the tools that would be exposed
166+
print("\nAvailable tools:")
167+
tools = await mcp.list_tools()
168+
for tool in tools:
169+
print(f"- {tool.name}: {tool.description}")
170+
if tool.outputSchema:
171+
print(f" Output schema: {tool.outputSchema.get('title', tool.outputSchema.get('type', 'structured'))}")
172+
173+
asyncio.run(test())

0 commit comments

Comments
 (0)