Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

Structured output

Structured output is a tool result returned as a typed object, so the client gets both text for the model and data for code.

Last updated: 29 Sep, 2026 · MCP 2.2

The tools so far returned a sentence. A sentence is readable by a model and useless to a program. Return a Pydantic model instead and the SDK sends both shapes at once.

A tool that returns a model

python
from pydantic import BaseModel

class Order(BaseModel):     # the shape of the answer
    id: str
    ...

@mcp.tool()
def lookup_order(order_id: str) -> Order:   # return type is the model
    return Order(...)

The Order model

Add BaseModel to the pydantic import at the top of shop.py, so it reads from pydantic import BaseModel, Field. Then describe the shape of an order once. status is held to three allowed values.

python
class Order(BaseModel):
    id: str
    item: str
    status: Literal["processing", "shipped", "delivered"]
    total: float

Returning the model

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

python
@mcp.tool()
def lookup_order(order_id: str) -> Order:
    """Look up an order by its id."""
    return Order(id=order_id, **ORDERS[order_id])
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop.py
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer

mcp = MCPServer("Shop support")

ORDERS = {
    "A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
    "B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}

ARTICLES = {
    "Where is my order?": "orders",
    "Changing an order": "orders",
    "How refunds work": "refunds",
    "Refunds for damaged items": "refunds",
    "Resetting your password": "account",
}


class Order(BaseModel):
    id: str
    item: str
    status: Literal["processing", "shipped", "delivered"]
    total: float


@mcp.tool()
def lookup_order(order_id: str) -> Order:
    """Look up an order by its id."""
    return Order(id=order_id, **ORDERS[order_id])


@mcp.tool()
def search_help(
    query: Annotated[str, Field(description="Words to look for in the help articles.")],
    topic: Literal["orders", "refunds", "account"] | None = None,
    limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
    """Search the help centre and return matching article titles."""
    found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
    return "; ".join(found[:limit]) or "No articles found."

Reading text and data from one call

The same call now gives a sentence for the model and a dictionary for your code.

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())

What each part is for

  • 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 arithmetic on total works because the value is a number, which a sentence would never have allowed.

The output schema

A return type also publishes an output schema, before anyone calls the tool, so an application knows the shape of the answer in advance.

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 value is checked against that schema on the server. If ORDERS held a status of "lost", the call would fail with an error rather than send a record that breaks the promise the schema made.

A string result vs a model result

Return a strReturn a model
contentThe sentenceThe object as JSON text
structured_content{"result": ...}The fields, unwrapped
output schemaNone publishedBuilt from the model
Usable by codeParse the sentence yourselfRead fields directly

When to return a model

  • Whenever your own code, not only a model, reads the result and needs its fields.
  • When the shape of the answer matters to the caller and should be published up front.
Watch out. Returning a model makes the SDK validate every result against its schema. Data that does not fit, like a status the model does not allow, fails the call rather than reaching the client, so keep your stored data inside the shape you promised.
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.

You understood something today that you didn't yesterday.