The full PKB: search, fetch, write, summarize — and how they compose.
MCP Servers Module 3
echo, search, summarize.Today: add fetch and write to finish the canonical tool set, then show composition.
| Tool | Args | Returns | Side-effects |
|---|---|---|---|
search | query, limit=10 | list[str] | none |
fetch | id | str | none |
write | title, body | str (status) | mutates NOTES |
summarize | id | str | samples (user tokens) |
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.
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).
The composition is invariant (always sample-then-save); failure of one means rollback of the other; latency matters.
The LLM should choose when to save vs not — keep them separate.
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.
write designclass 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}"
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.
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.
fetch tool (returns the same data the resource does).write tool with a Pydantic schema.summarize_and_save composed tool.overwrite=true defaults are dangerous — default to False.export.