Structured Output
In Tools you returned a str and the result came back twice: as text in content, and as {"result": "..."} in structured_content.
This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest.
The short version: the return type annotation is the output schema. You already wrote it.
The output schema
from mcp.server import MCPServer
mcp = MCPServer("Weather")
READINGS = {"London": 17, "Cairo": 34, "Reykjavik": 4}
@mcp.tool()
def get_temperature(city: str) -> int:
"""Current temperature in a city, in whole degrees Celsius."""
return READINGS[city]
The line that matters is the signature: -> int.
Because of it, the tool the SDK sends during tools/list carries an output_schema next to the input schema you met in Tools:
{
"properties": {
"result": {"title": "Result", "type": "integer"}
},
"required": ["result"],
"title": "get_temperatureOutput",
"type": "object"
}
A bare int isn't a JSON object, so the SDK wraps it in {"result": ...}. Call the tool and both channels are filled:
result.content # [TextContent(text="17")]
result.structured_content # {"result": 17}
Every scalar gets the same wrapper: str, int, float, bool, bytes, None.
Two channels
Why send the same value twice?
contentis for the model. A language model reads text; this is the only part of the result it sees.structured_contentis for the application the model runs inside: code that wants17, not a sentence containing "17".output_schemais the contract between them, published before the tool is ever called.
You return one Python value. The SDK fills in all three.
Return a model
Declare the shape as a Pydantic BaseModel and return an instance:
from pydantic import BaseModel, Field
from mcp.server import MCPServer
mcp = MCPServer("Weather")
class WeatherData(BaseModel):
temperature: float = Field(description="Degrees Celsius.")
humidity: float = Field(description="Relative humidity, 0 to 1.")
conditions: str
@mcp.tool()
def get_weather(city: str) -> WeatherData:
"""Current weather for a city."""
return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast")
WeatherData is the schema now. No wrapper, no result key:
{
"properties": {
"temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"},
"humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"}
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object"
}
structured_content is the object, field for field:
result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
And the model is not left out. The SDK serializes the same object to JSON text for content:
{
"temperature": 16.2,
"humidity": 0.83,
"conditions": "Overcast"
}
Notice the Field(description=...) on temperature and humidity landed in the schema. The same Field that described your inputs describes your outputs.
Info
If you've used FastAPI's response_model, you already know this: a Pydantic model as the declared
response, serialized and documented for you. The only difference is that here the return annotation
is the whole declaration.
A TypedDict
Not every shape deserves a class. A TypedDict produces the same schema:
from typing import TypedDict
from mcp.server import MCPServer
mcp = MCPServer("Weather")
class WeatherData(TypedDict):
temperature: float
humidity: float
conditions: str
@mcp.tool()
def get_weather(city: str) -> WeatherData:
"""Current weather for a city."""
return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast")
A TypedDict is a plain dict at runtime, so that is what you build and return. The schema, the validation, and structured_content are identical to the BaseModel version (minus the descriptions, which TypedDict has no place for).
A dataclass
Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes.
from dataclasses import dataclass
from mcp.server import MCPServer
mcp = MCPServer("Weather")
@dataclass
class WeatherData:
temperature: float
humidity: float
conditions: str
@mcp.tool()
def get_weather(city: str) -> WeatherData:
"""Current weather for a city."""
return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast")
Three spellings, one schema. Use whichever your codebase already has.
Lists
A list[...] isn't a JSON object either, so it gets the {"result": ...} wrapper, with your item type as a $defs reference inside it:
from pydantic import BaseModel
from mcp.server import MCPServer
mcp = MCPServer("Weather")
class WeatherData(BaseModel):
temperature: float
humidity: float
conditions: str
@mcp.tool()
def get_forecast(city: str, days: int) -> list[WeatherData]:
"""Daily forecast for a city."""
return [WeatherData(temperature=16.2 + day, humidity=0.83, conditions="Overcast") for day in range(days)]
{
"$defs": {
"WeatherData": {
"properties": {
"temperature": {"title": "Temperature", "type": "number"},
"humidity": {"title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"}
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object"
}
},
"properties": {
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"}
},
"required": ["result"],
"title": "get_forecastOutput",
"type": "object"
}
Ask for a two-day forecast and structured_content is {"result": [{...}, {...}]}. content becomes two TextContent blocks, one per item: a list is flattened for the model rather than dumped as one string.
tuple[...], unions, and Optional[...] are wrapped the same way.
Dictionaries
dict[str, ...] is the one generic that already is a JSON object, so it isn't wrapped:
from mcp.server import MCPServer
mcp = MCPServer("Weather")
READINGS = {"London": 16.2, "Cairo": 34.1, "Reykjavik": 4.4}
@mcp.tool()
def get_temperatures(cities: list[str]) -> dict[str, float]:
"""Current temperature for each city, in degrees Celsius."""
return {city: READINGS[city] for city in cities}
{
"additionalProperties": {"type": "number"},
"title": "get_temperaturesDictOutput",
"type": "object"
}
result.structured_content # {"London": 16.2, "Reykjavik": 4.4}
The keys must be str. A dict[int, float] can't be a JSON object, so it falls back to the {"result": ...} wrapper.
Validation
output_schema is not documentation. Whatever your function returns is validated against it before it leaves the server.
You don't notice while you build the value by hand: Pydantic already made sure your WeatherData was a WeatherData. You notice the day the data comes from somewhere you don't control:
import json
from pydantic import BaseModel
from mcp.server import MCPServer
mcp = MCPServer("Weather")
UPSTREAM = {"London": '{"temperature": 16.2, "conditions": "Overcast"}'}
class WeatherData(BaseModel):
temperature: float
humidity: float
conditions: str
@mcp.tool()
def get_weather(city: str) -> WeatherData:
"""Current weather for a city."""
return json.loads(UPSTREAM[city])
The annotation promises WeatherData. The upstream response stopped sending humidity.
Check
Call get_weather and it does not quietly hand the client a half-empty object. The call fails,
and the first lines of the error name the field:
Error executing tool get_weather: 1 validation error for WeatherData
humidity
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
That text comes back as the tool result with is_error=True, so the model knows the call failed
instead of confidently reading weather that isn't there.
Returning a plain dict from a -> WeatherData tool is fine, by the way. That's exactly what json.loads produced. Validation is on the value, not on the Python type.
Opting out
Sometimes the return annotation is for your type checker, not for the protocol. Pass structured_output=False and the tool is text-only:
from mcp.server import MCPServer
mcp = MCPServer("Weather")
@mcp.tool(structured_output=False)
def weather_report(city: str) -> str:
"""A human-readable weather report for a city."""
return f"{city}: 17 degrees, overcast, light rain easing by evening."
No output_schema, no wrapping, no validation. structured_content is None and content is the string you returned.
The opposite, structured_output=True, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text.
A class without type hints
There is one way to end up unstructured without asking for it: return a class that has no annotations on its body.
from mcp.server import MCPServer
mcp = MCPServer("Weather")
class Station:
def __init__(self, name: str, online: bool):
self.name = name
self.online = online
@mcp.tool()
def get_station(name: str) -> Station:
"""Look up a weather station by name."""
return Station(name=name, online=True)
Station sets name and online inside __init__, but the class declares nothing. The SDK reads class annotations, finds none, and gives up.
Warning
It gives up silently. output_schema is None, structured_content is None, and the text
the model reads is the object's repr:
"<server.Station object at 0x7f539d75b230>"
No error, no warning, a useless tool. Move the annotations onto the class body, or pass
structured_output=True, which turns this into a hard error the moment the module imports:
Function get_station: return type <class 'server.Station'> is not serializable for structured output.
Tip
Need full control (building the CallToolResult yourself, or attaching _meta that the
application can see but the model can't)? That's The low-level Server.
Recap
- The return type annotation is the output schema. It's published in
tools/listasoutput_schema. - Scalars, lists, tuples and unions are wrapped in
{"result": ...}. Models,TypedDicts, dataclasses, annotated classes anddict[str, ...]are objects already and stay as they are. - Every result carries
content(text, for the model) andstructured_content(data, for the application). - What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
structured_output=Falseopts a tool out. A class without type hints opts out silently; watch for it.
You now own everything a tool can say back. Next, the second primitive: Resources.