Lesson 08 A Multi-Tool Real Server
Theme:

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

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:

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

Troubleshooting

Common stumbles