Skip to main content
MCP (Model Context Protocol) · lesson 9 of 13
MCP (Model Context Protocol) advanced Lesson 9 of 13

Also 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

mcp model-context-protocol elicitation sampling roots python

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 complete elicitation/create request.
  • 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

  1. Make elicit_client.py’s callback return action="cancel". What does delete_file return?
  2. Add a tool delete_logged that asks for a reason with class Reason(BaseModel): reason: str and includes it in the result. Answer "duplicate" from the callback.
  3. Pass system_prompt="Be terse." to Sample in sampling.py and print it in scripted_model.
  4. Give one tool two resolvers, one Elicit and one ListRoots. 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.

Frequently Asked Questions

What is elicitation in MCP?
Elicitation lets a server ask the user for information in the middle of a tool call, such as a confirmation or a missing value. The server sends a message and a JSON Schema for the answer; the client shows a form, and returns accept with the data, decline or cancel. The tool then continues with the answer.
What is a multi round-trip request (MRTR) in MCP?
In protocol version 2026-07-28, a server that needs input from the client answers a request with resultType input_required, listing its questions in inputRequests and an opaque requestState. The client answers the questions and sends the same request again with inputResponses and the requestState. No connection-level state is needed, so it works over stateless HTTP.
What is MCP sampling?
Sampling lets a server ask the client's own language model for a completion, so the server can use a model without holding an API key. The client decides which model to run and can show the request to the user first. In tests, a sampling callback can return a fixed reply instead of calling a model.
What are roots in MCP?
Roots are the directories or URIs a client tells a server it may work in, such as the project folder open in an IDE. A server asks for them with roots/list and should limit file access to them. They're advice from the client, not an enforced sandbox.
What does MCP error -32021 mean?
-32021 means the request needs a client capability the client didn't declare, such as elicitation or roots. The error's data names the capability under requiredCapabilities. In the Python SDK a client declares a capability by passing the matching callback, for example elicitation_callback.