Lesson 08 A Multi-Tool Real Server
Theme:
Lesson 08 · Slide 1

Lesson 08 — A Multi-Tool Real Server

The full PKB: search, fetch, write, summarize — and how they compose.

MCP Servers Module 3

Lesson 08 · Slide 2

State of PKB after L07

Today: add fetch and write to finish the canonical tool set, then show composition.

Lesson 08 · Slide 3

The canonical PKB shape

ToolArgsReturnsSide-effects
searchquery, limit=10list[str]none
fetchidstrnone
writetitle, bodystr (status)mutates NOTES
summarizeidstrsamples (user tokens)
Lesson 08 · Slide 4

Tool composition

Real-world tools rarely stand alone. The LLM chains them: search → fetch → summarize.

But sometimes you want the server to chain them — fewer round trips, atomicity, lower cost. That's tool composition at the server.

Lesson 08 · Slide 5

Example: summarize_and_save

@mcp.tool()
async def summarize_and_save(id: str, ctx: Context) -> str:
    summary = await summarize_via_sampling(id, ctx)
    new_title = f"{id}-summary"
    NOTES[new_title] = summary
    return f"saved: {new_title}"

One LLM-visible tool call, two state changes (sample + write).

Lesson 08 · Slide 6

When server composition is right

Right

The composition is invariant (always sample-then-save); failure of one means rollback of the other; latency matters.

Wrong

The LLM should choose when to save vs not — keep them separate.

Lesson 08 · Slide 7

State management

PKB's NOTES dict is in-memory and process-local. Real servers persist state (SQLite, Postgres, filesystem, S3).

Migration sketch: swap the dict for a thin DAO exposing get / put / delete / list_titles — the tool signatures don't change.

Lesson 08 · Slide 8

write design

class WriteArgs(BaseModel):
    title: str = Field(..., min_length=1, max_length=120)
    body: str = Field(..., max_length=10_000)
    overwrite: bool = Field(False, description="allow replacing an existing note")

@mcp.tool()
def write(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}"
Lesson 08 · Slide 9

fetch design

@mcp.tool()
def fetch(id: str) -> str:
    """Fetch one note's body by id."""
    if id not in NOTES:
        raise McpError(ErrorData(code=INVALID_PARAMS,
            message=f"unknown note id: {id}"))
    return NOTES[id]

Why is fetch(id) -> str a tool when pkb://notes/{title} already exposes the same data?

Both have their place; ship both.

Lesson 08 · Slide 10

Idempotency revisited

write(title, body) without overwrite=true is idempotent: same args, same outcome (success or "note exists").

With overwrite=true it's not — repeated calls clobber. Document this in the tool description.

Lesson 08 · Slide 11

What we'll build today

Lesson 08 · Slide 12

Common gotchas

Lesson 08 · Slide 13

Recap & what's next

Coming up: Lesson 09 — authentication, secrets, and an OAuth-gated export.
1 / ?