Some tools can’t finish without the person at the keyboard: a confirmation, a choice, a missing value, a sign-in to another service. Elicitation is how a server asks them through the host.
This is part 15 of my series on what a Model Context Protocol (MCP) server does under the 2026-07-28 specification, after sealing requestState in part 14. It builds on the Multi Round-Trip Requests (MRTR) of part 13. The facts are as I read them in October 2026.
In brief #
- Elicitation asks the user, not the model. It travels only in
inputRequests, in an MRTR result or a task waiting for input, never unsolicited. - Form mode is for small structured values; URL mode, which sends the user to a web address (URL), is for anything sensitive. Passwords and card numbers then never pass through the AI application.
- The retry is the completion signal.
elicitationIdand the completion notification are gone; a server that has to match a browser flow to the retry keeps its own identifier inrequestState. - A server MUST NOT send an elicitation the request didn’t declare support for. If the operation can’t proceed without it, the specification requires the error
-32021. - My advice: design a path that works with no one there. Give the model an argument to supply, or fall back to a safe default.
Two modes: a form or a link #
An elicitation travels in the inputRequests of an MRTR result, or in a task’s input_required state (Tasks get a part of their own later). It comes in two modes:
| Mode | What the user sees | Use it for |
|---|---|---|
| Form | A dialog rendered by the host from a JSON schema the server supplies | Confirmations, choices, small structured values |
| URL | A prompt to open a link in the browser | Out-of-band flows: third-party sign-in, consent screens, payments, anything sensitive |
Form mode
The server describes the fields in a requestedSchema. Hosts render it as a form, so the schema is limited to a flat object with primitive properties (strings, numbers, integers, booleans and enumerations), not arbitrary nested JSON Schema:
{
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Which environment should I deploy to?",
"requestedSchema": {
"type": "object",
"properties": {
"environment": { "type": "string", "enum": ["dev", "staging", "prod"] },
"notify": { "type": "boolean", "description": "Post in #deployments" }
},
"required": ["environment"]
}
}
}
URL mode
The server gives a URL and a message. The host asks the user whether to open it, and the interaction happens in the browser, outside the client, so passwords and card numbers never pass through the AI application.
The answer
The retry carries the user’s answer in inputResponses, under the key the server used in inputRequests, with one of three actions:
action |
Meaning | content |
|---|---|---|
accept |
The user submitted (form) or agreed (URL) | The form’s values, in form mode |
decline |
The user explicitly said no | None |
cancel |
The user dismissed the prompt without choosing | None |
Handle all three: decline and cancel are normal outcomes, not errors. Inside MRTR, a form looks like this:
Signing in to another service with a link #
A typical use is a tool that needs the user’s account at a third-party service. With no stored grant for this user, the server sends a sign-in link; the service’s OAuth callback then reaches the server directly:
In 2025-11-25, a server signalled the end of a URL flow with an elicitationId and the notifications/elicitation/complete notification; 2026-07-28 removes both. The retry of the original request is the completion signal. If the user hasn’t finished yet, the server simply returns input_required again. To match the browser flow to the retry, the server keeps its own identifier in requestState, the state part 14 seals.
In Python
With the official Python software development kit (SDK), mcp 2.3.0, URL mode has no helper that works on 2026-07-28 connections: Elicit, the resolver’s request that part 13 uses, builds forms only. So the tool returns an InputRequiredResult itself, with an ElicitRequestURLParams for the link and the flow id as its requestState:
import secrets
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.types import ElicitRequest, ElicitRequestURLParams, InputRequiredResult
mcp = MCPServer("billing")
grants: dict[str, str] = {}
flows: dict[str, str] = {} # flow id -> the user who started that sign-in
@mcp.tool()
async def list_invoices(ctx: Context) -> str | InputRequiredResult:
"""List the open invoices in the user's billing account."""
user = "user-123" # stub: a real server takes the user from the access token
answer = (ctx.input_responses or {}).get("login")
if answer is not None and answer.action != "accept":
return "The billing account isn't connected, so no invoices were read."
if user in grants:
return "2 open invoices." # a real tool calls the billing API with the grant
require_url_elicitation(ctx) # shown below: -32021 unless the request declared URL mode
flow_id = ctx.request_state or secrets.token_urlsafe(16) # the same flow on a retry
flows[flow_id] = user
link = ElicitRequestURLParams(
message="Open this link to connect your billing account.",
url=f"https://billing.example.com/connect?flow={flow_id}",
)
return InputRequiredResult(input_requests={"login": ElicitRequest(params=link)}, request_state=flow_id)
MCPServer seals the state on the way out and opens it on the retry, so the tool gets its flow id back in ctx.request_state and the link stays the same while the user signs in. Leave elicitation_id unset: the SDK keeps the field for older clients and documents it as removed at 2026-07-28. With allow_input_required=True, the in-memory Client hands back the first round instead of running the retry loop itself, so you can see what the server sent:
import json
import anyio
from mcp import Client
from mcp.types import ElicitResult
async def on_elicit(ctx, params): # passing a callback declares elicitation (form and url)
return ElicitResult(action="accept")
async def main():
async with Client(mcp, elicitation_callback=on_elicit) as client:
first = await client.session.call_tool("list_invoices", {}, allow_input_required=True)
shown = first.model_dump(by_alias=True, exclude_none=True, exclude={"meta"})
shown["requestState"] = shown["requestState"][:12] + "..." # sealed by the SDK
print(json.dumps(shown, indent=2))
grants["user-123"] = "grant" # stand-in: the OAuth callback has saved the grant
retry = await client.session.call_tool(
"list_invoices", {},
input_responses={"login": ElicitResult(action="accept")},
request_state=first.request_state,
allow_input_required=True,
)
print(retry.result_type, retry.content[0].text)
if __name__ == "__main__":
anyio.run(main)
It printed:
{
"resultType": "input_required",
"inputRequests": {
"login": {
"method": "elicitation/create",
"params": {
"mode": "url",
"message": "Open this link to connect your billing account.",
"url": "https://billing.example.com/connect?flow=Nx6OuJMUoLLAjtrN1LWEyg"
}
}
},
"requestState": "v1.LF5yUJ7c6..."
}
complete 2 open invoices.
A retry before the sign-in finished got the same link back, and decline and cancel each got a complete result.
Two traps if you also serve older clients. mcp 2.3.0 still has ctx.elicit() and ctx.elicit_url(), the push-style calls of older examples: they work for clients on earlier versions such as 2025-11-25, but on a 2026-07-28 connection they raise NoBackChannelError. A hand-built InputRequiredResult fails the other way: on a 2025-11-25 connection the SDK answers -32603, so check ctx.protocol_version if you serve both.
When the client can’t ask #
Clients declare support per request, for example "elicitation": { "form": {}, "url": {} } in their client capabilities. A server MUST NOT include an elicitation/create in inputRequests unless the request declared support.
If your tool can’t go on without it, the basic protocol page is explicit: you MUST return a MissingRequiredClientCapabilityError (-32021) whose data.requiredCapabilities lists what’s missing, and over HTTP the status MUST be 400 Bad Request. A tool error is right only when the tool can still be useful without it, as part 13 sets out.
In Python, Elicit checks the capability for you, but a hand-built InputRequiredResult isn’t checked, so the billing tool calls this first:
from mcp import MCPError
from mcp.types import MISSING_REQUIRED_CLIENT_CAPABILITY
def require_url_elicitation(ctx: Context) -> None:
caps = ctx.client_capabilities
if not (caps and caps.elicitation and caps.elicitation.url):
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="list_invoices needs URL-mode elicitation to connect the billing account.",
data={"requiredCapabilities": {"elicitation": {"url": {}}}},
)
A client that declared no elicitation got an MCPError with code -32021 and that data.
A path with no one there
The spec sets the two rules above. My advice goes further: always design a path without elicitation. A deploy that needs an environment can return a tool execution error naming the argument (“environment is required: one of dev, staging, prod”), as part 9 describes, or fall back to a safe default. Many agentic hosts run unattended, and a tool that only works with a human present will fail there.
The billing tool above has no such path: its sign-in has to happen in the browser, through URL mode, so it answers -32021.
Trust and host support #
- Hosts must make clear which server is asking, and the user can always decline.
- Elicitations that arrive through Tasks get the same trust model and treatment as direct ones; a task isn’t a higher-trust channel.
- Don’t collect secrets through form mode; use URL mode.
- Keep forms small. For more than a handful of fields, consider an MCP App (a later part).
Host support, October 2026
| Host | Elicitation |
|---|---|
| Claude Code | Form and URL modes, both declared on 2026-07-28 connections. Dialogs appear automatically, and an Elicitation hook can answer them programmatically |
| ChatGPT | Supported. OpenAI’s docs recommend it “when the server needs structured information that was not provided in the original tool call” |
| Cursor | Form and URL modes, per a gateway vendor’s documentation |
| Others | Check current vendor docs |
Method and caveats #
- Built from my guide’s elicitation chapter and the URL-mode section of its MRTR chapter, written against the 2026-07-28 specification.
- Host facts are as of October 2026, checked against the linked pages on 9 October 2026; the Cursor row comes from a gateway vendor, not from Cursor.
- The Python samples ran as one file against
mcp2.3.0 on 9 October 2026; the flow id changes on every run. The other outcomes (the repeated link, decline and cancel, the missing capability) come from the file’s test harness in the same run; the two traps from short probes. - Where the specification is more precise than my notes (what a server returns when it can’t proceed without an undeclared elicitation), this article follows the specification.