Lesson 08 A Multi-Tool Real Server
Theme:

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?

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:

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.

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

Common pitfalls

Mistakes that look harmless until they bite

  1. overwrite=True as 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 to False and force destructive calls to opt in.
  2. 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.
  3. 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