Defining Callable Tools in an MCP Server
A callable tool is the most direct way an MCP server gives a model something to do.
Search across all documentation pages
A callable tool is the most direct way an MCP server gives a model something to do.
Getting a tool right means the model can find it, understand what it does, call it with valid arguments, and make sense of what comes back.
A tool in the Python MCP SDK starts as an ordinary function decorated with @mcp.tool().
The function's name, its type-hinted parameters, and its docstring together become the schema a client uses to decide when and how to call it.
Handlers should stay narrow and predictable, doing one job well rather than branching into several unrelated behaviors based on a flag argument.
Errors should be raised as exceptions with a clear message, not swallowed or returned as ambiguous strings, so the model gets useful feedback when a call fails.
Structured, validated inputs (via Pydantic models or plain type hints) catch bad arguments before your handler code ever runs.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("tool-recipes")
@mcp.tool()
def get_weather(city: str, units: str = "celsius") -> str:
"""Get the current weather for a city.
Args:
city: The city name, e.g. "Austin" or "Tokyo".
units: Either "celsius" or "fahrenheit". Defaults to "celsius".
"""
if units not in ("celsius", "fahrenheit"):
raise ValueError('units must be "celsius" or "fahrenheit"')
return f"72 degrees {units} and sunny in {city}"When to reach for this:
# server.py
from typing import Literal
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("task-tools")
# In-memory store for this example; swap for a real database in production.
_tasks: dict[int, dict] = {}
_next_id = 1
class CreateTaskInput(BaseModel):
title: str = Field(..., min_length=1, max_length=200, description="Short task title")
priority: Literal["low", "medium", "high"] = Field(
"medium", description="Task priority level"
)
@mcp.tool()
def create_task(input: CreateTaskInput) -> str:
"""Create a new task and return its id.
Use this when the user asks to add, track, or remember a to-do item.
"""
global _next_id
task_id = _next_id
_next_id += 1
_tasks[task_id] = {"title": input.title, "priority": input.priority, "done": False}
return f"Created task {task_id}: {input.title} (priority: {input.priority})"
@mcp.tool()
def complete_task(task_id: int) -> str:
"""Mark an existing task as done.
Args:
task_id: The numeric id returned by create_task.
"""
task = _tasks.get(task_id)
if task is None:
raise ValueError(f"No task found with id {task_id}")
task["done"] = True
return f"Task {task_id} ({task['title']}) marked done"
@mcp.tool()
def list_tasks(only_open: bool = True) -> str:
"""List tracked tasks.
Args:
only_open: If True, hide tasks already marked done. Defaults to True.
"""
rows = [
f"#{tid} [{t['priority']}] {t['title']}{' (done)' if t['done'] else ''}"
for tid, t in _tasks.items()
if not (only_open and t["done"])
]
return "\n".join(rows) if rows else "No tasks found."
if __name__ == "__main__":
mcp.run(transport="stdio")What this demonstrates:
create_task, complete_task, list_tasks) instead of one tool with a mode flag.CreateTaskInput) validating a structured input with a length constraint and a constrained set of allowed values.Literal type restricting priority to exactly three valid strings, enforced before the handler body runs.ValueError with a message naming the specific problem.FastMCP inspects every @mcp.tool()-decorated function and builds a JSON Schema from its type hints and default values.list_tools(), it receives this schema and description for every registered tool, without you writing any schema by hand.Literal set), the SDK returns a schema error to the client without ever calling your handler.| Element | Good Practice | Why It Matters |
|---|---|---|
| Tool name | snake_case, verb-first (create_task, not task) | The model matches names to intent; a verb signals an action |
| Docstring first line | One sentence stating exactly what the tool does | This is the primary text the model uses to decide relevance |
Docstring Args: | One line per parameter, plain language | Clarifies units, formats, and valid ranges the type hint can't express |
| Parameter names | Descriptive, not abbreviated (city, not c) | Reduces ambiguous or malformed calls |
# Prefer a constrained type over a loose string when the valid set is small and known.
from typing import Literal
@mcp.tool()
def set_status(status: Literal["open", "in_progress", "closed"]) -> str:
"""Set the status of the current item."""
return f"Status set to {status}"
# For richer validation (ranges, formats, nested fields), use a Pydantic model
# as the single parameter, as shown in CreateTaskInput above.| Parameter | Type | Description |
|---|---|---|
| function name | str (via def name) | Becomes the tool's identifier; keep it stable once clients rely on it |
| type hints | Python types / Literal / Pydantic model | Compiled into the tool's JSON Schema automatically |
| docstring | str | Becomes the tool description shown to the model |
| return value | str or structured content | Wrapped in MCP's content format and returned to the client |
handle or process. The model has nothing to match intent against. Fix: use a specific, verb-first name such as create_task or send_email.mode or action string parameter that branches into unrelated behaviors. This confuses schema-based discovery and increases the chance of a wrong call. Fix: split it into separate, narrowly scoped tools.None or an empty string on failure instead of raising. The model has no signal that anything went wrong and may report success incorrectly. Fix: raise an exception with a specific message; let the SDK translate it into a tool error.Args: block.Literal, Pydantic Field constraints, or explicit checks at the top of the handler.| Alternative | Use When | Don't Use When |
|---|---|---|
| MCP Resource | The operation is a pure read with no side effects | The model needs to take an action or perform a computation |
| MCP Prompt | You want to standardize instructions, not expose a callable action | The client needs a result value back in the conversation |
| Direct function calling (non-MCP) | The capability will only ever be used by one specific application | You want the same capability reusable across Claude Code, Claude Desktop, and custom apps |
A low-level @mcp.server handler (instead of FastMCP) | You need full control over the protocol handshake or custom transport wiring | You just need to expose a handful of straightforward tools quickly |
FastMCP reads the function's type hints to build a JSON Schema for its parameters.Plain type hints (str, int, bool, Literal[...]) are enough for simple tools. Reach for a Pydantic BaseModel parameter when you need field-level constraints, nested structures, or reusable validation logic across multiple tools.
The SDK validates incoming arguments against the generated schema before your handler runs. Invalid arguments produce a schema error returned to the client, and your function body never executes.
Specific enough that a reader unfamiliar with your codebase could guess exactly when to call it and what each argument means, including units, formats, or valid ranges not obvious from the type hint alone.
Several narrow tools, in most cases. A tool with a mode or action flag hides several behaviors behind one schema, which makes it harder for the model to pick correctly and harder for you to test each behavior in isolation.
@mcp.tool()
def withdraw(account_id: str, amount: float) -> str:
"""Withdraw funds from an account."""
if amount <= 0:
raise ValueError("amount must be positive")
return f"Withdrew {amount} from {account_id}"Raising a standard exception with a clear message is enough; the SDK converts it into a tool error the client can show to the model.
Nothing stops a handler from calling a plain Python function that another tool also calls, but tools should not call each other through the MCP protocol layer itself. Share logic through ordinary function composition instead.
Concurrent calls can race against shared state, producing inconsistent results or corrupted data. It is fine for a demo or prototype, but production tools should use a real datastore with proper transactional guarantees.
By default yes, FastMCP uses the function name as the tool name. Keep it stable once any client has started relying on it, since a rename changes what the client sees during discovery.
There's no hard limit, but if a tool needs many optional parameters to cover different use cases, that is often a sign it should be split into more than one focused tool instead.
Yes. A parameter like units: str = "celsius" becomes an optional field in the generated schema, and the client can omit it entirely to fall back to the default.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the current Model Context Protocol specification. Model names, SDK versions, and the MCP spec move quickly - verify current specifics at platform.claude.com/docs and modelcontextprotocol.io before relying on them.
Reviewed by Chris St. John·Last updated Jul 16, 2026