MCPMCP Python SDK 2.2 · Python 3.10+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
24 small wins to finish your pathNext lesson

Structured output: returning data, not sentences

A sentence is readable by a model and useless to a program. Return a Pydantic model and the client gets both: text for the model and data for code.

Exampleshop.py, new
class Order(BaseModel):
    id: str
    item: str
    status: Literal["processing", "shipped", "delivered"]
    total: float
Exampleshop.py, lookup_order now
@mcp.tool()
def lookup_order(order_id: str) -> Order:
    """Look up an order by its id."""
    return Order(id=order_id, **ORDERS[order_id])

The return type is now Order. **ORDERS[order_id] spreads the stored dictionary into the model's fields, and Pydantic checks each one, including that status is one of the three allowed values.

Example
import asyncio

from mcp import Client
from shop import mcp


async def main():
    async with Client(mcp) as client:
        result = await client.call_tool("lookup_order", {"order_id": "B42"})
        print(result.content[0].text)
        print(result.structured_content)
        print(result.structured_content["total"] + 5)


asyncio.run(main())

content is the order as JSON text, for the model. structured_content is the order as a dictionary, with no result wrapper this time because a model is already an object. The last line does arithmetic on the total, which a sentence would never have allowed.

The output schema

Example
import asyncio

from mcp import Client
from shop import mcp


async def main():
    async with Client(mcp) as client:
        tools = await client.list_tools()
        print(tools.tools[0].output_schema)


asyncio.run(main())

The return type became an output schema, published before anyone calls the tool. An application can know the shape of the answer in advance.

Data that does not match

The return value is checked against that schema on the server. If ORDERS held a status of "lost", the call would fail with an error instead of sending a record that breaks the promise the schema made.

Try it yourself
  • Set B42's status to "lost" in ORDERS, call the tool, and print is_error and the text.
  • Change the return type to dict and compare output_schema.
  • Add a delivered_on: str | None = None field to Order.

Every expert started right here.