A tool is a function the model can call.
You declare one by putting @mcp.tool() on a plain Python function. That's the whole API.
--8<-- "docs_src/tools/tutorial001.py"Look at what you wrote. There are no schemas, no JSON, no protocol, just a function. The SDK reads three things from it:
- The name of the tool is the name of the function:
search_books. - The description the model sees is the docstring:
Search the catalog by title or author. - The arguments the model is allowed to pass come from the type hints:
query: strandlimit: int.
From those type hints the SDK generates a JSON Schema and sends it to the client during tools/list:
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"title": "Limit", "type": "integer"}
},
"required": ["query", "limit"],
"title": "search_booksArguments"
}Both arguments are in required because neither has a default. You'll fix that in a moment. (The title keys are Pydantic artifacts; the properties, their types, and required are the contract.)
!!! tip
Type hints aren't documentation here. They are the contract. If a client sends "limit": "ten",
the SDK rejects it before your function ever runs.
Call the tool with {"query": "dune", "limit": 5} and the result has two parts:
result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")]
result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."}content is the text the model reads. structured_content is typed data for the client application. It's there because you declared the return type as -> str.
Don't worry about structured_content yet. Return real Python objects from your tools and the right thing happens; the Structured Output page is all about it.
Run the server with the MCP Inspector:
uv run mcp dev server.pyOpen the URL it prints, go to the Tools tab, and call search_books.
The Inspector renders a form with a required query text field and a required limit number field. It built that form from your type hints. So will every other MCP client.
Give a parameter a default value and it stops being required. That's it. It's just Python.
--8<-- "docs_src/tools/tutorial002.py"The schema follows:
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}limit left required and gained "default": 10. A client that omits it gets 10, exactly as Python would.
Type hints get you a long way, but sometimes you want to describe an argument, or constrain it.
Wrap the type in Annotated and add a Pydantic Field:
--8<-- "docs_src/tools/tutorial003.py"Three new things, all on the parameters:
Field(description=...): a per-argument description the model reads alongside the docstring.Field(ge=1, le=50): numeric bounds. They land in the schema as"minimum": 1, "maximum": 50.Literal["fiction", "non-fiction", "poetry"]: an enum. The model can only pick one of those.
!!! check
Constraints are not decoration. Call the tool with limit=999 and the SDK answers with a
tool error before your function runs:
```text
Input should be less than or equal to 50
```
That error goes back to the model as the tool result, and the model reads it and retries with
a valid value. You wrote `le=50` once and got self-correcting agents for free.
!!! info
If you've used FastAPI or Pydantic, you already know all of this. It's the same Field,
the same Annotated, the same validation. There is nothing MCP-specific to learn here.
When a tool takes more than a couple of arguments, group them into a Pydantic model:
--8<-- "docs_src/tools/tutorial004.py"The Book schema is nested inside the tool's input schema (as a $defs reference), the model fills it in as a JSON object, and your function receives a real Book instance, already validated, with .title, .author and .year attributes.
You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down.
If a tool does I/O (calls an API, reads a file, queries a database), declare it async def and await inside it. The SDK awaits it.
A plain def tool works too: the SDK runs it in a thread so it never blocks the server.
There is nothing else to configure.
Everything the SDK infers, you can override in the decorator:
--8<-- "docs_src/tools/tutorial005.py"titleis a human-readable name for UIs. Clients show "Search the catalog" instead ofsearch_books.annotationsare behavioural hints for the client:read_only_hint=True: this tool doesn't change anything.open_world_hint=False: it works on a closed set of things (this catalog), not the open web.- The other two,
destructive_hintandidempotent_hint, describe a tool that writes: may it delete something, and is calling it twice the same as calling it once? The spec defines both only for non-read-only tools, so they would say nothing onsearch_books.
A well-behaved client uses them to decide things like "do I need to ask the user before running this?". They are hints, not security. Never rely on a client honouring them.
!!! tip
name= and description= are also accepted by @mcp.tool() if you don't want to derive them
from the function name and docstring. Most of the time you do.
@mcp.tool()on a function makes it a tool. Name from the function, description from the docstring.- Type hints are the input schema. Defaults make arguments optional.
Annotated[..., Field(...)]adds descriptions and constraints;Literaladds enums.- A Pydantic model parameter is how you take a structured "body".
- Bad arguments are rejected for you, with an error the model can read and recover from.
async deffor I/O, plaindeffor everything else.
Structured Output is what happens to the value you return.