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.