Lesson 07 Sampling & Elicitation
Theme:

Sampling & Elicitation

Lesson 07 · MCP Servers · ~10 min read

The inversion: server-initiated requests

Every interaction in Lessons 01 through 06 ran in one direction. The client connected to your server, called tools/list or resources/read or prompts/get, and your server answered. Even when the LLM chose to invoke a tool, the request still originated client-side — the model produced a tool_use block, the client forwarded it to the server, and the server returned a result. Sampling and elicitation flip that direction. Now the server raises its hand and asks for something: either server → client → user (elicitation, when the server needs the human to fill in a value or confirm an action) or server → client → LLM (sampling, when the server wants the model to generate text on its behalf).

Both of those flows route through the client, and that routing is the whole point. The client owns the LLM credentials — your server has no API key and no way to call Claude directly. The client also owns the user's screen, so it is the only party that can render a confirmation dialog or a form. Sampling and elicitation are what make MCP a real protocol instead of a fancy function-calling shim: the server can collaborate with the model and the user without ever holding their credentials.

Sampling: server-initiated LLM calls

The sampling entry point is ctx.session.create_message(...). The ctx object is a Context instance FastMCP injects into any async tool that declares it as a typed parameter. A minimal sampling call looks like this:

from mcp.server.fastmcp import Context
from mcp.types import SamplingMessage, TextContent

result = await ctx.session.create_message(
    messages=[SamplingMessage(
        role="user",
        content=TextContent(type="text", text="Summarize this in two sentences: ..."),
    )],
    max_tokens=200,
    temperature=0.2,
    model_preferences=None,  # let the client pick; usually the one already serving the user
)

The call leaves your server, hits the client, and the client decides what to do. In Claude Desktop or Claude Code the client typically routes the prompt to the same model that is serving the user, charges the user's token budget for it, and returns the result. The response is a CreateMessageResult object with four fields worth knowing: .role (always "assistant"), .content (a single TextContent or ImageContent block — not a list), .model (the model the client actually selected), and .stop_reason ("endTurn", "maxTokens", "stopSequence").

Inputs use SamplingMessage; the content field of each message is itself a TextContent or ImageContent, the same content-block types you already met in tool results. The model_preferences parameter lets you hint a size class ("fastest" vs "highest-quality") but the client is free to ignore it — treat it as advisory.

Elicitation: server-initiated user prompts

Elicitation is the user-facing twin. The entry point is ctx.session.elicit(message=..., requested_schema={...}). The requested_schema is a JSON Schema fragment describing the value (or values) you need from the user; the client renders that schema as a form. A typical call:

result = await ctx.session.elicit(
    message="Pick a folder to save this note in.",
    requested_schema={
        "type": "object",
        "properties": {
            "folder": {"type": "string", "enum": ["personal", "work", "ideas"]},
        },
        "required": ["folder"],
    },
)

The return is an ElicitResult with two fields. .action is one of "accept", "decline", or "cancel" — the user either filled in the form and submitted, refused this particular request, or backed out of the entire interaction. .content is the user's filled-in values as a Python dict matching your schema; on decline or cancel it is empty or absent.

Handle all three actions explicitly. Never assume accept — that crashes the moment the user clicks Cancel. The idiomatic shape is a small if/elif block returning a graceful message for each non-accept branch.

Cost and consent

Cost and consent risks of sampling

When to use sampling vs a hardcoded prompt

Sampling is the right tool when the content is user-specific but the prompt structure is reusable. "Summarise THIS note in two sentences" fits perfectly — the template is fixed, the data is dynamic. It is the wrong tool whenever the answer is computable directly: search, fetch, lookups, arithmetic, parsing. Don't sample to do something you can code.

Sampling is also wrong when a prompt template would do. If you find yourself sampling the same static prompt every call, ship it as @mcp.prompt() instead and let the user invoke it as a slash command (Lesson 06). That moves the token spend into the user's normal chat turn, makes the prompt visible, and lets the user edit it before sending — cheaper and more transparent.

When to use elicitation vs strict schemas

Elicitation is right when the tool's inputs are genuinely ambiguous and the user holds the answer the LLM cannot infer. Picking a folder out of three options. Confirming a destructive delete. Choosing among suggestions the server computed (a fuzzy-search disambiguation, a list of likely matches). In all those cases the LLM has no way to know the right answer; the user does.

Elicitation is wrong when the LLM has the information but did not pass it. If your tool takes a title argument and the model keeps omitting it, the fix is a better tool description or a stricter schema (required: ["title"]) — not an elicitation prompt that shifts the work onto the human.

Implementation patterns

Both APIs live on the FastMCP Context object, and both are async. Declare your tool as async def and add a ctx: Context parameter (the type annotation is what triggers injection; misspelling the type silently breaks it):

from mcp.server.fastmcp import FastMCP, Context

mcp = FastMCP("pkb")

@mcp.tool()
async def summarize(id: str, ctx: Context) -> str:
    """Summarise one note via sampling."""
    body = NOTES[id]
    result = await ctx.session.create_message(
        messages=[SamplingMessage(
            role="user",
            content=TextContent(type="text", text=f"Summarise: {body}"),
        )],
        max_tokens=200,
    )
    return result.content.text if isinstance(result.content, TextContent) else "(non-text)"

Wrap both calls in try/except for capability errors (client doesn't support sampling/elicitation), timeouts, and user declines. Return a typed result to the LLM in every branch — a stack trace is not a useful tool response.

Common pitfalls

Things that will bite you

  1. Recursive sampling. Tool A samples; the sampled text contains a tool call to tool B; tool B also samples. Each user prompt now triggers an unbounded fan-out. Implement a hop counter (pass it through tool arguments or stash it on ctx's lifespan state) and reject after a small N.
  2. Returning raw sampling errors to the LLM. When create_message fails — capability missing, user declined, network glitch — don't let the exception text leak into the model's view. Catch it and return a short human-readable string. The LLM will quote whatever you return back to the user.
  3. Forgetting Context injection. FastMCP injects ctx only when the parameter is typed as Context. Mistype it (Contex, ctx: object) and you silently get None at runtime, then a NoneType has no attribute session traceback on the first sampling call.

References