MCP in Practice

Lesson 15 of 36

Tools I: your type hints are the schema

You never hand-write JSON Schema

The schema a client sees is generated from your function signature. That is not a shortcut the SDK offers; it is the intended way to work. Your job is to write a signature precise enough that the generated schema is correct, then read it back to confirm you meant it.

Give find_slots some arguments:

python
from typing import Literal

@mcp.tool()
def find_slots(room_id: str, day: str, minutes: Literal[30, 60, 120] = 60) -> list[str]:
    """Free slots in a room on a given day."""
    ...

And read what the client receives:

text
{'type': 'object',
 'properties': {'room_id': {'title': 'Room Id', 'type': 'string'},
                'day': {'title': 'Day', 'type': 'string'},
                'minutes': {'default': 60, 'enum': [30, 60, 120], 'title': 'Minutes', 'type': 'integer'}},
 'required': ['room_id', 'day'],
 'title': 'find_slotsArguments'}

Three things happened without being asked for. Literal[30, 60, 120] became an enum, so a client can reject 45 before your function runs. The defaulted parameter dropped out of required. And titles were generated from the parameter names.

What is missing is the important part. A model reading that schema knows room_id is a string. It does not know that valid values are atrium, studio-b and nook, or that day is YYYY-MM-DD rather than 17 September. It will guess, and it will guess wrong often enough to matter.

The schema is a job advert

You are not documenting this function for a colleague who can ask you a follow-up question. You are writing the entire briefing a stranger will act on, in one shot, with no way to clarify.

That is a job advert, and the failure mode is the same. Write a vague advert and you get applicants who misunderstood the role — not because they are careless, but because you gave them nothing better to work from. Write a specific one and the right people apply.

Type hints and Field annotations become the JSON Schema a model reads before calling the tool
Type hints and Field annotations become the JSON Schema a model reads before calling the tool

For a beginner this is the moment descriptions stop feeling like comments. For anyone who has maintained a public API, it is the interface-versus-implementation argument arriving somewhere unexpected: the schema is the contract, the docstring is the documentation, and both ship to a consumer you will never meet.

Saying what you mean

Annotated with Pydantic's Field attaches a description and constraints to a parameter:

python
from typing import Annotated, Literal
from pydantic import Field

@mcp.tool(title="Find free slots")
def find_slots(
    room_id: Annotated[str, Field(description="Room id: atrium, studio-b or nook.")],
    day: Annotated[str, Field(description="The day to search, as YYYY-MM-DD.")],
    minutes: Literal[30, 60, 120] = 60,
) -> list[Slot]:
    """Free slots in a room on a given day. Call this before booking anything."""

The generated schema now carries the descriptions:

Every part of a tool definition, and which of them the model actually reads
Every part of a tool definition, and which of them the model actually reads
text
'room_id': {'description': 'Room id: atrium, studio-b or nook.', 'title': 'Room Id', 'type': 'string'},
'day': {'description': 'The day to search, as YYYY-MM-DD.', 'title': 'Day', 'type': 'string'}

Two more things earned their place. The docstring says when to call the tool — "Call this before booking anything" — not just what it returns. And title="Find free slots" is a human-readable label for the client's UI, separate from the machine name.

Field also enforces constraints, which is better than describing them:

python
hour: Annotated[int, Field(ge=8, le=17, description="Start hour, 24h clock.")]

ge and le land in the schema as minimum and maximum. A model that proposes hour=23 is refused before your code runs, and the refusal explains itself. A description saying "must be between 8 and 17" is a hope; a constraint is a rule.

The general principle: put a fact in the schema if the schema can hold it, and in the description only if it cannot. Enumerable values go in Literal. Ranges go in ge/le. Formats and meaning go in description, because JSON Schema cannot express "the day the member wants".

Anything the schema can enforce belongs in the schema; only meaning belongs in the description
Anything the schema can enforce belongs in the schema; only meaning belongs in the description

Grouping arguments

When several parameters belong together, a Pydantic model is cleaner than a long signature:

python
from pydantic import BaseModel, Field

class BookingRequest(BaseModel):
    day: str = Field(description="The day to book, as YYYY-MM-DD.")
    hour: int = Field(ge=8, le=17, description="Start hour, 24h clock.")
    minutes: Literal[30, 60, 120] = 60

@mcp.tool()
def book_room(room_id: str, request: BookingRequest) -> str:
    """Book a room."""

The model becomes a nested object in the schema, with its own $defs entry. Use this when the grouping is real. Nesting for its own sake makes the model's job harder, because it now has to construct a structure rather than fill in a form.

Async when it does I/O

Handlers may be def or async def, and the rule is ordinary:

python
@mcp.tool()
async def fetch_holidays(year: int) -> list[str]:
    """Public holidays, from the calendar service."""
    async with httpx2.AsyncClient() as client:
        ...

Use async def for I/O — HTTP, a database driver that supports it. Use def for computation and for synchronous libraries.

If you have read an older tutorial, note that SDK v2 runs synchronous handlers on a worker thread rather than blocking the event loop, so a def handler that takes 200ms no longer stalls every other request. The practical consequence is that you cannot call asyncio.get_running_loop() inside a sync handler, because there is not one on that thread.

What to take into the next lesson

The schema is generated from your signature, so precision in the signature is precision in the contract: Literal becomes enum, Field(ge=, le=) becomes real validation, and Annotated[..., Field(description=...)] is how a parameter explains itself. Put a fact in the schema when the schema can hold it. Next: the return type, which turns out to be a schema too.

← Previous