Lesson 05 Tools: Design, Schemas, Errors
Theme:

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

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]

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:

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

Troubleshooting