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
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.
class Order(BaseModel):
id: str
item: str
status: Literal["processing", "shipped", "delivered"]
total: floatReturning 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.
@mcp.tool()
def lookup_order(order_id: str) -> Order:
"""Look up an order by its id."""
return Order(id=order_id, **ORDERS[order_id])View the code here
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.
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()){
"id": "B42",
"item": "desk lamp",
"status": "processing",
"total": 48.0
}
{'id': 'B42', 'item': 'desk lamp', 'status': 'processing', 'total': 48.0}
53.0What each part is for
contentis the order as JSON text, for the model.structured_contentis the order as a dictionary, with noresultwrapper this time, because a model is already an object.- The arithmetic on
totalworks 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.
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()){'properties': {'id': {'title': 'Id', 'type': 'string'}, 'item': {'title': 'Item', 'type': 'string'}, 'status': {'enum': ['processing', 'shipped', 'delivered'], 'title': 'Status', 'type': 'string'}, 'total': {'title': 'Total', 'type': 'number'}}, 'required': ['id', 'item', 'status', 'total'], 'title': 'Order', 'type': 'object'}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 str | Return a model | |
|---|---|---|
| content | The sentence | The object as JSON text |
| structured_content | {"result": ...} | The fields, unwrapped |
| output schema | None published | Built from the model |
| Usable by code | Parse the sentence yourself | Read 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.
Related
- Previous: Tool arguments
- Next: Tool errors
- Reference: MCP tools specification
- Set B42's status to
"lost"inORDERS, call the tool, and printis_errorand the text. - Change the return type to
dictand compareoutput_schema. - Add a
delivered_on: str | None = Nonefield toOrder.
You understood something today that you didn't yesterday.