Skip to content

06 · Structured Output & Tool Input Schemas

Level 1 let type hints generate a tool's schema automatically. That's fine for add(a: int, b: int). Real tools need real constraints — a status that can only be one of three values, a nested object, a field that's optional with a default. This lesson goes under the hood of what that schema actually is, and how to shape it on purpose.

What a tool's schema actually looks like

Every tool's inputSchema is plain JSON Schema. This is what FastMCP generates for:

from typing import Literal

@mcp.tool()
def create_ticket(
    title: str,
    priority: Literal["low", "medium", "high"],
    assignee: str | None = None,
) -> str:
    """Creates a support ticket."""
    ...
{
  "type": "object",
  "properties": {
    "title": { "type": "string" },
    "priority": { "type": "string", "enum": ["low", "medium", "high"] },
    "assignee": { "type": ["string", "null"] }
  },
  "required": ["title", "priority"]
}

This schema is what the model actually sees when deciding how to call the tool. A tight schema — the enum on priority especially — makes it far less likely the model ever sends "priority": "urgent" by mistake, because "urgent" was never a valid option it could have picked from.

Nested objects

from pydantic import BaseModel

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

@mcp.tool()
def ship_order(order_id: str, address: Address) -> str:
    """Ships an order to the given address."""
    ...

A Pydantic model as a parameter type produces a nested object schema automatically — address becomes {"type": "object", "properties": { "street": ..., "city": ..., "zip_code": ... }} — and the SDK validates and parses the incoming JSON into a real Address instance before your handler ever runs.

Structured tool output

A tool doesn't have to return only free text. Returning a Pydantic model (or a dict) lets the SDK attach a matching output schema and send back structured content a client can parse programmatically, in addition to a text fallback for the model to read directly:

class TicketResult(BaseModel):
    ticket_id: str
    status: str

@mcp.tool()
def create_ticket(title: str, priority: Literal["low", "medium", "high"]) -> TicketResult:
    """Creates a support ticket and returns its id and status."""
    ticket_id = f"T-{next_id()}"
    return TicketResult(ticket_id=ticket_id, status="open")

A client that wants to chain this ticket id into another tool call can read it directly from the structured result instead of parsing it back out of prose text.

Validation errors the model can act on

When a call doesn't match the schema, don't let your handler raise a bare exception with a Python stack trace — return (or raise, through the SDK's own error path) a message that tells the model specifically what was wrong:

@mcp.tool()
def create_ticket(title: str, priority: str) -> str:
    """Creates a support ticket. priority must be low, medium, or high."""
    if priority not in ("low", "medium", "high"):
        raise ValueError(
            f"priority must be one of 'low', 'medium', 'high' — got {priority!r}"
        )
    ...

A specific error like this gives the model everything it needs to retry the call correctly on its next attempt. A bare traceback doesn't.

Exercise

Take the ship_order tool above and add a quantity: int field with a constraint that it must be at least 1 (use Pydantic's Field(ge=1)). Call the tool through the Inspector with quantity: 0 and quantity: -5 and confirm you get a clear validation error in both cases rather than the handler silently running with an invalid quantity.