← Back to Blog

MCP Elicitation: Pause a Tool Call and Ask the User

Your tool already knows what to do, it just does not know enough to do it. The folder it was told to delete turns out to have files inside. Three accounts match the name the user typed. A model can guess, and often guessing is fine, but deleting the wrong folder is not a guess you can take back.

Most MCP servers handle this by failing. The tool raises, the model reads the error, and the loop restarts with the human somewhere outside it. Elicitation is the protocol's answer: the server asks a question in the middle of a tool call, and the answer comes back into the same function.

Everything below runs on mcp 2.2.0, the current official Python SDK (it requires Python 3.10 or newer). I built the server, ran it against a real client, and pasted the actual output.

Prerequisites

  • Python 3.10+
  • pip install mcp==2.2.0
  • A client that declares elicitation support. Claude Code and Cursor do, Claude Desktop does not, so check yours before you build on this
  • About 15 minutes

Form mode and URL mode

Form mode asks for data. The server sends a JSON Schema, the client renders fields, and the answer arrives typed. That schema is deliberately narrow: a flat object with primitive properties only, meaning string, number, integer, boolean, or a single-select enum. No nesting, no arrays of objects.

URL mode asks the user to go somewhere instead. You send a URL, the user opens it in a browser, and nothing typed there passes through the client or the model. Credentials, card details, and OAuth consent screens belong in this mode.

Both modes come back with one of three actions: accept, decline, or cancel. Decline means the user said no. Cancel means they closed the dialog without deciding. Those are different, and the spec asks you to treat them differently.

Build it: a resolver, not a mid-body call

The Python SDK v2 gives you two ways to ask. The one to write new code with is a resolver, where the question hangs on a tool parameter and the framework asks when the value is genuinely needed.

# server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import (
    AcceptedElicitation,
    CancelledElicitation,
    DeclinedElicitation,
    Elicit,
    ElicitationResult,
    Resolve,
)

mcp = MCPServer("Notes")

_FOLDERS: dict[str, list[str]] = {
    "/tmp/notes/empty": [],
    "/tmp/notes/drafts": ["a.md", "b.md"],
}


class Confirm(BaseModel):
    ok: bool


async def confirm_delete(path: str) -> "Confirm | Elicit[Confirm]":
    count = len(_FOLDERS.get(path, []))
    if count == 0:
        return Confirm(ok=True)
    return Elicit(f"{path} has {count} file(s). Delete anyway?", Confirm)


@mcp.tool()
async def delete_folder(
    path: str,
    confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
    """Delete a notes folder, asking first when it is not empty."""
    match confirm:
        case AcceptedElicitation(data=Confirm(ok=True)):
            _FOLDERS.pop(path, None)
            return f"deleted {path}"
        case AcceptedElicitation():
            return "kept the folder"
        case DeclinedElicitation():
            return "declined: folder not deleted"
        case CancelledElicitation():
            return "cancelled: folder not deleted"

Three things carry the weight.

confirm_delete reads the tool's own path argument by name and decides whether a question is needed at all. An empty folder returns Confirm(ok=True) and never bothers the client. That decision lives in ordinary Python, not in the schema.

Annotating the parameter ElicitationResult[Confirm] hands the tool the whole outcome, so the match can branch on accept-with-yes, accept-with-no, decline, and cancel. If your tool only cares about the value on accept, annotate Annotated[Confirm, Resolve(confirm_delete)] instead and the call aborts on decline or cancel.

The confirm parameter never shows up in the tool's input schema. The client sends path, the resolver supplies everything else.

Run it

The client side is one callback, and passing that callback is also how a client declares it can be asked.

# client.py
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult

from server import mcp


async def handle(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    if isinstance(params, ElicitRequestURLParams):
        print(f"open this: {params.url}")
        return ElicitResult(action="accept")
    print(params.message)
    return ElicitResult(action="accept", content={"ok": True})


async def main():
    async with Client(mcp, elicitation_callback=handle) as client:
        result = await client.call_tool("delete_folder", {"path": "/tmp/notes/drafts"})
        print(result.content[0].text)

Here is the real output from three scenarios, with the client answering yes, answering no, and not being asked at all.

== non-empty folder, user says yes ==
[client] asked: /tmp/notes/drafts has 2 file(s). Delete anyway?
[client] schema: {'properties': {'ok': {'title': 'Ok', 'type': 'boolean'}}, 'required': ['ok'], 'type': 'object'}
[client] -> deleted /tmp/notes/drafts
== non-empty folder, user declines ==
[client] asked: /tmp/notes/drafts has 2 file(s). Delete anyway?
[client] -> declined: folder not deleted
== empty folder, no question should be asked ==
[client] -> deleted /tmp/notes/empty

The decline run matters most. The tool still returned a clean result, because a refusal is an outcome your code handles and reports back normally, not an error path.

The third run is the resolver earning its keep. Nothing was asked, because nothing needed asking.

When the client cannot ask

Drop the callback and the same call fails before your tool body runs:

mcp.shared.exceptions.MCPError: Client did not declare the form elicitation capability
required by resolver 'server:confirm_delete'

That is not a decline. Any elicitation you add needs an answer to "what if I cannot ask?", usually a fallback to a required argument or a clear error the model can act on.

Two more limits worth knowing before you design around this. Elicitation schemas are flat, so put a nested model inside your model and the call fails with Error executing tool collect while the server log carries the real reason, which names the $ref that is not a valid primitive schema definition. And the SDK's await ctx.elicit(...) form only works on legacy connections, which is the next section.

The 2026-07-28 transport change

Before 2026-07-28, a server asked by opening its own request back to the client in the middle of handling a tool call. That back-channel is what await ctx.elicit(...) uses, and it is why a test client for that path has to pass mode="legacy".

The 2026-07-28 spec retires the back-channel. The server returns instead of calling back: tools/call gets an InputRequiredResult carrying input_requests for what the server still needs and request_state as an opaque token. The client fulfils those requests, calls the same tool again with the answers in input_responses and the token echoed back, and the server finishes normally. Every leg is an ordinary request from client to server.

I ran both paths on 2.2.0. The ctx.elicit version booked the replacement table on a legacy connection and returned Booked a table for 2 on 2025-12-27. The resolver version gave the same behaviour without caring which transport sat underneath. That indifference is the reason to prefer resolvers in new code.

Security rules you cannot skip

These sit at MUST level in the spec, and they exist because the failure modes are reachable.

Do not use form mode for passwords, API keys, access tokens, or payment credentials. Push all of that to URL mode, where the secret stays out of the client and out of the model context.

Bind elicitation state to the user's identity rather than a session ID on its own. On a remote server, derive that identity from credentials you already verified instead of anything the client claims.

Verify that whoever opens a URL mode link is the person who triggered it. The spec walks through the attack directly: a malicious user triggers the elicitation, forwards the link to a victim on the same server, and the victim's completed authorization ends up bound to the attacker's session. That is an account takeover delivered with a helpful tone.

Never put credentials, personal data, or a pre-authenticated link inside a URL mode URL, and use HTTPS once you are off your laptop. Client side rules are just as concrete: show the full URL before opening it, never pre-fetch it, and open it somewhere the client and the model cannot read the page content. The spec names SFSafariViewController as acceptable and WKWebView as not, which tells you how strict the intent is.

When elicitation is the wrong tool

Ask up front when the value is knowable before the call. Interrupting someone mid-task costs attention, and if the answer could have been a tool argument, it should have been one. Elicitation is for what only the human holds: confirmation before something irreversible, and a choice between candidates that look equally valid to the model.

Return a plain error when the model can fix the problem itself. A bad path or an unknown ID is information the model can act on alone, so save the interruptions for the cases where a wrong guess is expensive.

Next steps

Cap how often a single tool can elicit. A loop of questions is worse than one clear failure.

Log every elicitation with the tool name and the action taken. After a week you will know which of your tools nag, and that list is your fix queue.

If your server holds credentials for a third-party API, look at URL mode as an OAuth client flow rather than asking for the key. The spec covers that pattern in detail, including where the tokens must live.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation