Lab 05 — Add a proper Pydantic-typed tool
Lesson 05 · MCP Servers · ~25 min hands-on
Goal: extend PKB with pkb.search backed by a Pydantic input model; verify the schema in
Inspector; trigger one validation error to see structured errors in action.
Prereqs
- Lesson 04 lab complete — the
pkbserver is installed and itsechotool runs in MCP Inspector and Claude Desktop. - You can run
mcp dev pkb/server.pyfrommcp-servers/sample-server/python/without errors.
Step 1 — Create pkb/tools.py
Add a new module that owns tool implementations. server.py stays small and just wires
decorators. The file already exists in the repository (committed in 3094ab3) — open it
and walk through the pieces:
"""Tool implementations for PKB. Imported by server.py."""
from pydantic import BaseModel, Field
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INVALID_PARAMS
NOTES: dict[str, str] = {
"hello": "Hello note body",
"todo": "Pick up groceries",
"ideas": "Build an MCP server tutorial",
}
class SearchArgs(BaseModel):
query: str = Field(..., description="case-insensitive substring match")
limit: int = Field(10, ge=1, le=100, description="max number of hits")
def search(args: SearchArgs) -> list[str]:
"""Search note titles and bodies by case-insensitive substring."""
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]
- Imports.
BaseModelandFieldfrom Pydantic v2 give us the schema layer;McpErrorandErrorDatafrom the SDK let us raise protocol-shaped errors;INVALID_PARAMSis the JSON-RPC code-32602. NOTES. A toy in-memory store. Three notes, keyed by title, value is the body. Real PKB will swap this for SQLite or filesystem-backed storage later — for this lab a dict is enough.SearchArgs. One required field (query) and one bounded field (limitwithge=1, le=100and default 10). Thedescription=arguments end up in the JSON Schema — that is what the LLM reads when it picks arguments.search(). Plain function over the model. Lowercases the query, scans both keys and values for substring matches, slices tolimit, returns the matching titles.
Step 2 — Wire search into server.py
Open pkb/server.py and add the import plus a decorated wrapper. The decorator turns the
function into an MCP tool; the body just calls the implementation in tools.py.
from pkb.tools import SearchArgs, search as _search
@mcp.tool()
def search(args: SearchArgs) -> list[str]:
"""Search notes by full-text query."""
return _search(args)
Note the separation: the implementation lives in tools.py (testable as a plain function),
while server.py just declares the MCP-facing surface. The aliased import
(search as _search) avoids a name clash between the module-level function and the
decorated wrapper.
Step 3 — Reinstall and run
Editable installs do not pick up new module files until you reinstall. Run:
cd mcp-servers/sample-server/python
pip install -e .
mcp dev pkb/server.py
The mcp dev command boots both Inspector and your server, prints a localhost URL,
and opens the browser.
Step 4 — Inspect the schema
In Inspector, click Tools in the left rail. The search tool now appears
alongside echo. Click on it to expand. You should see an inputSchema
block with:
- An
argsproperty whose$refpoints to aSearchArgsdefinition. - Under
SearchArgs:query(string, required),limit(integer, default 10,minimum: 1,maximum: 100). - Both fields carry the descriptions you typed in
Field(...).
Compare this to the schema FastMCP emits for echo — that one is a flat
{message: string} with no constraints. Pydantic earns its keep on schemas with more than
one field.
Step 5 — Call with valid args
Still in Inspector, click Call tool on search. Enter the arguments:
{"args": {"query": "hello"}}
Hit Call. Expected response: a content block whose JSON-parsed value is
["hello"] — the matching note title. Try other queries ("groceries",
"mcp") and confirm they return the expected hits.
Step 6 — Trigger a validation error
Now send arguments that violate the schema:
{"args": {"query": "x", "limit": 999}}
Expected: a structured error response with code: -32602 (INVALID_PARAMS)
and a message that mentions the limit field — something like
"Input validation error: limit: Input should be less than or equal to 100". Notice how
the message names the offending field and explains the constraint. That is exactly what the LLM
needs to retry with a corrected value instead of giving up or guessing.
Step 7 — Confirm in Claude Desktop
Restart Claude Desktop (it picks up server changes on restart; the PKB entry in
claude_desktop_config.json from Lesson 04 already points at this server). In a fresh
conversation, ask:
search my notes for “hello”
Claude should invoke pkb.search with arguments shaped like
{args: {query: "hello"}} and surface the result in its reply. You can confirm the
tool call in Claude Desktop’s Developer → MCP logs panel.
What you should leave with
pkb.searchcallable from both Inspector and Claude Desktop.- An
inputSchemavisibly carrying Pydantic constraints and descriptions. - Validation errors that arrive at the LLM as structured JSON-RPC errors, not opaque crashes.
Troubleshooting
- Tool not appearing in Inspector or Claude Desktop. The editable install missed the new module — rerun
pip install -e .frommcp-servers/sample-server/python/and restart Claude Desktop. ImportError: cannot import name 'McpError' from 'mcp.shared.exceptions'. Upgrade withpip install -U "mcp[cli]". Recent SDK versions moved the symbol; older snapshots may not export it from that path.- Inspector shows the old schema. FastMCP caches schemas at process start. Kill the
mcp devprocess (Ctrl-C in the terminal) and restart it so the newSearchArgsmodel is picked up.