Tools II: the return annotation is the output schema
You already wrote it
A tool returns two things to its caller, and SDK v2 derives both from the return annotation you were going to write anyway.
class Slot(BaseModel):
room_id: str = Field(description="The room this slot is in.")
start: str = Field(description="Local start time, as YYYY-MM-DDTHH:MM.")
minutes: int = Field(description="How long the slot runs.")
@mcp.tool()
def find_slots(room_id: str, day: str, minutes: Literal[30, 60, 120] = 60) -> list[Slot]:
"""Free slots in a room on a given day."""
...That -> list[Slot] produces an output schema:
{'$defs': {'Slot': {'properties': {'room_id': {...}, 'start': {...}, 'minutes': {...}},
'required': ['room_id', 'start', 'minutes'],
'title': 'Slot', 'type': 'object'}},
'properties': {'result': {'items': {'$ref': '#/$defs/Slot'}, 'title': 'Result', 'type': 'array'}},
'required': ['result'],
'title': 'find_slotsOutput',
'type': 'object'}Note the wrapping: because the return is a list rather than an object, it arrives under a result key. That is consistent — anything that is not already an object gets wrapped, so a -> int produces {"result": 17}. Knowing that saves you an afternoon the first time you go looking for a bare integer.
The lab form and the doctor's note
A blood test comes back two ways. There is a form with reference ranges and values in columns, and there is the sentence the doctor writes underneath. Both describe one test. You would not parse the sentence to build a chart, and you would not hand a patient the form and call it an explanation.
MCP returns both, always:
result = await client.call_tool("find_slots", {"room_id": "studio-b", "day": "2026-09-17"})
print(result.content)
print(result.structured_content)[TextContent(type='text', text='{\n "room_id": "studio-b",\n "start": "2026-09-17T09:00",\n "minutes": 60\n}'), TextContent(type='text', text='{\n "room_id": "studio-b",\n "start": "2026-09-17T14:00",\n "minutes": 60\n}')]
{'result': [{'room_id': 'studio-b', 'start': '2026-09-17T09:00', 'minutes': 60}, {'room_id': 'studio-b', 'start': '2026-09-17T14:00', 'minutes': 60}]}content is the note — what goes into the model's context. structured_content is the form — what an application parses.
For a beginner the takeaway is that there are two audiences for one answer. For an engineer it is more pointed: write your host code against structured_content, never against content. Parsing the text blocks works today and breaks the moment somebody improves the formatting.
One detail visible above and worth noting because it surprises people: a list return produces one TextContent block per element, not a single block containing a list.
Everything works
The return annotation can be almost anything, and the schema follows:
@mcp.tool()
def room_count() -> int:
"""How many bookable rooms the floor has."""
return 3
@mcp.tool()
def utilisation() -> dict[str, float]:
"""Yesterday's utilisation, per room."""
return {"atrium": 0.62, "studio-b": 0.91, "nook": 0.25}Pydantic models, TypedDict, dataclasses, list[...], dict[...] and scalars all produce schemas. Pick whichever suits the code; the protocol treats them identically.
When to opt out
Sometimes prose really is the answer:
@mcp.tool(structured_output=False)
def floor_summary(day: str) -> str:
"""A short readable summary of how the floor looks on a given day."""
return "Studio B is nearly full; The Atrium has the afternoon free; The Nook is unbooked."structured_output=False suppresses the structured channel. The honest use is a tool whose output genuinely has no structure — a summary, an explanation, something written for a human to read.
The dishonest use is a tool that returns structured data as a formatted string because that was easier. If your return value has fields, give it a type and let the schema exist. A caller that has to regex your output is a caller you have failed.
Validation is not optional
Results are validated against the generated schema before they leave. A handler that returns something that does not match its annotation fails loudly rather than shipping malformed data:
@mcp.tool()
def broken() -> Slot:
"""Return the wrong shape on purpose."""
return {"room_id": "nook"}Missing start and minutes, so it errors instead of silently sending a partial object. This is worth a moment's appreciation: the annotation you wrote for your own benefit became a runtime guarantee for a consumer you will never meet.
What to take into the next lesson
The return annotation is the output schema; non-objects get wrapped under result; every caller receives both content for the model and structured_content for code, and host code should read the latter. Opt out only when the output genuinely has no structure. Next: telling the host what your tool will do to the world.