Lab 07 — Add a sampling-powered pkb.summarize tool
Lesson 07 · MCP Servers · ~25 min hands-on
By the end of this lab the PKB server exposes a third tool, pkb.summarize(id), callable
from Inspector and from Claude Desktop. The tool implements its work by asking the client's LLM
via the MCP sampling capability — meaning the server makes an LLM call on the client's tokens. The
first time you call it, you'll see the client's consent dialog before any sampling traffic flows.
Prereqs
- Plan B's Lesson 06 lab done — PKB already exposes
pkb.echo,pkb.search, thepkb://aboutandpkb://notes/{title}resources, and thewelcomeprompt. - Editable install still in place:
pip install -e .frommcp-servers/sample-server/python/. mcp[cli]version that shipsContext.session.create_message— sampling lives in a relatively recent SDK revision. If you installed the SDK months ago, upgrade withpip install -U "mcp[cli]".
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 the existing search() function):
from mcp.server.fastmcp import Context
from mcp.types import SamplingMessage, TextContent
async def summarize_via_sampling(note_id: str, ctx: Context) -> str:
"""Summarize a note by asking the client's LLM via sampling.
Raises McpError(INVALID_PARAMS) if note_id is unknown.
"""
if note_id not in NOTES:
raise McpError(ErrorData(
code=INVALID_PARAMS,
message=f"unknown note id: {note_id}",
))
body = NOTES[note_id]
result = await ctx.session.create_message(
messages=[SamplingMessage(
role="user",
content=TextContent(
type="text",
text=f"Summarize the following note in 2 sentences:\n\n{body}",
),
)],
max_tokens=200,
)
# result.content is a TextContent or ImageContent; we requested text.
if isinstance(result.content, TextContent):
return result.content.text
return "(sampling returned non-text content)"
What this does, piece by piece:
- New imports.
Contextis the per-request handle FastMCP injects when you declare a parameter of that type.SamplingMessageandTextContentare the typed wrappers MCP uses for the sampling request payload — the same shape you'd send to any chat-completion API, but transport-agnostic. async def. Sampling is asynchronous because it has to round-trip through the client to a real LLM and back. The tool function must be async; FastMCP awaits it for you.ctx.session.create_message(...). This is the actual sampling call. The server hands a list of messages to the client and asks it to call its LLM. The client is responsible for consent, model choice, and billing.max_tokens=200. A hard cap. The user is paying for these tokens — bound the cost. 200 tokens is plenty for a two-sentence summary.isinstance(result.content, TextContent). Sampling can return text or images depending on the model. We asked for text but defensive code returns a placeholder for the rare image case so the tool doesn't crash on an unexpected response shape.
Step 2 — Wire summarize into server.py
Open pkb/server.py. Update the imports at the top to pull in Context and the
new helper, then register a tiny async tool that delegates to it:
from mcp.server.fastmcp import FastMCP, Context
from pkb.tools import SearchArgs, search as _search, summarize_via_sampling
from pkb.resources import about as _about, get_note as _get_note
from pkb.prompts import welcome as _welcome
@mcp.tool()
async def summarize(id: str, ctx: Context) -> str:
"""Summarize one note via sampling (calls back to the client's LLM)."""
return await summarize_via_sampling(id, ctx)
The interesting line is ctx: Context. FastMCP introspects the type annotation: when it
sees Context, it doesn't include the parameter in the public tool schema (clients won't
see it as an input) — instead, it injects the per-request context object automatically. From the LLM's
point of view the tool's only input is id: str. The docstring becomes the tool
description in Inspector and Claude Desktop.
Step 3 — Reinstall and run
Both edits land inside the existing pkb/ package, so a fresh editable install is enough.
From the project root:
cd mcp-servers/sample-server/python
pip install -e .
mcp dev pkb/server.py
Inspector opens in your browser as usual. Watch the terminal for import errors — if the new tools module fails to load, FastMCP will tell you which import or syntax error tripped it up before the Inspector UI even renders.
Step 4 — Inspect the new tool
Click the Tools tab. You should now see three entries: echo,
search, and the brand-new summarize. Click summarize:
- Input schema shows a single field,
id: str. Thectxparameter is hidden because FastMCP recognised it as the context-injection sentinel. - The description is the function's docstring: "Summarize one note via sampling (calls back to the client's LLM)."
Step 5 — Trigger sampling from Inspector
In the Inspector UI, call summarize with input { "id": "hello" }. Two things
happen in quick succession:
- Inspector pops up a sampling request approval dialog. This is the client side of sampling: the server has asked the host to call its LLM, and the host requires user consent before spending tokens on the server's behalf. Inspector implements this consent step so you can develop against sampling without a live host.
- Once approved, Inspector routes the sampling request. For local Inspector use without a real LLM backend wired up, you can stub the response or use Inspector's built-in sampling mock — recent Inspector builds prompt you to paste a fake completion the first time. The tool then returns that text as its result.
The response pane shows the LLM's two-sentence summary as the tool's return value.
Step 6 — Verify in 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, type:
summarize the note titled hello
Claude inspects the available tools, sees pkb.summarize, and calls
summarize(id="hello"). Because the server is initiating sampling, Claude Desktop shows
its own consent dialog — typically worded along the lines of "the server is asking the model"
— listing the prompt and the cap (max_tokens=200). Approve it. The summary streams back
into the conversation.
Step 7 — Try an unknown id
Back in Inspector (or in Claude Desktop), call summarize with
{ "id": "does-not-exist" }. You should see a structured error response:
code: -32602 (INVALID_PARAMS)
message: unknown note id: does-not-exist
Notice what didn't happen: no sampling request, no consent dialog. The error
short-circuited inside summarize_via_sampling before ctx.session.create_message
was reached. This is intentional — the user shouldn't pay tokens for a request that was guaranteed to
fail.
What you should leave with
- PKB now exposes 3 tools:
echo(smoke test),search(local lookup), andsummarize(sampling-powered). - The third tool consumes user tokens via the client's LLM, with consent gating provided by the host. The server itself never sees an API key.
- Errors raised before the sampling call (like an unknown id) short-circuit cleanly without triggering a consent dialog.
Troubleshooting
Common stumbles
- "
ctx.sessionhas no attributecreate_message" — your installedmcp[cli]predates sampling support. Upgrade withpip install -U "mcp[cli]"and reinstall the editable package. - "Inspector doesn't show a sampling dialog" — Inspector versions vary. Recent
releases implement client-side sampling; older ones reject sampling requests outright with
METHOD_NOT_FOUND. Upgrade Inspector (it ships with the SDK), or test sampling in Claude Desktop instead. - "Sampling returns empty content" — the client may have applied a default
max_tokenslower than the prompt expected, or the model hit a stop sequence early. Inspectresult.stop_reasonin your tool and log it; bump the cap if the cause is truncation rather than your prompt.
max_tokens, log the call (Lesson 10), and document the cost expectation in
your README so nobody is surprised by their next invoice.