MCP (Model Context Protocol) · lesson 9 of 13
Beginner
4Intermediate
4Advanced
5Also part of MCP (Model Context Protocol)
5Also part of: AI Agents
Elicitation, Sampling and Roots (Multi Round-Trip Requests)
Let an MCP server ask the client for input mid-call: elicitation, sampling and roots via multi round-trip requests, with scripted callbacks.
By SkillByExample · Published
When the Server Needs Something Back
Some tools can’t finish on their arguments alone. A delete should be confirmed by a person, a summariser needs a model, and a file search needs to know which folders it may read. MCP lets a server ask the client for these mid-call:
- Elicitation asks the user a question.
- Sampling asks the client’s model for a completion.
- Roots asks which directories the client allows.
Before 2026-07-28 the server sent these as requests of its own over a long-lived connection. Now the server answers the call with “I need input”, and the client calls again with the answers: a multi round-trip request (MRTR). Every example here answers with a scripted callback, so no user and no model are involved.
Asking the User to Confirm
In the Python SDK, a server asks through a resolver: a function that returns a request marker such as Elicit. The tool’s parameter is filled with the answer.
# files_server.py - a tool that asks the user before deleting
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, Resolve
mcp = MCPServer("files")
class Confirm(BaseModel):
confirm: bool
def ask_first(path: str) -> Elicit[Confirm]:
return Elicit(f"Really delete {path}?", Confirm)
@mcp.tool()
def delete_file(path: str, answer: Annotated[Confirm, Resolve(ask_first)]) -> str:
"""Delete a file after the user confirms."""
return f"deleted {path}" if answer.confirm else f"kept {path}"
if __name__ == "__main__":
mcp.run()
The client declares that it can answer questions by passing an elicitation_callback. A real host would show a dialog; this one accepts.
# elicit_client.py - answer the server's question with a scripted callback
import asyncio
from mcp import Client, StdioServerParameters, types
server = StdioServerParameters(command="python", args=["files_server.py"])
async def on_elicit(context, params: types.ElicitRequestParams) -> types.ElicitResult:
print("server asks:", params.message)
print("expects:", params.requested_schema["properties"])
return types.ElicitResult(action="accept", content={"confirm": True}) # a real host asks the user
async def main():
async with Client(server, elicitation_callback=on_elicit) as client:
tool = (await client.list_tools()).tools[0]
print("arguments the model sees:", list(tool.input_schema["properties"]))
result = await client.call_tool("delete_file", {"path": "report.pdf"})
print("result:", result.content[0].text)
asyncio.run(main())
$ python elicit_client.py
arguments the model sees: ['path']
server asks: Really delete report.pdf?
expects: {'confirm': {'title': 'Confirm', 'type': 'boolean'}}
result: deleted report.pdf
answer isn’t in the tool’s schema. A model can’t fill it in, or fake a “yes”, because the only way to fill it is through the client’s callback. The resolver received path from the call’s arguments by name, and Confirm became the requestedSchema the client was asked to fill. The schema is kept flat, with simple types only, so any host can render it as a form. call_tool returned only once the answer was in, so from the client’s side it looks like one call.
The Round Trip on the Wire
That one call_tool was two requests. Here they are by hand, against the same server.
# mrtr_by_hand.py - the same exchange as raw JSON: ask, answer, retry
import json
import subprocess
server = subprocess.Popen(["python", "files_server.py"], text=True,
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {"form": {}}}}
def call(req_id, params):
msg = {"jsonrpc": "2.0", "id": req_id, "method": "tools/call", "params": {**params, "_meta": META}}
server.stdin.write(json.dumps(msg) + "\n")
server.stdin.flush()
return json.loads(server.stdout.readline())["result"]
first = call(1, {"name": "delete_file", "arguments": {"path": "report.pdf"}})
print("1:", first["resultType"], json.dumps(first["inputRequests"])[:110] + "...")
print(" requestState:", first["requestState"][:3] + "<opaque>") # opaque; different every run
(key,) = first["inputRequests"]
second = call(2, {"name": "delete_file", "arguments": {"path": "report.pdf"},
"inputResponses": {key: {"action": "accept", "content": {"confirm": True}}},
"requestState": first["requestState"]})
print("2:", second["resultType"], second["structuredContent"])
server.stdin.close()
server.wait()
$ python mrtr_by_hand.py
1: input_required {"__main__:ask_first": {"method": "elicitation/create", "params": {"message": "Really delete report.pdf?", "mo...
requestState: v1.<opaque>
2: complete {'result': 'deleted report.pdf'}
The first tools/call didn’t run the tool. It came back with resultType: "input_required" and two things:
inputRequests: the questions, keyed by an id the server chose (__main__:ask_first, named after the resolver). Each is a completeelicitation/createrequest.requestState: an opaque string the client must send back untouched. The SDK encrypts and authenticates it, so the text changes on every run and the client can’t read or alter it.
The client answered under the same key in inputResponses and repeated the call with the requestState. The server worked out where it had stopped from that request alone, ran the tool and returned complete. It kept nothing in memory between the two requests, so on HTTP the retry could reach a different server instance. That’s the reason for the design. One catch: by default MCPServer encrypts the state with a random key made when the process starts, so only that process can read it. Servers behind a load balancer need a shared key, passed as MCPServer(..., request_state_security=RequestStateSecurity(keys=[secret])) (from mcp.server.mcpserver import RequestStateSecurity). The state also expires after 600 seconds by default.
The envelope had to declare "elicitation": {"form": {}}. Without it, the server can’t ask, as the roots section shows.
When the User Says No
A user can decline or cancel. What the tool sees depends on how the parameter is annotated.
# decline.py - the user says no: abort the call, or branch on the answer
import asyncio
from typing import Annotated
from mcp import Client, types
from mcp.server.mcpserver import DeclinedElicitation, ElicitationResult, Resolve
from files_server import Confirm, ask_first, mcp
@mcp.tool()
def archive_file(path: str, answer: Annotated[ElicitationResult[Confirm], Resolve(ask_first)]) -> str:
"""Archive a file, or leave it alone if the user declines."""
if isinstance(answer, DeclinedElicitation):
return f"left {path} where it is"
return f"archived {path}"
async def say_no(context, params):
return types.ElicitResult(action="decline")
async def main():
async with Client(mcp, elicitation_callback=say_no) as client:
for tool in ["delete_file", "archive_file"]:
r = await client.call_tool(tool, {"path": "report.pdf"})
print(tool, r.is_error, "|", r.content[0].text)
asyncio.run(main())
$ python decline.py 2>/dev/null
delete_file True | Error executing tool delete_file: Resolver for parameter 'answer' could not resolve: elicitation was decline
archive_file False | left report.pdf where it is
delete_file asked for a plain Confirm, so it can only run with an accepted answer. A decline aborted the call before the function body ran, as a tool error the model can read. archive_file asked for ElicitationResult[Confirm], the union of accepted, declined and cancelled outcomes, so it ran either way and chose what to do. Use the plain form when there’s no sensible way to continue without a yes, and the union when “no” is a normal answer.
Sampling: Borrowing the Client’s Model
Sample asks the client to run its own model. The server never needs an API key.
# sampling.py - the server borrows the client's model; here a scripted stand-in
import asyncio
from typing import Annotated
from mcp import Client, types
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Sample
mcp = MCPServer("notes")
def ask_model(text: str) -> Sample:
prompt = types.TextContent(type="text", text=f"Summarise in five words: {text}")
return Sample([types.SamplingMessage(role="user", content=prompt)], max_tokens=20)
@mcp.tool()
def summarise(text: str, reply: Annotated[types.CreateMessageResult, Resolve(ask_model)]) -> str:
return f"summary ({reply.model}): {reply.content.text}"
async def scripted_model(context, params: types.CreateMessageRequestParams):
print("client got:", params.messages[0].content.text, "| max_tokens", params.max_tokens)
return types.CreateMessageResult(role="assistant", model="scripted-v1", stop_reason="endTurn",
content=types.TextContent(type="text", text="Deploy failed; config missing."))
async def main():
async with Client(mcp, sampling_callback=scripted_model) as client:
r = await client.call_tool("summarise", {"text": "The 14:02 deploy failed because DATABASE_URL was unset."})
print(r.content[0].text)
asyncio.run(main())
$ python sampling.py
client got: Summarise in five words: The 14:02 deploy failed because DATABASE_URL was unset. | max_tokens 20
summary (scripted-v1): Deploy failed; config missing.
The server wrote the messages and a max_tokens cap; the client chose the model and returned its reply, including which model answered (scripted-v1). In a real host, scripted_model would call whatever model the user has configured, and may first show the request to the user. That puts the client in control of cost and of what leaves the machine. A server sees only the reply. The same round trip as mrtr_by_hand.py carried it, with sampling/createMessage in place of elicitation/create.
Roots, and a Missing Capability
ListRoots asks which locations the client allows. The second client here offers no roots at all.
# roots.py - ask which directories the client allows, and what if it can't say
import asyncio
from typing import Annotated
from mcp import Client, MCPError, types
from mcp.server import MCPServer
from mcp.server.mcpserver import ListRoots, Resolve
mcp = MCPServer("search")
@mcp.tool()
def search_scope(roots: Annotated[types.ListRootsResult, Resolve(lambda: ListRoots())]) -> list[str]:
"""Report which directories a search may read."""
return [str(r.uri) for r in roots.roots]
async def project_roots(context):
return types.ListRootsResult(roots=[types.Root(uri="file:///home/ada/shop", name="shop")])
async def main():
async with Client(mcp, list_roots_callback=project_roots) as client:
print((await client.call_tool("search_scope")).structured_content)
async with Client(mcp) as client: # no roots callback, so no roots capability
try:
await client.call_tool("search_scope")
except MCPError as e:
print("MCPError", e.error.code, e.error.data)
asyncio.run(main())
$ python roots.py
{'result': ['file:///home/ada/shop']}
MCPError -32021 {'requiredCapabilities': {'roots': {}}}
The first client named one root, the project folder, and the tool got it. An IDE would send the folders the user has open. Roots are the client’s advice about where to work, not a sandbox: the server should keep to them, but nothing in the protocol stops it reading elsewhere. The MCP security lesson adds the server-side checks.
The second client passed no list_roots_callback, so its envelope didn’t declare roots, and the server couldn’t ask. It answered with a protocol error, -32021, whose data names exactly which capability was missing. A host that sees this knows the call can’t work with it, which is better than a question it can never answer.
Practice
- Make
elicit_client.py’s callback returnaction="cancel". What doesdelete_filereturn? - Add a tool
delete_loggedthat asks for a reason withclass Reason(BaseModel): reason: strand includes it in the result. Answer"duplicate"from the callback. - Pass
system_prompt="Be terse."toSampleinsampling.pyand print it inscripted_model. - Give one tool two resolvers, one
Elicitand oneListRoots. Does a client need both callbacks?
Solution 1
The call fails as a tool error: Error executing tool delete_file: Resolver for parameter 'answer' could not resolve: elicitation was cancel. cancel means the user dismissed the question without choosing, while decline is an explicit no. With the ElicitationResult[Confirm] annotation you could tell them apart with CancelledElicitation.
Solution 2
class Reason(BaseModel):
reason: str
def why(path: str) -> Elicit[Reason]:
return Elicit(f"Why delete {path}?", Reason)
@mcp.tool()
def delete_logged(path: str, r: Annotated[Reason, Resolve(why)]) -> str:
return f"deleted {path} ({r.reason})"
return types.ElicitResult(action="accept", content={"reason": "duplicate"})
The result is deleted report.pdf (duplicate). The SDK validates content against Reason before the tool runs. A callback that returns {"reason": 3} gets a tool error saying the accepted content “does not match the requested schema”, and the body never sees the wrong type.
Solution 3
return Sample([types.SamplingMessage(role="user", content=prompt)], max_tokens=20,
system_prompt="Be terse.")
print("system:", params.system_prompt)
The callback prints system: Be terse.. The system prompt is a request too: the client may override it, the same way it chooses the model.
Solution 4
@mcp.tool()
def delete_logged(path: str,
r: Annotated[Reason, Resolve(why)],
roots: Annotated[types.ListRootsResult, Resolve(lambda: ListRoots())]) -> str:
return f"deleted {path} ({r.reason}) within {[str(x.uri) for x in roots.roots]}"
Yes. Both callbacks run before the body, here giving deleted a (duplicate) within ['file:///tmp']. A client missing either callback gets -32021 naming the capability it lacks.