Solutions — Tools: Design, Schemas, Errors
Lesson 05 · MCP Servers · reference answers
Task 1 — Literal field on SearchArgs
Updated SearchArgs:
from typing import Literal
class SearchArgs(BaseModel):
query: str = Field(..., description="case-insensitive substring match")
limit: int = Field(10, ge=1, le=100, description="max number of hits")
mode: Literal["title", "body", "both"] = Field("both", description="where to search")
Updated search:
def search(args: SearchArgs) -> list[str]:
q = args.query.lower()
def match(k: str, v: str) -> bool:
if args.mode == "title":
return q in k.lower()
if args.mode == "body":
return q in v.lower()
return q in k.lower() or q in v.lower()
return [k for k, v in NOTES.items() if match(k, v)][: args.limit]
Why Literal instead of str: Pydantic emits
{"enum": ["title", "body", "both"]} in the generated JSON Schema, which Inspector renders
as a dropdown and which the LLM sees as a closed set of choices. Rejection of bad values happens at
the protocol boundary before your function runs.
Common mistake
Using mode: str with a docstring that lists valid values. Inspector won't show an enum,
the LLM has to guess from prose, and validation moves into your function body — exactly the situation
Pydantic models exist to avoid.
Task 2 — pkb.delete with the right error code
from mcp.types import METHOD_NOT_FOUND
@mcp.tool()
def delete(title: str) -> str:
"""Delete a note by title."""
if title not in NOTES:
raise McpError(ErrorData(
code=METHOD_NOT_FOUND,
message=f"unknown note title: {title}",
))
del NOTES[title]
return f"deleted: {title}"
In Inspector you'll see the resulting JSON-RPC error has code: -32601 and your message
string in message. The client gets enough structure to distinguish "the thing you asked
for doesn't exist" from "your arguments were malformed" (-32602) or "the server crashed"
(-32603).
Common mistake
Raising bare ValueError("unknown title"). FastMCP catches it and turns it into
INTERNAL_ERROR (-32603) — which tells the client "the server has a bug" instead of
"you passed a name that doesn't exist." The user retries the same call expecting different
behavior, and you get noisy alerts that don't actually indicate a bug.
Task 3 — Standalone pytest
mcp-servers/sample-server/python/tests/test_tools.py:
def test_search_respects_limit():
from pkb.tools import search, SearchArgs
result = search(SearchArgs(query="hello", limit=1))
assert len(result) <= 1
Run from mcp-servers/sample-server/python/:
pytest -q
Why this works: FastMCP wraps your function for protocol purposes — it registers the
callable with the server and uses Pydantic to validate incoming JSON-RPC arguments — but the original
function is still bound to the module under its original name. You can from pkb.tools import
search and call it directly. No server, no transport, no Inspector. This is the testing
story that makes "MCP tools are just typed functions" more than a slogan.
Looking ahead
Lesson 06 introduces resources and prompts — the two non-tool primitives in MCP. Resources are read-only data the model can pull on demand; prompts are reusable templates surfaced through the same protocol. Same server file, same transport — different primitives.