A Multi-Tool Real Server
Lesson 08 · MCP Servers · ~10 min read
From toy to canonical
By the end of Lesson 07 the PKB sample server exposed exactly three tools —
echo, search, and summarize — plus the
pkb://notes/{title} resource and the quick_capture prompt. That is
enough to demonstrate every individual primitive in the protocol, but it is not yet a server
you can plausibly use. Real personal-knowledge tools need to write, not just read; they need
to compose actions, not just expose them one at a time.
Lesson 08 fills in the missing pieces. After this lesson PKB exposes five tools:
the original echo, search, and summarize, plus a new
fetch (read one note by id), a new write (create or replace), and a
composed summarize_and_save that chains a sampling call with a mutation. That
shape — the five tools, two resources, one prompt — is the canonical sample-server
configuration used in every demo for the rest of the course. Lesson 12 ports this exact shape
to TypeScript. Lesson 09 adds authentication on top of it. The lab clients you build in
Lessons 10 and 11 talk to this same server. When you read “PKB” in any later lesson,
this is the shape we mean.
The write tool
Writing is where MCP servers stop being read-only adapters and start being agents of change.
The write tool takes a small WriteArgs Pydantic model:
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")
Three design choices to call out. First, overwrite is a separate
boolean flag, not implicit on the title collision. If a tool that writes silently replaces
whatever was there before, the LLM cannot distinguish a fresh save from a destructive overwrite
— and neither can the user reading the call log. Forcing the caller to pass
overwrite=true makes the destructive case explicit at the schema level.
Second, title has min_length=1. Empty titles are a
usability disaster: they break list rendering, collide with each other, and produce
“file not found” errors with no hint of what to look for. Reject them at the schema
boundary so the LLM gets a clear validation error and retries, instead of writing a junk row
you have to clean up later.
Third, body has max_length=10_000. That is a cost
cap, not a storage cap. The body is what later flows into summarize_and_save and
costs the user real tokens; the limit refuses pathological inputs before they hit the sampling
path. The implementation itself is dull on purpose — check the dict, raise on conflict,
assign, return the title:
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
return f"saved: {args.title}"
NOTES is an in-memory dict. Restart the server and your writes are gone. That is
intentional — this course is about the protocol, not databases — and Lesson 10
shows how to swap it for persistent storage without touching tool signatures.
The fetch tool vs the resource
PKB now exposes the same data through two surfaces: the existing
pkb://notes/{title} resource and the new fetch(id) -> str tool.
The duplication is intentional, because resources and tools are consumed by different parties.
The resource is discoverable. The client calls resources/list
on startup and may surface the list in a sidebar, pre-fetch a few, or include them as
attachments in the next user turn. The model sees them as context, not as actions to take.
Resources are how data shows up to the user; they answer the question
“what's available?”
The tool is callable. The LLM, mid-reasoning, can decide it needs note
todo right now to answer the user's question, and call fetch(id="todo")
the same way it would call any other function. The model sees tools as actions, not as
context. Tools answer the question “what should I do next?”
Different consumers, different surfaces. Real servers should share the underlying data-access
function — both registrations call into the same NOTES[id] lookup —
but expose it twice so each consumer can reach it the way it expects. The duplication is one
registration line each, not duplicated logic.
Tool composition at the server
Most of the time the right answer is to expose small, single-purpose tools and let the LLM
compose them in its reasoning trace. The user asks for a summary saved to disk; the model
calls summarize, then calls write with the result. The composition
is visible, debuggable, and re-orderable.
Sometimes, though, the right answer is a single composed tool on the server side.
summarize_and_save is the example:
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
return f"saved: {new_title}"
Three steps fused into one MCP call: sample the body via
ctx.session.create_message, save the result back to NOTES as
{id}-summary, return the new title. The user sees one consent dialog (for the
sampling step) and one tool result, not three.
When is server-side composition the right call?
- Invariant pairing. The two steps always happen together in this flow. You never want to sample without saving; you never want to save without first sampling. If the LLM is going to call them in lockstep every time anyway, fusing them is honest.
- Atomicity matters. You do not want to land in a state where the summary was generated, charged to the user's tokens, and then lost because the second tool call never happened. Wrapping both steps inside one tool function makes it the server's job to keep them together.
- Fewer round trips. Each tool call is a protocol round trip — serialize, transport, deserialize, render. Server-side composition collapses three of them into one.
When is it the wrong call? Whenever the LLM should get to choose. If the user
might want a summary without saving, or want to save under a different title, or
want to inspect the summary before persisting it — ship summarize and
write separately and let the model pick. Don't fuse what the model should be
free to take apart.
State management
PKB's NOTES: dict[str, str] is the simplest possible store: a process-local
Python dict, populated with three seed entries on import, wiped on restart. That is deliberate.
The course is about the protocol, not the persistence layer; if every lesson had to first
motivate a database choice we'd never get to talk about sampling, elicitation, or auth.
Real servers, of course, need real storage. The shapes you'll see in production:
- SQLite for a single-user local server — file-backed, no daemon, perfect for a desktop MCP server.
- PostgreSQL for multi-user remote servers — concurrent writes, proper transactions, row-level security if you need it.
- S3 (or any blob store) for the body content when it's large or binary — the metadata stays in a row, the bytes live in object storage.
- Redis as a cache in front of any of the above — for hot reads, rate limits, and idempotency keys.
The migration path is mechanical: replace the NOTES dict with a thin data-access
object that exposes get, put, delete, and
list_titles. The tool functions don't change — they keep calling
get and put on the DAO and never learn whether they're hitting a
dict, a SQLite file, or a Postgres cluster. Per-user state, where each authenticated user
sees their own slice, arrives in Lesson 09 along with authentication.
Idempotency revisited
Document idempotency explicitly in tool descriptions
Lesson 04 introduced idempotency as a tool-design principle. write is a good
case study because its idempotency depends on a flag.
write(title, body, overwrite=False)is idempotent. Re-running with the same arguments produces the same observable result: either both attempts succeed quietly (if the note didn't exist initially and you race-condition the second one against an existing identical row, you still end up with that row), or both raise"note exists". The LLM can retry on a network blip without fear.write(title, body, overwrite=True)is not idempotent. The first call writes; the second call overwrites whatever the first call wrote — and if anything mutated the row in between (another tool, another client, another user once Lesson 09 lands), the second call silently destroys that change.
The fix is documentation, not code. The tool description string is what the LLM reads when deciding whether retry is safe. State it plainly: “Idempotent only when overwrite=false”. The model will plan accordingly.
Composition pitfalls
Watch for the failure modes that server-side composition introduces
- Hidden cost. A composed tool that samples may surprise users who didn't realize the side-effect was spending tokens. The user sees one tool name and one consent dialog; the cost lives inside. Put the sampling step in the tool description explicitly so the user can grant consent with eyes open.
- Partial failure. If step 2 fails after step 1 succeeded, what state
are you in?
summarize_and_savecharged the user for the summary; if the save then fails, the tokens are gone and the user has nothing to show for it. Either wrap the whole thing in a transaction (real DBs help), or design each step so it's independently re-runnable — e.g., return the summary in the error message so the caller can retry the save without re-sampling. - Lost observability. When the composition runs server-side, the LLM doesn't see the intermediate steps. From the model's view, one tool was called and one result came back; the sampling that happened in the middle is invisible. Lesson 10 fixes this with structured logging that surfaces the intermediate work back to the caller.
Common pitfalls
Mistakes that look harmless until they bite
overwrite=Trueas the default. Tempting because it “just works” the first few times. Six months later a user loses three notes to a single accidental call from the LLM and you can't tell them why. Default toFalseand force destructive calls to opt in.- Saving structured data as the body. Storing a dict (serialized as
JSON, or worse, as
repr()) in a field typed as “note text” defeats every assumption downstream: MIME types, summarisation, search. Pick one type per field — plain text here — and serialize structured data into a separate tool with its own schema. - Forgetting that in-memory state dies on restart. The dev loop
(
mcp dev pkb/server.py) restarts on file change; every save you wrote two minutes ago is gone. Call this out in your server's README and your own mental model, and don't reach for “why isn't it persisting?” debugging until you've ruled out a restart.
References
- Pydantic v2 — Field constraints — reference for
min_length,max_length, and the rest of the validator surface used byWriteArgs. - MCP specification — tools — normative spec for
tools/list,tools/call, structured errors, and the schema fragment a Pydantic model compiles to. - FastMCP docs — tool registration patterns — covers
@mcp.tool()with Pydantic args, async tools, and theContextinjection point used bysummarize_and_save. - PKB sample-server tree — the working server at
mcp-servers/sample-server/python/you extend in this lesson's lab.