Lesson 05 Tools: Design, Schemas, Errors
Theme:

Tools: Design, Schemas, Errors

Lesson 05 · MCP Servers · ~10 min read

Why Pydantic for tool inputs

The decorator-only path you saw in Lesson 04 is exactly the right call for a one-argument tool like echo. The function signature is the schema; nothing extra to write. But the moment your tools grow — multiple arguments, optional fields, enumerated choices, value ranges, cross-field validation — the bare signature stops carrying enough information. You either smuggle the rules into the docstring (where the LLM might follow them and the validator definitely won't), or you promote your inputs to a real schema.

Pydantic v2 is the canonical schema layer in the official modelcontextprotocol/python-sdk. FastMCP itself is built on top of it: when you annotate a parameter with a primitive type, FastMCP asks Pydantic for the JSON Schema; when you annotate it with a BaseModel subclass, FastMCP asks Pydantic for the JSON Schema for that whole model. Either way, the JSON Schema your client sees comes from Pydantic, not from a hand-written dict. Defining a BaseModel for your tool's inputs costs you about five extra lines and buys you three things in return: a typed Python object inside the handler (no dict["key"] noise), automatic JSON Schema with descriptions and constraints surfaced to the LLM, and runtime validation that rejects malformed calls with a clear error before they ever reach your code.

The mental model: a tool is a function from one Pydantic model to one return value. Stick to that shape and FastMCP's introspection always does the right thing.

The Pydantic model anatomy

Four building blocks cover ninety per cent of what you will write. BaseModel is the base class — subclass it, declare fields with type annotations, and you have a validated, JSON-serialisable object. Field(...) is how you attach metadata to a field: a description (which becomes the field's description in JSON Schema and which the LLM reads), constraints like ge (greater-or-equal), le (less-or-equal), min_length, max_length, and a default value. The literal sentinel ... (Python's Ellipsis) marks a field as required.

Literal[...] from typing is how you express an enum cleanly: the field accepts only the listed values, the JSON Schema gets a proper enum entry, and the LLM sees the exact allowed strings. Optional[X] is shorthand for X | None — reach for it when None is a meaningful value, not as a way to say "this field has a default" (more on that pitfall later).

from typing import Literal, Optional
from pydantic import BaseModel, Field

class SearchArgs(BaseModel):
    query: str = Field(..., description="Full-text query string", min_length=1)
    limit: int = Field(10, description="Max results to return", ge=1, le=100)
    sort: Literal["recent", "relevance"] = Field(
        "relevance", description="Result ordering"
    )
    tag: Optional[str] = Field(
        None, description="If set, restrict results to notes with this tag"
    )

query is required and must be non-empty. limit defaults to 10 and is clamped to [1, 100]. sort is an enum with two values and a default. tag is genuinely optional — None means "no filter". All four fields show up in the JSON Schema with their descriptions and constraints, which is exactly what Claude reads when deciding what to pass.

Designing tool names and descriptions

Tool names are not just identifiers — they are part of the LLM's reasoning surface. The convention used across the official MCP servers is noun.verb, all lowercase, dots as separators: notes.search, notes.create, repo.list_branches. The noun groups related tools, the verb names the action, and the dot scopes the name without colliding with Python identifiers (FastMCP keeps the function name as the Python identifier and exposes a separate tool name to the protocol via the decorator argument). Lowercase + dots also reads better in JSON-RPC logs and avoids quoting surprises across shells.

The description is the single most important piece of LLM-facing text in your whole server. It is what the model reads when choosing between thirty available tools, and it is the only place it learns what your tool actually does. Be concrete. "Search notes by full-text query, returning up to limit matching note titles, optionally filtered by tag" beats "search" by a wide margin — the first one tells the model what is searched, what comes back, and which arguments influence the call. Keep it to one or two sentences and mention every meaningful argument by name.

Raising structured errors

When something goes wrong inside a tool, the LLM is the one who has to decide what to do next: retry with the same arguments, modify the arguments, ask the user for clarification, or give up. The decision is only as good as the error message you hand it. MCP errors travel over JSON-RPC, which means they carry a numeric code, a human-readable message, and an optional data payload. Use them deliberately.

The Python SDK exposes these as McpError wrapping an ErrorData object:

from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INVALID_PARAMS, INTERNAL_ERROR

raise McpError(ErrorData(
    code=INVALID_PARAMS,
    message="No note found with id=42",
    data={"note_id": 42, "hint": "Call notes.list to see valid ids"},
))

Three standard codes cover most cases. INVALID_PARAMS (-32602) is for "the call shape is valid but the values are wrong" — bad ids, contradictory filters, business-rule violations. The LLM treats this as a signal to fix the arguments and retry. INTERNAL_ERROR (-32603) is for "something broke on the server side and it is not the caller's fault" — database down, disk full, third-party API timeout. The LLM treats this as transient and may retry, or may give up depending on context. METHOD_NOT_FOUND (-32601) is emitted automatically by the framework when a tool name is unknown; you rarely raise it yourself.

The data field is yours to shape. Put structured context there — the offending id, the list of allowed values, a hint on what to try next. Both the host and the model can read it, and a well-formed data payload is the difference between an LLM that recovers cleanly and one that gets stuck in a retry loop.

Idempotency and retry semantics. A tool the client may retry should be idempotent — or it should refuse to repeat itself on retry. The client cannot tell whether a transient timeout means the tool ran and the response was lost, or that the tool never ran at all. notes.search is naturally safe to retry; notes.create is not. For write tools, either accept a client-provided idempotency key and deduplicate against it, or design the operation so a second call with the same arguments is a no-op (upsert semantics). The retry-safety contract belongs in the tool's description so the LLM knows whether to retry on its own.

Returning structured content

Tools return content blocks. Three types are defined in the spec. TextContent is the default and the one you will use ninety-nine per cent of the time — strings, JSON-stringified dicts, formatted reports. FastMCP wraps any plain-string or plain-dict return value in a TextContent automatically. ImageContent carries a base64-encoded image payload plus a MIME type; reach for it when your tool genuinely produces an image (a chart, a render, a screenshot). EmbeddedResource is a reference to a server-side resource the client can fetch separately — useful when the payload is large or when the client should be able to refresh it without rerunning the tool. Most tools return text; the other two matter for advanced use cases like data-visualisation servers, screenshot tools, or document pipelines that emit large artifacts.

Testing tools

FastMCP-decorated functions are not magic. The @mcp.tool() decorator registers a handler with the server, but it returns the original function unchanged — meaning you can still call search(SearchArgs(query="x")) directly from a test, and it will behave exactly as the protocol would invoke it. This is the right level of test for argument validation, branching, and return-shape assertions; it runs in microseconds and needs nothing but pytest.

def test_search_returns_titles():
    out = search(SearchArgs(query="meeting", limit=5))
    assert isinstance(out, dict)
    assert "results" in out
    assert len(out["results"]) <= 5

When you need to test the protocol surface itself — that the tool is discoverable through tools/list, that the JSON Schema is what you expect, that tools/call dispatches correctly — use mcp.shared.memory.create_connected_server_and_client_session (introduced in Lesson 03). It wires an in-memory client to your real server over an async stream and lets you exercise every JSON-RPC method without spawning a subprocess. Unit tests for handler logic, in-memory integration tests for protocol behaviour, and the Inspector for hands-on smoke tests — that three-tier setup catches almost every regression before it reaches Claude Desktop.

Common pitfalls:

  1. Using Optional[int] for a field that has a default. Write limit: int = 10, not limit: Optional[int] = None. The first says "the field has a default; absence means 10". The second says "the field is nullable, and None is a real value you have to handle in the body". Reach for Optional only when None is genuinely meaningful (e.g. "no filter"); use a plain default for everything else.
  2. Raising bare ValueError from a tool. FastMCP catches it and wraps it as INTERNAL_ERROR — which tells the LLM "this is a server bug, not your fault" and discourages it from fixing the arguments. Raise McpError(ErrorData(code=INVALID_PARAMS, ...)) so the model knows the call shape was the problem and a different argument might work.
  3. Returning Pydantic models directly. FastMCP can serialise them, but the LLM-facing return schema is then implicit and harder to reason about. Return a dict[str, Any] or a str from the tool function, and keep Pydantic models on the input side where they earn their keep.

References