Pydantic models, structured errors, and tool ergonomics.
MCP Servers Module 2
@mcp.tool() decoratorsFastMCP supports Pydantic models for input — your tool signature can take a typed model and the schema is derived from it.
pkb.search tool we'll addfrom pydantic import BaseModel
class SearchArgs(BaseModel):
query: str
limit: int = 10
@mcp.tool()
def search(args: SearchArgs) -> list[str]:
"""Search notes by full-text query."""
...
The LLM sees a schema with query (required) and limit (optional, default 10) — derived automatically from the Pydantic model.
"42" → 42 when the field is typed int.Optional[...] or with a default.Literal["draft", "published"].noun.verb style — e.g., pkb.search, not pkb_search.Why noun.verb? It matches the convention many official servers use, and the dot acts as a namespace — pkb.search and github.search are clearly different tools, even if the verb collides.
limit exceeds 100".If the LLM picks the wrong tool, the description is almost always to blame — rewrite it before you blame the model.
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INVALID_PARAMS
@mcp.tool()
def search(args: SearchArgs) -> list[str]:
if args.limit > 100:
raise McpError(ErrorData(code=INVALID_PARAMS, message="limit must be <= 100"))
...
Structured errors travel back to the LLM as JSON-RPC errors with proper codes — the client can distinguish "bad input" from "tool failed" from "server crashed", and surface the right message to the user.
A tool the client may retry should be idempotent or the server should reject retries explicitly. The client can't tell whether a transient timeout means the tool ran or didn't.
Tools can return text, images, or resource references via TextContent, ImageContent, and EmbeddedResource. The return type doesn't have to be a string — FastMCP wraps the value in the right content block for you.
from mcp.types import TextContent
return [TextContent(type="text", text="hello")]
For most tools, returning a plain Python value (string, list, dict) is enough — FastMCP serialises it into a TextContent block automatically.
When returning structured objects (Python dicts), pick field names that are self-documenting — the LLM will use them in its reasoning. {"note_id": "hello", "matched_at": 12} reads better than {"id": "hello", "pos": 12}, and the LLM is more likely to thread the right value into its next step.
note_id over id.note_id, others should too.position over pos.pkb.search implementationfrom pydantic import BaseModel, Field
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INVALID_PARAMS
NOTES = {"hello": "Hello note body", "todo": "Pick up groceries"}
class SearchArgs(BaseModel):
query: str = Field(..., description="case-insensitive substring match")
limit: int = Field(10, ge=1, le=100)
def search(args: SearchArgs) -> list[str]:
q = args.query.lower()
hits = [k for k, v in NOTES.items() if q in v.lower() or q in k.lower()]
return hits[: args.limit]
def test_search_finds_hello():
result = search(SearchArgs(query="hello"))
assert "hello" in result
With FastMCP-decorated tools, the underlying function is still callable as a plain Python function — you can unit-test it directly, no transport, no client, no JSON-RPC envelope. The decorator wraps the function for MCP but doesn't replace it.
McpError for errors — structured JSON-RPC errors, not Python tracebacks.noun.verb names — and descriptions the LLM can actually use.