Defining Callable Tools in an MCP Server
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.
Summary
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.
Recipe
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:
- The model needs to perform an action or computation, not just read static data.
- The operation has a small, well-typed set of inputs that map cleanly to function parameters.
- You want the same capability reachable from Claude Code, Claude Desktop, or a custom Agent SDK app without rewriting it per client.
- The result of the call should become part of the conversation, not just be cached or displayed separately.
Working Example
# 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:
- Three narrow, single-purpose tools (
create_task,complete_task,list_tasks) instead of one tool with amodeflag. - A Pydantic model (
CreateTaskInput) validating a structured input with a length constraint and a constrained set of allowed values. - A
Literaltype restrictingpriorityto exactly three valid strings, enforced before the handler body runs. - Consistent error handling: unknown ids raise
ValueErrorwith a message naming the specific problem. - Plain string returns that read naturally when the model surfaces them in a conversation.
Deep Dive
How It Works
- When the server starts,
FastMCPinspects every@mcp.tool()-decorated function and builds a JSON Schema from its type hints and default values. - The function's docstring becomes the tool's description; the client shows this to the model so it can judge when the tool is relevant.
- When a client calls
list_tools(), it receives this schema and description for every registered tool, without you writing any schema by hand. - When the model decides to call a tool, the client sends the tool name plus a JSON object of arguments; the SDK validates that object against the generated schema before invoking your function.
- If validation fails (wrong type, missing required field, value outside a
Literalset), the SDK returns a schema error to the client without ever calling your handler.
Naming and Description Rules at a Glance
| 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 |
Python Notes
# 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.Parameters & Return Values
| 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 |
Gotchas
- Vague tool names like
handleorprocess. The model has nothing to match intent against. Fix: use a specific, verb-first name such ascreate_taskorsend_email. - One tool with a
modeoractionstring 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. - Returning
Noneor 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. - Missing or thin docstrings. Without a description, the model is guessing what a tool does from its name alone. Fix: always include a one-line summary plus an
Args:block. - Skipping input validation on numeric or string ranges. A handler that trusts its inputs blindly can crash on edge cases like negative counts or empty strings. Fix: use
Literal, PydanticFieldconstraints, or explicit checks at the top of the handler. - Mutating shared global state without any guard. Two rapid tool calls against the same in-memory store can race or corrupt data. Fix: use a real datastore with transactions, or add locking, once you move past a demo.
Alternatives
| 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 |
FAQs
How does the SDK turn a Python function into a tool schema?
FastMCPreads the function's type hints to build a JSON Schema for its parameters.- It reads the docstring to populate the tool's description shown to the model.
- No manual schema authoring is required for typical scalar or Pydantic-model parameters.
Do I need Pydantic, or are plain type hints enough?
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.
What happens if the model calls a tool with the wrong argument types?
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.
Should a tool return plain text or structured data?
- Plain strings work well when the result is meant to be read directly in conversation.
- Structured content is worth returning when a client or downstream tool needs to parse the result programmatically.
How specific should a tool's docstring be?
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.
Is it better to have one flexible tool or several narrow tools?
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.
How do I signal an error from a tool handler?
@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.
Can a tool call another tool internally?
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.
What is the risk of using global mutable state inside a tool?
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.
Does the tool name have to match the Python function name?
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.
How many parameters is too many for one tool?
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.
Can default parameter values be used in a tool signature?
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.
Related
- Understanding MCP: Tools, Resources, and Prompts - the conceptual foundation for what a tool is versus a resource or prompt.
- MCP Core Concepts Basics - a minimal single-tool server and client to build on.
- Exposing Readable Data as MCP Resources - the sibling primitive for read-only data.
- MCP Tools vs Resources vs Prompts Comparison - deciding when a capability belongs as a tool at all.
- MCP Core Concepts Best Practices - a checklist for schema and naming quality.
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.