Lab 08 — Build out the multi-tool PKB
Lesson 08 · MCP Servers · ~30 min hands-on
By the end of this lab the PKB server exposes fetch, write, and
summarize_and_save alongside the existing echo, search, and
summarize tools — all callable from Inspector and from Claude Desktop. The interesting
one is summarize_and_save: it composes sampling (Lesson 07) with a local mutation, so a
single tool call drives an LLM round-trip and then persists the result in the server's state.
Prereqs
- Lesson 07 lab done — PKB already exposes
echo,search, andsummarize(the sampling-powered tool); plus thepkb://aboutandpkb://notes/{title}resources and thewelcomeprompt. pkb.summarizeworks against the client's LLM (you've seen it return a real summary in Inspector or Claude Desktop at least once).- Editable install still in place:
pip install -e .frommcp-servers/sample-server/python/.
Step 1 — Extend pkb/tools.py
Open mcp-servers/sample-server/python/pkb/tools.py and append the following block at the
bottom of the file (after summarize_via_sampling() from Lesson 07):
class WriteArgs(BaseModel):
title: str = Field(..., min_length=1, max_length=120,
description="note title (must be unique unless overwrite=true)")
body: str = Field(..., max_length=10_000, description="note body")
overwrite: bool = Field(False, description="allow replacing an existing note")
def fetch_note(note_id: str) -> str:
"""Return the body of one note by id. Tool-friendly counterpart to the resource."""
if note_id not in NOTES:
raise McpError(ErrorData(
code=INVALID_PARAMS,
message=f"unknown note id: {note_id}",
))
return NOTES[note_id]
def write_note(args: WriteArgs) -> str:
"""Save a note. Refuses to overwrite unless args.overwrite is True."""
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
return f"saved: {args.title}"
async def summarize_and_save(note_id: str, ctx: Context) -> str:
"""Compose summarize + write: sample, then save as `{id}-summary`."""
summary = await summarize_via_sampling(note_id, ctx)
new_title = f"{note_id}-summary"
NOTES[new_title] = summary
return f"saved: {new_title}"
What each piece is doing:
WriteArgs. A Pydantic model that becomes the public input schema for thewritetool.min_length=1rejects empty titles before your code ever sees them;max_length=120on title and10_000on body bound the resource cost. Theoverwriteflag is the safety latch — without it, an LLM that hallucinates an existing title can't silently destroy data. The schema constraints are enforced by FastMCP at the boundary, so by the timewrite_note()runs, the values are already known-valid.fetch_note(id). Symmetric to thepkb://notes/{title}resource from Lesson 06, but exposed as a tool. Resources are pull-style; tools are call-style. Some clients use only one or the other; offering both makes PKB portable across hosts.write_note(args). Refuses to clobber an existing note unlessoverwrite=True. This is the canonical "fail-closed" pattern: the riskier operation requires an explicit opt-in, not the absence of an opt-out.summarize_and_save(note_id, ctx). The composition. It callssummarize_via_sampling()(which round-trips to the client's LLM under the consent dialog you saw in Lesson 07), then mutatesNOTESto persist the result. One tool call, two effects: a sampling round-trip and a state change. This pattern is how you turn raw LLM output into something durable that subsequent tool calls can read.
Step 2 — Wire into server.py
Open pkb/server.py. Extend the import line from Lesson 07 to also pull in the new helpers,
then add three @mcp.tool() registrations after the existing summarize tool:
from pkb.tools import SearchArgs, search as _search, summarize_via_sampling
from pkb.tools import WriteArgs, fetch_note, write_note, summarize_and_save as _summarize_and_save
@mcp.tool()
def fetch(id: str) -> str:
"""Fetch one note's body by id."""
return fetch_note(id)
@mcp.tool()
def write(args: WriteArgs) -> str:
"""Create or overwrite a note."""
return write_note(args)
@mcp.tool()
async def summarize_and_save(id: str, ctx: Context) -> str:
"""Summarize a note and save the result as `{id}-summary`."""
return await _summarize_and_save(id, ctx)
Two things worth noticing. First, write takes a single parameter of type
WriteArgs — FastMCP unpacks the Pydantic model and presents its fields
(title, body, overwrite) as the tool's public input schema,
which is what the client and LLM actually see. Second, summarize_and_save takes
ctx: Context: same trick as Lesson 07, FastMCP recognises the type and injects the
per-request context object without exposing it as a tool input.
Step 3 — Reinstall and run
Both edits land inside the existing pkb/ package, so a fresh editable install is enough:
cd mcp-servers/sample-server/python
pip install -e .
mcp dev pkb/server.py
Inspector opens in your browser. Click the Tools tab — you should now see six
entries: echo, search, summarize, fetch,
write, and summarize_and_save. If any are missing, scroll the terminal
back — FastMCP logs import failures before Inspector even loads.
Step 4 — Call fetch
In Inspector, click Tools → fetch and submit the input
{ "id": "hello" }. Expected return value:
"Hello note body"
That's the exact body the pkb://notes/hello resource serves — same data, different
surface. Clients that don't speak the resource protocol can now reach it through the tool path.
Step 5 — Call write (happy path)
Still in Inspector, click write. The form should show three fields:
title, body, and overwrite. Submit:
{ "args": { "title": "lab08", "body": "from lesson 08 lab" } }
Expected return:
"saved: lab08"
The note is now in the in-memory NOTES dict. You can verify with another call to
fetch with { "id": "lab08" } — it should return
"from lesson 08 lab".
Step 6 — Call write (conflict path)
Repeat the exact same call from Step 5. Expected: a structured error, not a silent overwrite.
code: -32602 (INVALID_PARAMS)
message: note exists: lab08; pass overwrite=true to replace
Now retry with the override flag flipped on:
{ "args": { "title": "lab08", "body": "v2", "overwrite": true } }
Expected: "saved: lab08" with no error. A follow-up fetch for
"lab08" now returns "v2". The fail-closed gate held; the explicit opt-in
bypassed it cleanly.
Step 7 — Call summarize_and_save
In Inspector, click summarize_and_save and submit { "id": "lab08" }.
Inspector will pop the same sampling-consent dialog you saw in Lesson 07 — approve it. Expected
return:
"saved: lab08-summary"
Now call fetch with { "id": "lab08-summary" } to read back the persisted
summary. That round-trip — sample, mutate, read back — is the whole point of the composition: an
LLM output became part of the server's state, addressable by a subsequent tool call exactly like any
other note.
Step 8 — End-to-end from Claude Desktop
Restart Claude Desktop. Its config from Lesson 04 still points at the PKB server, so no edits are needed. In a new chat, ask:
write a note titled "shopping" with body "milk, bread, eggs", then summarize and save it
Watch Claude inspect the tool list, pick write first, then summarize_and_save
on the just-written note. You'll see two consent dialogs — one for the write tool call,
one for the sampling request that summarize_and_save triggers. Approve both and the
final state of NOTES will contain "shopping" and
"shopping-summary". Verify with fetch.
What you should leave with
- PKB exposes 5 tools + 2 resources + 1 prompt:
echo,search,summarize,fetch,write,summarize_and_save, plus thepkb://aboutandpkb://notes/{title}resources, plus thewelcomeprompt. (Six tool registrations, butechois the smoke test — five make up the canonical surface.) - Composition demonstrated.
summarize_and_saveshows that tools can invoke other primitives — sampling here — and persist the result. This is the building block for every non-trivial agentic flow. - Persistence is still in-memory. Restarting the
pkbprocess wipes everything. That's by design for the course; Homework Task 3 walks you through swapping in a JSON file. Lesson 09 adds auth and per-user state on top.
Troubleshooting
Common stumbles
- "
writeaccepts a negative-length title" — confirmWriteArgsis correctly imported inserver.pyand that thewritetool's parameter is annotated asargs: WriteArgs(not as loosetitle/bodykwargs). Pydantic only validates when the model is instantiated via the schema, which FastMCP does only when it sees the typed annotation. - "
summarize_and_savereturns the summary but doesn't save" — check the body of the helper inpkb/tools.py: the lineNOTES[new_title] = summarymust execute before thereturn. A common edit slip is returningsummarydirectly and forgetting the mutation. - "Claude Desktop calls
writebut the call panel doesn't show the args" — some Claude Desktop versions hide complex (nested-object) tool arguments in the inline call panel. The full payload is still in the call log at~/Library/Logs/Claude/— open the most recent log and search for the tool name.