Back to Fastmcp

User Elicitation

docs/servers/elicitation.mdx

4.0.0a125.3 KB
Original Source

import { VersionBadge } from '/snippets/version-badge.mdx'

<VersionBadge version="2.10.0" />

User elicitation allows MCP servers to request input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed.

Elicitation enables tools to request specific information from users mid-task:

  • Missing parameters: Ask for required information not provided initially
  • Clarification requests: Get user confirmation or choices for ambiguous scenarios
  • Progressive disclosure: Collect complex information step-by-step
  • Dynamic workflows: Adapt tool behavior based on user responses

For example, a file management tool might ask "Which directory should I create?" or a data analysis tool might request "What date range should I analyze?"

Which approach to use

Elicitation reaches the user two different ways, depending on the protocol era the connection negotiated:

  • On handshake-era connections (≤ 2025-11-25), a running tool calls ctx.elicit(). The tool pauses mid-execution, the server sends a request over the session back-channel, and the tool resumes with the answer. This is the original elicitation API and the rest of this page's first half covers it in full.
  • On the modern protocol (2026-07-28), that back-channel is gone — server-initiated requests were removed from the wire (SEP-2577), so a tool cannot issue a request mid-execution and block on the answer. Instead a tool asks for input by returning a description of what it needs; each round completes normally and the client issues a new call with the answer attached. This is the guard pattern, covered in the second half.

The era gate is strict: ctx.elicit() only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls ctx.elicit() on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on ctx.protocol_version to pick the right one. fastmcp.Client drives whichever the connection negotiated automatically.

Requesting input on handshake connections

Use the ctx.elicit() method within any tool function to request user input on a handshake-era connection. Specify the message to display and the type of response you expect.

python
from fastmcp import FastMCP, Context
from dataclasses import dataclass

mcp = FastMCP("Elicitation Server")

@dataclass
class UserInfo:
    name: str
    age: int

@mcp.tool
async def collect_user_info(ctx: Context) -> str:
    """Collect user information through interactive prompts."""
    result = await ctx.elicit(
        message="Please provide your information",
        response_type=UserInfo
    )

    if result.action == "accept":
        user = result.data
        return f"Hello {user.name}, you are {user.age} years old"
    elif result.action == "decline":
        return "Information not provided"
    else:  # cancel
        return "Operation cancelled"

The elicitation result contains an action field indicating how the user responded:

ActionDescription
acceptUser provided valid input—data is available in the data field
declineUser chose not to provide the requested information
cancelUser cancelled the entire operation

FastMCP also provides typed result classes for pattern matching:

python
from fastmcp.server.elicitation import (
    AcceptedElicitation,
    DeclinedElicitation,
    CancelledElicitation,
)

@mcp.tool
async def pattern_example(ctx: Context) -> str:
    result = await ctx.elicit("Enter your name:", response_type=str)

    match result:
        case AcceptedElicitation(data=name):
            return f"Hello {name}!"
        case DeclinedElicitation():
            return "No name provided"
        case CancelledElicitation():
            return "Operation cancelled"

Multi-Turn Elicitation

Tools can make multiple elicitation calls to gather information progressively:

python
@mcp.tool
async def plan_meeting(ctx: Context) -> str:
    """Plan a meeting by gathering details step by step."""

    title_result = await ctx.elicit("What's the meeting title?", response_type=str)
    if title_result.action != "accept":
        return "Meeting planning cancelled"

    duration_result = await ctx.elicit("Duration in minutes?", response_type=int)
    if duration_result.action != "accept":
        return "Meeting planning cancelled"

    priority_result = await ctx.elicit(
        "Is this urgent?",
        response_type=["yes", "no"]
    )
    if priority_result.action != "accept":
        return "Meeting planning cancelled"

    urgent = priority_result.data == "yes"
    return f"Meeting '{title_result.data}' for {duration_result.data} minutes (Urgent: {urgent})"

Client Requirements

Elicitation requires the client to implement an elicitation handler. If a client doesn't support elicitation, calls to ctx.elicit() will raise an error indicating that elicitation is not supported.

See Client Elicitation for details on how clients handle these requests.

Schema and Response Types

The server must send a schema to the client indicating the type of data it expects in response to the elicitation request. The MCP spec only supports a limited subset of JSON Schema types for elicitation responses—specifically JSON objects with primitive properties including string, number (or integer), boolean, and enum fields.

FastMCP makes it easy to request a broader range of types, including scalars (e.g. str) or no response at all, by automatically wrapping them in MCP-compatible object schemas.

Scalar Types

You can request simple scalar data types for basic input, such as a string, integer, or boolean. When you request a scalar type, FastMCP automatically wraps it in an object schema for MCP spec compatibility. Clients will see a schema requesting a single "value" field of the requested type. Once clients respond, the provided object is "unwrapped" and the scalar value is returned directly in the data field.

<CodeGroup> ```python title="String" @mcp.tool async def get_user_name(ctx: Context) -> str: result = await ctx.elicit("What's your name?", response_type=str)
if result.action == "accept":
    return f"Hello, {result.data}!"
return "No name provided"
```python title="Integer"
@mcp.tool
async def pick_a_number(ctx: Context) -> str:
    result = await ctx.elicit("Pick a number!", response_type=int)

    if result.action == "accept":
        return f"You picked {result.data}"
    return "No number provided"
python
@mcp.tool
async def pick_a_boolean(ctx: Context) -> str:
    result = await ctx.elicit("True or false?", response_type=bool)

    if result.action == "accept":
        return f"You picked {result.data}"
    return "No boolean provided"
</CodeGroup>

Customizing the Field Label

<VersionBadge version="3.3.0" />

When FastMCP wraps a scalar, Literal, Enum, or one of the constrained-option shorthands, the wrapper's value property is labelled "Value" by default — and some clients (including VS Code) render that label directly in the UI. Pass response_title and response_description to override it:

python
@mcp.tool
async def confirm_purchase(ctx: Context) -> str:
    result = await ctx.elicit(
        "Buy 1x Baguette?",
        response_type=bool,
        response_title="Confirm purchase",
        response_description="Approve this transaction?",
    )
    if result.action == "accept":
        return "Purchased" if result.data else "Declined"
    return "No response"

These arguments only apply when FastMCP is adding the wrapper. For structured responses (BaseModel, dataclass, TypedDict), set the metadata on the individual fields via Field(title=..., description=...) — passing response_title or response_description alongside a model type raises TypeError.

Empty Responses

Passing None as the response type creates an empty-object schema and returns an accepted result with data == {}. This form is deprecated because some clients render empty forms poorly; prefer an explicit response type such as bool for confirmations.

python
@mcp.tool
async def approve_action(ctx: Context) -> str:
    result = await ctx.elicit("Approve this action?", response_type=bool)

    if result.action == "accept" and result.data:
        return do_action()
    else:
        raise ValueError("Action rejected")

Constrained Options

Constrain the user's response to a specific set of values using a Literal type, Python enum, or a list of strings as a convenient shortcut.

<CodeGroup> ```python title="List of strings" @mcp.tool async def set_priority(ctx: Context) -> str: result = await ctx.elicit( "What priority level?", response_type=["low", "medium", "high"], )
if result.action == "accept":
    return f"Priority set to: {result.data}"
```python title="Literal type"
from typing import Literal

@mcp.tool
async def set_priority(ctx: Context) -> str:
    result = await ctx.elicit(
        "What priority level?",
        response_type=Literal["low", "medium", "high"]
    )

    if result.action == "accept":
        return f"Priority set to: {result.data}"
    return "No priority set"
python
from enum import Enum

class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

@mcp.tool
async def set_priority(ctx: Context) -> str:
    result = await ctx.elicit("What priority level?", response_type=Priority)

    if result.action == "accept":
        return f"Priority set to: {result.data.value}"
    return "No priority set"
</CodeGroup>

Multi-Select

<VersionBadge version="2.14.0" />

Enable multi-select by wrapping your choices in an additional list level. This allows users to select multiple values from the available options.

<CodeGroup> ```python title="List of strings" @mcp.tool async def select_tags(ctx: Context) -> str: result = await ctx.elicit( "Choose tags", response_type=[["bug", "feature", "documentation"]] # Note: list of a list )
if result.action == "accept":
    tags = result.data
    return f"Selected tags: {', '.join(tags)}"
```python title="list[Enum] type"
from enum import Enum

class Tag(Enum):
    BUG = "bug"
    FEATURE = "feature"
    DOCS = "documentation"

@mcp.tool
async def select_tags(ctx: Context) -> str:
    result = await ctx.elicit(
        "Choose tags",
        response_type=list[Tag]
    )
    if result.action == "accept":
        tags = [tag.value for tag in result.data]
        return f"Selected: {', '.join(tags)}"
</CodeGroup>

Titled Options

<VersionBadge version="2.14.0" />

For better UI display, provide human-readable titles for enum options. FastMCP generates SEP-1330 compliant schemas using the oneOf pattern with const and title fields.

python
@mcp.tool
async def set_priority(ctx: Context) -> str:
    result = await ctx.elicit(
        "What priority level?",
        response_type={
            "low": {"title": "Low Priority"},
            "medium": {"title": "Medium Priority"},
            "high": {"title": "High Priority"}
        }
    )

    if result.action == "accept":
        return f"Priority set to: {result.data}"

For multi-select with titles, wrap the dict in a list:

python
@mcp.tool
async def select_priorities(ctx: Context) -> str:
    result = await ctx.elicit(
        "Choose priorities",
        response_type=[{
            "low": {"title": "Low Priority"},
            "medium": {"title": "Medium Priority"},
            "high": {"title": "High Priority"}
        }]
    )

    if result.action == "accept":
        return f"Selected: {', '.join(result.data)}"

Structured Responses

Request structured data with multiple fields by using a dataclass, typed dict, or Pydantic model as the response type. Note that the MCP spec only supports shallow objects with scalar (string, number, boolean) or enum properties.

python
from dataclasses import dataclass
from typing import Literal

@dataclass
class TaskDetails:
    title: str
    description: str
    priority: Literal["low", "medium", "high"]
    due_date: str

@mcp.tool
async def create_task(ctx: Context) -> str:
    result = await ctx.elicit(
        "Please provide task details",
        response_type=TaskDetails
    )

    if result.action == "accept":
        task = result.data
        return f"Created task: {task.title} (Priority: {task.priority})"
    return "Task creation cancelled"

Default Values

<VersionBadge version="2.14.0" />

Provide default values for elicitation fields using Pydantic's Field(default=...). Clients will pre-populate form fields with these defaults. Fields with default values are automatically marked as optional.

python
from pydantic import BaseModel, Field
from enum import Enum

class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class TaskDetails(BaseModel):
    title: str = Field(description="Task title")
    description: str = Field(default="", description="Task description")
    priority: Priority = Field(default=Priority.MEDIUM, description="Task priority")

@mcp.tool
async def create_task(ctx: Context) -> str:
    result = await ctx.elicit("Please provide task details", response_type=TaskDetails)
    if result.action == "accept":
        return f"Created: {result.data.title}"
    return "Task creation cancelled"

Default values are supported for strings, integers, numbers, booleans, and enums.

Elicitation on the modern protocol

<VersionBadge version="4.0.0" />

The modern protocol (2026-07-28) removes the server-initiated back-channel that ctx.elicit() depends on (SEP-2577), so a running tool has no way to reach the user mid-execution. Elicitation reaches the user a different way: a tool asks for input by returning a description of what it needs. That return value completes the call normally — the result just happens to be an InputRequiredResult describing a request rather than a final answer. The client fulfils the request and issues a new tool call with the answer attached, and the tool runs again from the top, sees the answer, and either asks for the next thing or returns its final result.

Every round is a complete, independent request→response cycle: the tool holds no state between rounds, and nothing on the server stays alive waiting between them. That makes elicitation work on stateless, serverless, and load-balanced deployments where no two rounds are guaranteed to land on the same worker. A booking tool can ask for a destination, then a date, then confirm, across as many rounds as the work requires, without keeping a connection or a server-side session alive in between.

<Note> This pattern requires an MCP **2026-07-28** connection. The `InputRequiredResult` result type does not exist on earlier protocol versions; a tool that returns one on a handshake-era connection raises a clear error (see [Protocol requirements](#protocol-requirements)). On those connections, use [`ctx.elicit()`](#requesting-input-on-handshake-connections) instead. </Note>

How it works

A tool that asks for input this way is a guard: each round it re-runs from the top, checks whether the answers it needs are present, and either asks for more or proceeds. Each of those rounds is an ordinary tool call that runs the full request path — middleware chain included — and returns a result like any other; the framework does not hold the call open between rounds. It inspects two request-scoped properties on the Context to decide what to do:

  • ctx.input_responses — the client's answers to what you asked on a previous round. It is None on the very first round (nothing has been asked yet) and a mapping of answers on later rounds.
  • ctx.request_state — a small opaque string you can carry from one round to the next. It is None on the first round and echoes back whatever you last put in InputRequiredResult.request_state.

To ask for input, return an InputRequiredResult whose input_requests map describes the requests to run — most commonly an elicitation. Each request has a key; the client's answer comes back under the same key in ctx.input_responses.

The following tool books a flight across three rounds: it asks for a destination, then asks for a date (carrying the destination forward), then confirms the booking.

python
from fastmcp import FastMCP, Context
from mcp_types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams

mcp = FastMCP("Booking Server")


def ask(key: str, message: str, field: str, request_state: str | None = None) -> InputRequiredResult:
    """Build an InputRequiredResult that elicits a single text field."""
    params = ElicitRequestFormParams(
        message=message,
        requested_schema={
            "type": "object",
            "properties": {field: {"type": "string"}},
            "required": [field],
        },
    )
    elicitation = ElicitRequest(method="elicitation/create", params=params)
    return InputRequiredResult(
        result_type="input_required",
        input_requests={key: elicitation},
        request_state=request_state,
    )


@mcp.tool
async def book_flight(ctx: Context) -> str | InputRequiredResult:
    responses = ctx.input_responses

    if responses is None:
        return ask("destination", "Where would you like to fly?", "destination")

    if "destination" in responses:
        destination = responses["destination"].content["destination"]
        return ask(
            "date",
            f"When would you like to fly to {destination}?",
            "date",
            request_state=f"dest={destination}",
        )

    destination = ctx.request_state.split("=", 1)[1]
    date = responses["date"].content["date"]
    return f"Booked a flight to {destination} on {date}"

The tool runs three times for one logical call. On the first run ctx.input_responses is None, so it asks for a destination. On the second run the destination is present, so it asks for a date and stashes the destination in request_state. On the third run the date is present, so it reads the destination back out of ctx.request_state and returns the booking. Each round is a fresh execution — the tool holds no state of its own between rounds; everything it needs travels on the request.

Reading answers

Each value in ctx.input_responses is the client's result for one request, keyed by the key you gave it. For an elicitation, that is an ElicitResult with an action and (when accepted) content:

python
@mcp.tool
async def confirm(ctx: Context) -> str:
    responses = ctx.input_responses
    if responses is None:
        return ask("ok", "Proceed?", "ok")

    answer = responses["ok"]
    if answer.action != "accept":
        return "Cancelled."
    return f"Proceeding with {answer.content['ok']}"

Always check answer.action before reading answer.content: a client may decline or cancel, in which case content is absent. A decline is a normal answer, not an error — it is delivered to your tool like any other round so you can handle it deliberately.

Driving the loop from a client

fastmcp.Client drives the whole loop automatically. Point it at a 2026-era connection (mode="auto" negotiates one) and give it an elicitation handler; it fulfils each round's requests and retries until the tool returns its final result.

python
from fastmcp import Client
from fastmcp.client.elicitation import ElicitResult


async def handler(message, response_type, params, ctx):
    if "Where" in message:
        return ElicitResult(action="accept", content=response_type(destination="Paris"))
    return ElicitResult(action="accept", content=response_type(date="2026-08-01"))


async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
    result = await client.call_tool("book_flight", {})
    print(result.data)  # "Booked a flight to Paris on 2026-08-01"

The client caps the number of rounds it will drive (input_required_max_rounds, default 10) so a misbehaving guard cannot loop forever; exceeding it raises an error rather than hanging.

Carrying state across rounds

The request_state you return is sealed by the framework before it reaches the wire and unsealed and verified before your tool runs again. Your tool only ever mints and reads plaintext — the client receives an opaque token it cannot read, and a token that has been tampered with, has expired, or was minted by a different server is rejected before your tool sees it. You never call any crypto yourself.

Because sealing is automatic, request_state is a safe place to carry a computed value forward instead of re-deriving it each round. Keep it small — it round-trips through the client on every leg.

Multi-replica deployments

By default each server process seals under a per-process ephemeral key. That is correct for single-process deployments (stdio, one HTTP worker), but it means state minted by one process is rejected by another — so a horizontally scaled deployment, where consecutive rounds may land on different replicas, needs a shared key.

Give every replica the same key (or key ring) via request_state_security:

python
import os
from fastmcp import FastMCP
from mcp.server.request_state import RequestStateSecurity

mcp = FastMCP(
    "Booking Server",
    request_state_security=RequestStateSecurity(keys=[os.environ["REQUEST_STATE_KEY"].encode()]),
)

Keys must be at least 32 bytes of secret randomness. keys is a rotation ring: keys[0] seals, and every key in the ring can unseal, so you can rotate without downtime by rolling keys=[old, new]keys=[new, old]keys=[new] across deployments. Generate a key with:

bash
python -c "import secrets; print(secrets.token_hex(32))"

Protocol requirements

The InputRequiredResult result type is part of MCP 2026-07-28 and does not exist on earlier protocol versions. If a tool returns one on a handshake-era (≤ 2025-11-25) connection, FastMCP rejects the call with a clear error naming the era mismatch rather than letting it fail as a generic invalid result:

Tool 'book_flight' returned an InputRequiredResult to request client input, but
the multi-round-trip result type (SEP-2322) only exists at MCP 2026-07-28; this
connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input
on handshake-era connections.

If you need to support both eras, branch on ctx.protocol_version: return an InputRequiredResult on modern connections and fall back to ctx.elicit() on handshake-era ones.

Sampling and roots

Elicitation is the most common request to carry this way, and roots requests work identically — the input_requests map holds them the same way, and each answer comes back in ctx.input_responses under its key (an ElicitResult or ListRootsResult). See Client Roots for what a roots request contains. fastmcp.Client answers both from the handlers you already configured, so a guard tool that mixes them needs no extra client wiring.

The map can structurally hold a sampling request too (its answer would be a CreateMessageResult), but SEP-2577 deprecated server-initiated sampling on the modern protocol, so reach for a direct server-side LLM call instead of routing generation through a guard round. See Sampling.

Middleware

Because each round is a complete request→response cycle, a multi-round tool call runs the full middleware chain on every round. on_call_tool fires once per round and call_next(context) returns that round's result like any other call — there is no held-open call and no special control flow to account for. Default middleware behaves sensibly with no changes: logging logs each round, timing times each round, and error-handling middleware does not fire on an asking round — an ask is a legitimate result, not an error.

An asking round returns an InputRequiredToolResult (a ToolResult subclass); the final round returns an ordinary ToolResult. Middleware that needs to treat the two differently identifies an ask with an isinstance check, and tells an initial round from a continuation round by inspecting ctx.input_responses (None on the first round, present once the client has answered):

python
from fastmcp.server.middleware import Middleware
from fastmcp.tools import InputRequiredToolResult


class GuardAwareMiddleware(Middleware):
    async def on_call_tool(self, context, call_next):
        ctx = context.fastmcp_context
        # Either signal marks a continuation: a state-only round carries
        # request_state with no answers, so it retries with input_responses=None.
        is_continuation = (
            ctx.input_responses is not None or ctx.request_state is not None
        )

        result = await call_next(context)

        if isinstance(result, InputRequiredToolResult):
            ...  # this round asked the client for input
        else:
            ...  # this round returned a final result

        return result

One built-in makes a deliberate exception: the response caching middleware never stores an InputRequiredToolResult, because caching an ask would replay a stale question to a later caller.