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
- Cost lives with the user. Every sampling call spends the user's tokens.
Always cap
max_tokens. Reject pathological cases server-side — if the note body is 50 000 characters and you would spend a fortune summarising it, refuse with a clear error instead of issuing the request. - Consent UX is the client's job, not yours. Claude Desktop and Inspector both show a "Allow this server to ask the model?" dialog the first time a sampling request happens for a given server. Don't try to bypass or pre-confirm — reaching around the consent flow breaks the user's trust model and is the kind of thing that gets servers blocklisted.
- No loops. Don't recurse: tool calls sampling, sampling response contains another tool call, that tool calls sampling again. Each hop multiplies cost and reduces user visibility. If you need multi-step reasoning, design it explicitly with a hop counter that refuses after N hops.
- Default to refusal. If the user has disabled sampling for your server,
ctx.session.create_messageraises a capability error. Your tool should catch that and return a graceful "sampling unavailable for this server" message instead of bubbling the stack trace into the LLM's reply.
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
- 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. - Returning raw sampling errors to the LLM. When
create_messagefails — 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. - Forgetting
Contextinjection. FastMCP injectsctxonly when the parameter is typed asContext. Mistype it (Contex,ctx: object) and you silently getNoneat runtime, then aNoneType has no attribute sessiontraceback on the first sampling call.
References
- MCP specification — sampling — normative spec for
sampling/createMessage, message structure, model preferences, and stop reasons. - MCP specification — elicitation — normative spec for
elicitation/create, JSON-schema payloads, and the accept / decline / cancel action set. - FastMCP docs —
Context— reference forctx.session.create_message,ctx.session.elicit, and the rest of the per-call context object. - PKB sample-server tree — the working server at
mcp-servers/sample-server/python/you extend in this lesson's lab.