Tools
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.
Your first tool
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str, limit: int) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r} (showing up to {limit})."
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.
The input schema
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.
What the model gets back
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 chapter is all about it.
Try it
Run the server with the MCP Inspector:
uv run mcp dev server.py
Open 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.
Optional arguments
Give a parameter a default value and it stops being required. That's it. It's just Python.
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str, limit: int = 10) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r} (showing up to {limit})."
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.
Richer schemas with Field
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:
from typing import Annotated, Literal
from pydantic import Field
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(
query: Annotated[str, Field(description="Title or author to search for.")],
limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of results.")] = 10,
genre: Literal["fiction", "non-fiction", "poetry"] | None = None,
) -> str:
"""Search the catalog by title or author."""
where = f" in {genre}" if genre else ""
return f"Found 3 books matching {query!r}{where} (showing up to {limit})."
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:
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.
A model as a parameter
When a tool takes more than a couple of arguments, group them into a Pydantic model:
from pydantic import BaseModel, Field
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
class Book(BaseModel):
title: str
author: str
year: int = Field(ge=1450, description="Year of first publication.")
@mcp.tool()
def add_book(book: Book) -> str:
"""Add a book to the catalog."""
return f"Added {book.title!r} by {book.author} ({book.year})."
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.
async def
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.
Names, titles, and annotations
Everything the SDK infers, you can override in the decorator:
from mcp_types import ToolAnnotations
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool(
title="Search the catalog",
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),
)
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
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.
Recap
@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.
Next up, Structured Output: what happens to the value you return.