Lesson 05 Tools: Design, Schemas, Errors
Theme:

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.