Lesson 08 A Multi-Tool Real Server
Theme:

Solutions — A Multi-Tool Real Server

Lesson 08 · MCP Servers · reference implementations + common mistakes

Reference solutions for the three homework tasks. Each one is the smallest code that satisfies the self-check; the call-outs that follow flag the mistakes that bite almost everyone the first time round.

Solution to Task 1 — delete with elicitation

In pkb/server.py:

@mcp.tool()
async def delete(id: str, ctx: Context) -> str:
    """Delete a note (requires user confirmation via elicitation)."""
    if id not in NOTES:
        raise McpError(ErrorData(code=INVALID_PARAMS,
            message=f"unknown note id: {id}"))
    result = await ctx.session.elicit(
        message=f"Confirm deletion of '{id}'? Type 'yes' to proceed.",
        requested_schema={
            "type": "object",
            "properties": {"confirm": {"type": "string"}},
            "required": ["confirm"],
        },
    )
    if result.action != "accept" or result.content.get("confirm", "").lower() != "yes":
        return "deletion cancelled"
    del NOTES[id]
    return f"deleted: {id}"

Common mistake

Treating any accept as confirmation. Users can accept an elicitation with an unexpected payload (an empty string, the literal word "no", anything that isn't yes) — you have to inspect the value, not just the action. The conjunction result.action != "accept" or result.content.get("confirm", "").lower() != "yes" closes both holes.

Solution to Task 2 — search_and_fetch with a hard cap

Add to pkb/tools.py:

class SearchAndFetchArgs(BaseModel):
    query: str
    n: int = Field(3, ge=1, le=5)


@mcp.tool()
def search_and_fetch(args: SearchAndFetchArgs) -> dict[str, str]:
    """Search, then fetch bodies for the top N matches."""
    titles = search(SearchArgs(query=args.query, limit=args.n))
    return {t: NOTES[t] for t in titles}

(The @mcp.tool() registration belongs in pkb/server.py if you keep the strict tools-vs-server split from the lab; the helper above can live in tools.py and be imported and re-registered. Either organisation is fine for the homework.)

Common mistake

Forgetting the cap on n. A careless LLM can pass n=10000 and either DOS the server or quietly exhaust memory. The Pydantic le=5 enforces the limit at the schema level — FastMCP rejects the call before search_and_fetch ever runs. A min(n, 5) inside the function body works too, but it's the wrong layer: the rule belongs in the tool's public contract, where the LLM and the user can see it.

Solution to Task 3 — JSON-backed persistence

At the top of pkb/tools.py, replace the bare NOTES dict with load/save helpers:

import json
from pathlib import Path

NOTES_PATH = Path.home() / ".pkb-notes.json"


def _load() -> dict[str, str]:
    if NOTES_PATH.exists():
        return json.loads(NOTES_PATH.read_text())
    return {"hello": "Hello note body", "todo": "Pick up groceries",
            "ideas": "Build an MCP server tutorial"}


def _save() -> None:
    NOTES_PATH.write_text(json.dumps(NOTES, indent=2))


NOTES: dict[str, str] = _load()

Then call _save() at the end of every mutation — write_note(), summarize_and_save(), and delete() from Task 1:

def write_note(args: WriteArgs) -> str:
    if args.title in NOTES and not args.overwrite:
        raise McpError(ErrorData(code=INVALID_PARAMS,
            message=f"note exists: {args.title}; pass overwrite=true to replace"))
    NOTES[args.title] = args.body
    _save()
    return f"saved: {args.title}"


async def summarize_and_save(note_id: str, ctx: Context) -> str:
    summary = await summarize_via_sampling(note_id, ctx)
    new_title = f"{note_id}-summary"
    NOTES[new_title] = summary
    _save()
    return f"saved: {new_title}"

And in delete() from Task 1, add _save() right after del NOTES[id].

Common mistake

Writing the JSON file on every read as well as every write. It's wasteful, and on a shared filesystem it dramatically widens the window for partial-write corruption. Only call _save() after a real mutation. If you want to harden it further, write to NOTES_PATH.with_suffix(".tmp") first and then os.replace() — that gets you atomicity on POSIX. Most course labs don't need it, but it's the canonical safer-write pattern.

Closing tip: Lesson 09 adds authentication — per-user notes, OAuth-gated export, secrets management. The persistence layer you just built becomes per-user persistence, which is when the in-memory shortcut finally stops paying its way.