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:
-
Using
Optional[int]for a field that has a default. Writelimit: int = 10, notlimit: Optional[int] = None. The first says "the field has a default; absence means 10". The second says "the field is nullable, andNoneis a real value you have to handle in the body". Reach forOptionalonly whenNoneis genuinely meaningful (e.g. "no filter"); use a plain default for everything else. -
Raising bare
ValueErrorfrom a tool. FastMCP catches it and wraps it asINTERNAL_ERROR— which tells the LLM "this is a server bug, not your fault" and discourages it from fixing the arguments. RaiseMcpError(ErrorData(code=INVALID_PARAMS, ...))so the model knows the call shape was the problem and a different argument might work. -
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 astrfrom the tool function, and keep Pydantic models on the input side where they earn their keep.
References
- Pydantic v2 documentation — full reference for
BaseModel,Field, validators, and JSON Schema generation. - MCP specification — tools section — normative spec for
tools/list,tools/call, input schemas, and content block return types. - JSON-RPC 2.0 error object spec — the canonical list of error codes (
-32600through-32603) and the structure of theerrorobject.