Adding custom tools to an MCP server in Python

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The quick version

A tool is just a Python function with a decorator on it. FastMCP reads the signature to build the input schema, reads the docstring to describe the tool to the model, and uses the function name as the tool's name.

from fastmcp import FastMCP

mcp = FastMCP("Calc Server")


@mcp.tool
def convert_celsius(temp_c: float) -> float:
    """Convert a temperature from Celsius to Fahrenheit."""
    return temp_c * 9 / 5 + 32

That's the whole contract: the name (convert_celsius), the schema (temp_c is a required number), and the description (the docstring) all come straight from the code. Everything else in this guide is about doing that well: describing arguments so the model fills them in correctly, validating what comes back, returning typed output, and handling errors in a way the model can actually recover from.

This guide assumes you already have a server running. If you don't, start with building an MCP server in Python with FastMCP, which covers setup and the one thing that trips everyone up first.

First, know which FastMCP you're on — and whether it's still called that

There are two libraries whose tool-authoring API differs in small ways that break copy-pasted code — and until the mcp 2.0.0 release on 2026-07-28, they even called their class the same thing. The companion guide covers this in full; here's the short version because it changes almost every snippet below.

The standalone fastmcp package (jlowin's project, docs at gofastmcp.com) is imported as from fastmcp import FastMCP. The in-SDK class ships inside the official mcp package; as of mcp 2.0.0 (the current, stable release) it's named MCPServer and imported as from mcp.server.mcpserver import MCPServer. On mcp<2 (final 1.x: 1.29.0) the same class was called FastMCP, imported as from mcp.server.fastmcp import FastMCP — that path is removed, not deprecated, in 2.0.0.

For tool authoring, three differences matter:

Standalone fastmcpIn-SDK MCPServer
Importfrom fastmcp import FastMCPfrom mcp.server.mcpserver import MCPServer
Tool decorator@mcp.tool or @mcp.tool()@mcp.tool()
ToolError importfrom fastmcp.exceptions import ToolErrorfrom mcp.server.mcpserver.exceptions import ToolError
Context importfrom fastmcp import Contextfrom mcp.server.mcpserver import Context

On mcp<2, the in-SDK column reads FastMCP throughout: import from mcp.server.fastmcp, exceptions from mcp.server.fastmcp.exceptions, Context from mcp.server.fastmcp. The decorator spelling (@mcp.tool()) is unchanged across the rename.

The standalone package accepts the decorator bare or with parentheses; both @mcp.tool and @mcp.tool() register the tool. (gofastmcp.com tools) The in-SDK examples use @mcp.tool() with parentheses, unchanged since before the rename. (python-sdk docs/migration.md) The rest of this guide uses the standalone package and flags the in-SDK spelling wherever it differs.

The rename has already shipped: mcp 2.0.0 landed stable on 2026-07-28, so a normal pip install mcp today gives you MCPServer, not FastMCP. (python-sdk v2 migration) If you're maintaining code against the older spelling, pin pip install "mcp<2"; the standalone fastmcp package keeps the FastMCP name either way, since it's a separate project untouched by the SDK rename.

Snippets in this guide target the standalone fastmcp 3.x line (pip install "fastmcp>=3,<4"; current stable is 3.4.5). A 4.0.0b1 pre-release is out as of this writing — if you've opted into pre-releases, check a snippet against your installed version before assuming it applies unchanged.

Type hints are the schema

Every parameter needs a type annotation, because the annotation is the JSON Schema the client uses to call your tool. This isn't documentation that FastMCP happens to read; it's the wire contract. A parameter with a default becomes optional, one without a default is required.

@mcp.tool
def search_products(
    query: str,
    max_results: int = 10,
    in_stock_only: bool = False,
) -> list[dict]:
    """Search the product catalog."""
    ...

That produces a schema with a required string query and two optional parameters with their defaults. FastMCP maps the common Python types the way you'd expect: int/float/str/bool become primitives, list[T]/dict[K, V] become collections, T | None marks a parameter optional, and Literal[...] or an Enum constrains the value to a fixed set. datetime, Path, and UUID are accepted and serialized as strings. (gofastmcp.com tools)

A Literal is worth reaching for whenever an argument has a small set of valid values, because it stops the model from inventing one:

from typing import Literal


@mcp.tool
def set_priority(task_id: str, level: Literal["low", "medium", "high"]) -> dict:
    """Set the priority level of a task."""
    ...

The schema now advertises exactly three allowed values, so a client that validates against it will reject "urgent" before your function ever runs.

The docstring is what the model reads

The model doesn't see your implementation. It sees the tool's name, its schema, and its description, and it decides whether to call the tool based almost entirely on that description. A vague docstring is the most common reason a tool that works in tests never gets picked in practice.

FastMCP parses Google, NumPy, and Sphinx docstring styles. The text before the Args section becomes the tool description, and each documented argument becomes that parameter's description in the schema. (gofastmcp.com tools)

@mcp.tool
def create_calendar_event(title: str, start_iso: str, duration_minutes: int = 30) -> dict:
    """Create an event on the user's primary calendar.

    Use this when the user asks to schedule, book, or add something to
    their calendar. Do not use it to check availability.

    Args:
        title: Short human-readable event title.
        start_iso: Start time as an ISO 8601 timestamp, e.g. 2026-06-25T14:00:00.
        duration_minutes: Event length in minutes. Defaults to 30.
    """
    ...

Write the description for the model, not for another developer. Say when to reach for the tool and when not to, and spell out any format the arguments expect (an ISO timestamp, a currency code, a repo slug). That "when not to" line does real work once a server has more than a handful of tools with overlapping names.

If you'd rather keep an internal docstring and expose a different description, pass name and description on the decorator; they override what's read from the function:

@mcp.tool(name="lookup_customer", description="Fetch a customer record by email address.")
def _get_customer(email: str) -> dict:
    """Internal helper; not shown to the model."""
    ...

Validating arguments with Field

Type hints get you required-versus-optional and the basic type. For real constraints (a number in a range, a string matching a pattern, a per-argument description that lives next to the parameter instead of in the docstring) use Annotated with Pydantic's Field:

from typing import Annotated
from pydantic import Field


@mcp.tool
def paginate(
    query: Annotated[str, Field(description="Full-text search query.")],
    page: Annotated[int, Field(ge=1, description="1-based page number.")] = 1,
    page_size: Annotated[int, Field(ge=1, le=100)] = 20,
) -> dict:
    """Search with pagination."""
    ...

Field supports the usual constraints: ge/gt/le/lt for numeric bounds, min_length/max_length, and pattern for strings. (gofastmcp.com tools) Those constraints land in the generated schema, so a well-behaved client can reject page=0 without a round trip, and FastMCP validates them again on the server before your code runs. A plain string in the Annotated slot (Annotated[str, "the query"]) is shorthand for a description with no other constraints.

For a request with several related fields, take a Pydantic model as a single parameter. The model's fields become a nested object in the input schema, and you get its validators for free:

from pydantic import BaseModel, Field


class EmailDraft(BaseModel):
    to: str = Field(description="Recipient email address.")
    subject: str = Field(min_length=1, description="Subject line.")
    body: str = Field(description="Plain-text body.")
    cc: list[str] = Field(default_factory=list, description="CC recipients.")


@mcp.tool
def draft_email(draft: EmailDraft) -> dict:
    """Draft an email for review (does not send)."""
    ...

Returning structured output

By default, a tool's return value is serialized to a text block. But FastMCP also emits structuredContent, a machine-readable copy of the result, whenever the return type is object-like. This is the structuredContent/outputSchema mechanism from the 2025-06-18 spec revision, and it lets a client parse your result as data instead of re-parsing prose.

Annotate the return type and FastMCP does the rest. A dict, a dataclass, or a Pydantic model produces structuredContent directly. (gofastmcp.com tools)

from pydantic import BaseModel


class WeatherReport(BaseModel):
    temperature_c: float
    condition: str
    humidity_pct: float


@mcp.tool
def get_weather(city: str) -> WeatherReport:
    """Get the current weather for a city."""
    return WeatherReport(temperature_c=22.5, condition="sunny", humidity_pct=45.0)

The WeatherReport fields become the tool's output schema, and the returned object is validated against it before it goes out. One thing to know about primitives and lists: a bare int or list[str] return gets wrapped as {"result": value} in the structured payload rather than sent raw. (gofastmcp.com tools) So -> int returning 8 produces structuredContent: {"result": 8}, not 8. Return a model or a dict when you want named fields the model can address by key.

One spec-level note: as of the 2026-07-28 revision, structuredContent is permitted to be any JSON value, not only an object — the wrapping behavior described above is what today's SDKs still do in practice, not a protocol requirement.

The in-SDK MCPServer behaves the same way and documents the compatible return types explicitly: Pydantic models, TypedDicts, dataclasses, dict[str, T], and primitives or generics wrapped in {"result": value}. Classes without annotated attributes fall back to unstructured output. (python-sdk docs/server.md, v1.29.0, migration guide)

Errors the model can react to, versus errors it can't

This is the part that separates a tool that helps the model recover from one that just fails. There are two very different situations, and they call for different code.

An expected, actionable error (a city you don't support, a record that doesn't exist, a validation the model can fix by trying again) should be surfaced to the model with a clear message. Raise ToolError. Its message is always delivered to the client, so the model reads exactly what you wrote and can adjust:

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError

mcp = FastMCP("Directory")

KNOWN = {"alice", "bob", "carol"}


@mcp.tool
def get_user(username: str) -> dict:
    """Look up a user by username."""
    key = username.strip().lower()
    if key not in KNOWN:
        raise ToolError(f"No user named {username!r}. Known users: {', '.join(sorted(KNOWN))}.")
    return {"username": key, "active": True}

An internal failure (a database timeout, a bug, anything that leaks a stack trace or an implementation detail) is a different animal. You still want the call to fail cleanly rather than crash the server, but you don't want the raw exception text handed to the model or, worse, to a user. FastMCP catches any exception a tool raises and turns it into an error response rather than letting it take the process down. Whether the message gets forwarded depends on the mask_error_details setting: any exception that isn't a ToolError has its details replaced with a generic message when you construct the server with mask_error_details=True. ToolError always passes through regardless of that setting. (gofastmcp.com tools)

mcp = FastMCP("Directory", mask_error_details=True)


@mcp.tool
def charge_card(token: str, amount_cents: int) -> dict:
    """Charge a saved payment method."""
    if amount_cents <= 0:
        # Expected and safe to show the model:
        raise ToolError("amount_cents must be positive.")
    # If the payment gateway call below raises, the model sees a generic
    # error instead of the gateway's internal exception text.
    return _gateway.charge(token, amount_cents)

The mechanism underneath both paths is the MCP isError flag. When a tool fails, the framework returns a CallToolResult with isError=True set, which is how the client tells a failed call apart from a successful one. You rarely construct that yourself, but it's why raising an exception from a tool is a normal, expected thing rather than something to guard against with a blanket try/except that swallows everything. (python-sdk docs/server.md, v1.29.0, migration guide)

The in-SDK MCPServer frames the same three options: raise ToolError for expected conditions, let unhandled exceptions be caught and converted automatically, or return a CallToolResult with is_error=True yourself for full control over the error content (on mcp<2 this attribute was spelled isError; mcp 2.0.0 renamed the model fields to snake_case for attribute access, while the wire JSON stays camelCase). Import path: from mcp.server.mcpserver.exceptions import ToolError on mcp 2.0.0 (current); on mcp<2 (final 1.x: 1.29.0) it was from mcp.server.fastmcp.exceptions import ToolError. (python-sdk docs/server.md, v1.29.0, migration guide)

For the fuller treatment of failure modes, JSON-RPC error semantics, and transport-specific behavior, see error handling in custom MCP servers. And for a pattern to test the schema and validation rules your tools advertise, see validation tests for tool inputs.

Registering more than one tool

There's no separate registration step. Each @mcp.tool on the same mcp object adds a tool, and the whole set is advertised when a client lists tools. Give each one a name and a description distinct enough that the model can tell them apart:

from fastmcp import FastMCP

mcp = FastMCP("Task Manager")


@mcp.tool
def create_task(title: str, due_iso: str | None = None) -> dict:
    """Create a new task. Use when the user wants to add a to-do item."""
    ...


@mcp.tool
def list_tasks(include_done: bool = False) -> list[dict]:
    """List existing tasks. Use to show what's already on the list."""
    ...


@mcp.tool
def complete_task(task_id: str) -> dict:
    """Mark a task as done. Use only when the user confirms it's finished."""
    ...


if __name__ == "__main__":
    mcp.run()  # stdio by default; see below for HTTP

When two tools have overlapping purposes, the "use when" and "use only when" lines in the docstrings are what keep the model from calling the wrong one. That's the highest-leverage editing you can do on a multi-tool server.

Adding Context to a tool

If a tool needs to log progress, read another resource, or reach the lifespan state your server set up at startup, add a parameter typed as Context. FastMCP injects it at call time; it never appears in the tool's input schema, so the model doesn't see it or try to fill it.

In the standalone package the Context methods are async, so the tool has to be async and you await them:

from fastmcp import FastMCP, Context

mcp = FastMCP("Indexer")


@mcp.tool
async def index_files(paths: list[str], ctx: Context) -> dict:
    """Index a batch of files, reporting progress as it goes."""
    for i, path in enumerate(paths):
        await ctx.info(f"Indexing {path}")
        # ... do the work ...
        await ctx.report_progress(progress=i + 1, total=len(paths))
    return {"indexed": len(paths)}

Context is imported from fastmcp, and ctx.info / ctx.debug / ctx.warning / ctx.error for logging plus ctx.report_progress(progress, total) are the documented methods. (gofastmcp.com tools) Prefer these over print(): on stdio, anything written to stdout corrupts the protocol stream, and the context log methods route messages to the client as structured notifications instead. The in-SDK version injects Context the same way; import it from mcp.server.mcpserver (on mcp<2 this was mcp.server.fastmcp) and reach lifespan state through ctx.request_context.lifespan_context. (python-sdk docs/server.md, v1.29.0, migration guide)

Note that MCP's Logging feature is deprecated as of the 2026-07-28 revision (SEP-2577): logging/setLevel is gone, a server must not emit notifications/message unless the client set io.modelcontextprotocol/logLevel on that request, and the spec's migration path is stderr for stdio plus OpenTelemetry for observability. The context log methods still work and still beat print() on stdio, but write to stderr as well if you need output you can count on.

Two mistakes worth naming

Type every parameter, and avoid *args/**kwargs. An untyped parameter still registers, but FastMCP falls back to a weak type: string schema, and it can't build any useful schema for variadic arguments, so the client has little or nothing to validate against. Spell each parameter out with a type to get a real schema.

# Won't produce a usable schema:
@mcp.tool
def bad(*args, **kwargs): ...

# Fine:
@mcp.tool
def good(name: str, count: int = 1) -> dict: ...

The model ignores tools it can't tell apart. If a tool registers correctly but never gets called, the description is usually the culprit before the connection is. Make the docstring say plainly what the tool does and when to use it, then confirm the client actually sees it by listing tools with the MCP Inspector or an in-memory test client.

Running it, briefly

Tool authoring is the same regardless of transport, but the run call differs between the two libraries. In the standalone package, stdio is the default and remote servers use transport="http":

if __name__ == "__main__":
    mcp.run()                              # stdio (local)
    # mcp.run(transport="http", port=8000)  # Streamable HTTP (remote)

On the in-SDK MCPServer the HTTP transport is spelled transport="streamable-http" — unchanged across the FastMCP→MCPServer rename — and for HTTP deployments you pass json_response=True to run(transport="streamable-http", json_response=True) or to streamable_http_app(json_response=True). On mcp<2 this was a FastMCP(...) constructor argument; mcp 2.0.0 moved all transport parameters off the constructor and onto run()/streamable_http_app(). (python-sdk docs/migration.md) Either way, reach for Streamable HTTP, not the older two-endpoint SSE transport, for anything networked. The companion FastMCP guide walks through transports, installing into a client, and testing in more depth.

Once your tools are live against real clients, the interesting questions become operational: which tools actually get called, what arguments come in, and where calls error out. That visibility is what AgentCat adds to production MCP servers.