Lesson 07 Sampling & Elicitation
Theme:

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

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:

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:

Step 5 — Trigger sampling from Inspector

In the Inspector UI, call summarize with input { "id": "hello" }. Two things happen in quick succession:

  1. 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.
  2. 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

Troubleshooting

Common stumbles

You just made the user pay for tokens. Before shipping a sampling tool to other users: cap max_tokens, log the call (Lesson 10), and document the cost expectation in your README so nobody is surprised by their next invoice.