MCP in Practice

Lesson 16 of 36

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.

python
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:

text
{'$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:

One answer, two channels: content for the model to phrase, structured_content for your code to parse
One answer, two channels: content for the model to phrase, structured_content for your code to parse
python
result = await client.call_tool("find_slots", {"room_id": "studio-b", "day": "2026-09-17"})
print(result.content)
print(result.structured_content)
text
[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:

python
@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:

python
@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:

python
@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.

← Previous