Chapter 3: Defining Tools


Tools are the most important primitive in MCP. They are how the AI model takes action: searching, writing, calling APIs, running code. This chapter covers everything about defining tools — from simple single-argument functions to complex async tools with rich return types.


The Tool Contract

From the AI model’s perspective, a tool is described by three things:

  1. Name — how the model identifies and calls the tool
  2. Description — a natural-language explanation of what the tool does (this is what the model reads to decide whether to use it)
  3. Input schema — a JSON Schema describing the expected arguments

FastMCP derives all three automatically from your Python function. The function name becomes the tool name, the docstring becomes the description, and the type annotations become the schema.


Input Types

FastMCP uses Python type hints to generate JSON Schema for your tool inputs. All standard Python types are supported:

@mcp.tool()
def search(
    query: str,
    max_results: int = 10,
    include_metadata: bool = False,
) -> list[dict]:
    """Search the knowledge base and return matching documents."""
    ...

This generates a JSON Schema with:

Parameters with default values are marked as optional in the schema.


Using Pydantic for Complex Inputs

For tools with structured or nested inputs, use Pydantic models:

from pydantic import BaseModel, Field

class EmailParams(BaseModel):
    to: str = Field(description="Recipient email address")
    subject: str = Field(description="Email subject line")
    body: str = Field(description="Email body in plain text")
    cc: list[str] = Field(default_factory=list, description="CC recipients")

@mcp.tool()
def send_email(params: EmailParams) -> str:
    """Send an email via the configured SMTP server."""
    # params.to, params.subject, etc. are fully typed
    return f"Email sent to {params.to}"

Field(description=...) adds per-field descriptions that the AI model sees in the schema, improving its ability to call the tool correctly.

Full example: code/02_tools_example.py


Return Types

Tools can return several types of content:

Plain text

@mcp.tool()
def get_status() -> str:
    """Return the current system status."""
    return "All systems operational"

Structured data

Return a dict or a list — FastMCP serializes it to JSON text:

@mcp.tool()
def list_users() -> list[dict]:
    """List all registered users."""
    return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

Multiple content blocks

For rich responses (text + images, or multiple text segments), return a list of TextContent or ImageContent objects:

from mcp.types import TextContent, ImageContent
import base64

@mcp.tool()
def get_chart(metric: str) -> list:
    """Return a chart for the given metric."""
    chart_bytes = generate_chart(metric)  # returns PNG bytes
    return [
        TextContent(type="text", text=f"Chart for {metric}:"),
        ImageContent(
            type="image",
            data=base64.b64encode(chart_bytes).decode(),
            mimeType="image/png",
        ),
    ]

Error Handling

Raise exceptions to signal errors. FastMCP catches them and returns a proper MCP error response:

@mcp.tool()
def get_record(record_id: int) -> dict:
    """Fetch a record by ID."""
    record = db.find(record_id)
    if record is None:
        raise ValueError(f"No record found with ID {record_id}")
    return record

The AI model will see the error message and can decide how to handle it (retry with a different ID, report the error to the user, etc.).

For errors that should be presented to the user as tool output (not protocol-level errors), return them as text:

@mcp.tool()
def run_query(sql: str) -> str:
    """Run a read-only SQL query."""
    try:
        results = db.execute(sql)
        return str(results)
    except Exception as e:
        return f"Query failed: {e}"

Async Tools

Any tool can be made async. Use async when the tool performs I/O:

import httpx

@mcp.tool()
async def fetch_github_issue(owner: str, repo: str, number: int) -> dict:
    """Fetch a GitHub issue by number."""
    url = f"https://api.github.com/repos/{owner}/{repo}/issues/{number}"
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        response.raise_for_status()
        return response.json()

Sync and async tools can be mixed freely in the same server.


Tool Naming and Descriptions

The model uses tool names and descriptions to decide when and how to call a tool. Good descriptions improve accuracy:

Weak description:

@mcp.tool()
def search(q: str) -> list[dict]:
    """Search."""
    ...

Better description:

@mcp.tool()
def search_knowledge_base(query: str, max_results: int = 5) -> list[dict]:
    """
    Search the internal knowledge base for documents related to the query.
    Returns a list of matching documents with title, summary, and URL.
    Use this when the user asks about internal policies, procedures, or documentation.
    """
    ...

Guidelines:


Annotating Tools With Metadata

For additional control, use @mcp.tool() with explicit metadata:

from mcp.server.fastmcp import FastMCP

@mcp.tool(name="kb_search", description="Search the knowledge base")
def search_knowledge_base(query: str) -> list[dict]:
    ...

This overrides the auto-derived name and description while keeping the schema from type hints.


Key Takeaways


← Chapter 2: Your First MCP Server Table of Contents Chapter 4: Exposing Resources →