Announcing CIMD support for MCP Client registration
Learn more

MCP Elicitation and Multi Round-Trip Requests, Explained

Srinivas Karre
Founding Engineer

A deployment tool asked the engineer to confirm before it rolled production. It worked for three weeks on one pod. The week the server scaled to three pods, confirmations started vanishing: the tool sent elicitation/create, the user clicked approve, and the approval arrived at a pod that had never heard of the request. No error. The tool just never finished.

The fix was not a sticky session. The fix was the Model Context Protocol (MCP) changing underneath it. The 2026-07-28 revision inverted elicitation: a server no longer asks the client for input, it returns a result that says input is required, and the client comes back with the answer in a brand-new request that can land anywhere. That inversion is what Multi Round-Trip Requests (MRTR) are, and it is why every elicitation tutorial written before July 2026 describes a wire format that no longer exists.

This post covers what elicitation is for, both modes, how it worked under 2025-11-25, how it works now, what to store between rounds and what never to store, and where the user's third-party credentials come from when a tool needs them mid-call.

TL;DR

  • Elicitation is mid-call input from the human, not from the model. A tool that cannot finish without an answer, a choice or a credential pauses and asks. It is not authorization, and the spec says so explicitly.
  • Two modes, one hard line between them. Form mode carries the data through the client and the model's context, so the spec forbids it for passwords, API keys, access tokens and payment credentials. URL mode sends the user out of band for exactly those cases.
  • Under 2025-11-25 the server pushed elicitation/create as its own request and signaled completion with notifications/elicitation/complete. Both the notification and elicitationId were removed in 2026-07-28, not deprecated.
  • Under 2026-07-28 the server answers the original request with resultType: "input_required" and an inputRequests map. The client gathers answers and retries the same method with inputResponses and an echoed opaque requestState. The handler runs again from the top.
  • Because the handler reruns, it must be idempotent before the input point and must keep nothing secret in requestState. The retry can arrive at a different instance; that is the feature.
  • When a tool needs a user's third-party account mid-call, URL mode is the only correct channel, and the URL is the connected account's authorization link. On retry, the handler checks the connected account's status; it does not retry the API call blind.

What Elicitation Is For

Most tools are complete on input: the model fills the arguments, the server executes. Some are not. A tool that files an expense needs the cost center the model does not know. A tool that deletes a project should require a human to type the project name. A tool that reads the user's calendar needs the user to have connected a calendar.

Elicitation is the protocol's answer: the server, mid-request, asks the client to put something in front of the user and send back what the user did. The response has three actions, accept, decline and cancel, and only accept carries content.

Two boundaries matter from the start. The spec is explicit that elicitation is not for the MCP client's own authorization to the MCP server; that is handled by the OAuth flow before any tool is called. And a form-mode accept is not authorization for a destructive action; it is a data entry event. The distinction between "the user typed yes" and "the user authorized this specific call" is the one the human-in-the-loop approval gates post is built on, and elicitation lives on the data entry side of it.

The Two Modes and the Line Between Them

Dimension
Form mode
URL mode
Added in
2025-06-18
2025-11-25 (SEP-1036)
Request fields
message, requestedSchema; mode optional, defaults to form
mode: "url", message, url; all required
Where the data goes
Through the client, into the model's context
Out of band; never through the client
Schema
Flat object of primitives (string, number, boolean, enum) with titles, defaults and descriptions
None
What accept means
The content is the user's answer
The user consented to open the URL; nothing about completion
Allowed for credentials
MUST NOT
MUST
Client capability
elicitation: { form: {} }, or bare elicitation: {} which means form only
elicitation: { url: {} } must be declared explicitly

The line is about where bytes travel. In form mode, whatever the user types becomes part of the elicitation result, which the server sees, which the model may see, which ends up in logs. That is fine for a cost center. It is disqualifying for a GitHub token. The spec does not leave this to judgment: servers MUST NOT use form mode for sensitive information and MUST use URL mode for it.

URL mode is also the only mode that supports a third-party OAuth flow, because the consent screen for Google or Slack has to happen in a browser the MCP client does not own.

A client declaring the elicitation capability must support at least one mode. A server should check which before it asks; a URL-mode request to a form-only client fails, and the correct degradation is to fail the tool with a clear message, never to downgrade the credential request to a form.

How It Worked in 2025-11-25

Under the legacy revisions, elicitation was a server-initiated request. The server, inside a tool handler, sent its own JSON-RPC request back down the open connection:

{ "jsonrpc": "2.0", "id": "srv-17", "method": "elicitation/create", "params": { "mode": "form", "message": "Confirm the project to delete.", "requestedSchema": { "type": "object", "properties": { "project_name": { "type": "string", "title": "Project name" } }, "required": ["project_name"] } } }

The client rendered the form, the user answered, and the client replied to srv-17 on the same connection. The handler, still blocked in the same process, read the answer and continued.

URL mode added an elicitationId, and the server was expected to send notifications/elicitation/complete once the out-of-band interaction finished, so the client could stop waiting. A new error code, -32042, let a tool fail with "URL elicitation required" instead of asking inline.

Every part of this assumed a connection that stayed open and a process that stayed alive. That assumption is what broke across three pods.

How It Works in 2026-07-28

MRTR (SEP-2322) removes server-initiated requests entirely. The server cannot push elicitation/create, sampling/createMessage or roots/list. It can only answer.

Round one: the server says input is required

The tool handler runs, hits the point where it needs the user, and returns a result instead of a request:

{ "jsonrpc": "2.0", "id": 41, "result": { "resultType": "input_required", "inputRequests": { "confirm_project": { "method": "elicitation/create", "params": { "mode": "form", "message": "Confirm the project to delete.", "requestedSchema": { "type": "object", "properties": { "project_name": { "type": "string", "title": "Project name" } }, "required": ["project_name"] } } } }, "requestState": "eyJzdGVwIjoiY29uZmlybSIsInByb2plY3RfaWQiOiJwcmpfOTkifQ" } }

Three fields carry the pattern. resultType is now required on every result and is either "complete" or "input_required". inputRequests is a map from a server-chosen key to a bare {method, params} request; a single result can carry several. requestState is an opaque string the client must echo back unchanged.

The connection then closes. Nothing is held open. The pod that answered can be gone by the time the user clicks.

Round two: the client retries with answers

The client renders each input request, collects the results, and sends the original method again with a new JSON-RPC id:

{ "jsonrpc": "2.0", "id": 42, "method": "tools/call", "params": { "name": "delete_project", "arguments": { "project_id": "prj_99" }, "inputResponses": { "confirm_project": { "action": "accept", "content": { "project_name": "billing-legacy" } } }, "requestState": "eyJzdGVwIjoiY29uZmlybSIsInByb2plY3RfaWQiOiJwcmpfOTkifQ", "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {}, "url": {} } } } } }

The handler runs again from the top, with the same arguments, this time finding inputResponses populated. It reads the answer and either completes or asks for the next thing.

InputRequiredResult is valid only on tools/call, resources/read and prompts/get. A client talking to an older server must treat a result with no resultType as "complete".

What was removed with it

notifications/elicitation/complete and elicitationId are gone. Not deprecated; removed, and they had existed for one revision. The completion signal is the retry. Error code -32042 is retired; a modern server must not emit it.

A Worked Handler, Both Eras

The same tool, written twice, so the inversion is visible rather than described.

Legacy (2025-11-25, official Python SDK 1.x)

from mcp.server.fastmcp import FastMCP, Context mcp = FastMCP("ops") @mcp.tool() async def delete_project(ctx: Context, project_id: str) -> str: project = await db.get_project(project_id) result = await ctx.elicit( message="Confirm the project to delete.", schema={ "type": "object", "properties": {"project_name": {"type": "string", "title": "Project name"}}, "required": ["project_name"], }, ) if result.action != "accept" or result.content["project_name"] != project.name: return "Cancelled: name did not match." await db.delete_project(project_id) return f"Deleted {project.name}."

The await ctx.elicit(...) line is where the process blocks on an open connection. If that connection belongs to a different pod on the way back, the await never resolves.

Modern (2026-07-28, official Python SDK 2.x)

from mcp.server.mcpserver import MCPServer, Context from mcp.types import InputRequiredError, ElicitRequest mcp = MCPServer("ops") @mcp.tool() async def delete_project(ctx: Context, project_id: str) -> str: project = await db.get_project(project_id) # idempotent read; runs on every round answers = ctx.input_responses # empty on round one, populated on retry if "confirm_project" not in answers: raise InputRequiredError( input_requests={ "confirm_project": ElicitRequest( mode="form", message="Confirm the project to delete.", requested_schema={ "type": "object", "properties": {"project_name": {"type": "string", "title": "Project name"}}, "required": ["project_name"], }, ) }, request_state=f"confirm:{project_id}", ) confirm = answers["confirm_project"] if confirm.action != "accept" or confirm.content["project_name"] != project.name: return "Cancelled: name did not match." await db.delete_project(project_id) return f"Deleted {project.name}."

Nothing blocks. The handler either has the answer or it does not, and it says so by returning. The SDK turns the raised error into the input_required result and parses inputResponses on the retry.

Two rules fall out of this shape:

Everything before the input point reruns. The db.get_project read happens on both rounds. Any write placed before the input check would happen twice. Reads before, writes after.

requestState is for correlation, not secrets. It travels to the client and back. Put a step marker and an identifier in it. Never put a token, a credential, or anything you would not log. If you need more state than fits in a string, mint a handle, store the state server-side keyed by that handle, and put the handle in requestState; that is also the spec's guidance for cross-call state now that sessions are gone.

requestState is also tamperable. The client echoes it, and the client is not trusted. If the handler makes any decision based on its contents (which step it is on, which project was confirmed), a client can edit it between rounds. Sign it, and verify the signature before reading it:

import hmac, hashlib, base64, json, os STATE_KEY = os.environ["MRTR_STATE_KEY"].encode() def pack_state(payload: dict) -> str: body = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() sig = hmac.new(STATE_KEY, body.encode(), hashlib.sha256).hexdigest()[:32] return f"{body}.{sig}" def unpack_state(state: str) -> dict: body, sig = state.rsplit(".", 1) expected = hmac.new(STATE_KEY, body.encode(), hashlib.sha256).hexdigest()[:32] if not hmac.compare_digest(sig, expected): raise ValueError("requestState signature mismatch") return json.loads(base64.urlsafe_b64decode(body))

Include the user identity from the inbound token inside the signed payload and compare it on the retry. A requestState minted for one user must not be replayable by another.

Multi-Step Chains

A handler can ask more than once. Each round is a complete request-response pair; the client's job is to keep retrying until it sees "complete", and the server's job is to make each round pure given its inputs.

answers = ctx.input_responses if "pick_env" not in answers: raise InputRequiredError(input_requests={"pick_env": env_enum_request()}, request_state="step:env") env = answers["pick_env"].content["environment"] if env == "production" and "confirm_prod" not in answers: raise InputRequiredError(input_requests={"confirm_prod": typed_confirmation_request()}, request_state="step:confirm") await deploy(env)

Client SDKs cap the number of rounds; the C# SDK and several community implementations default to around ten. A chain that needs more than a handful is usually a sign the tool should be split. Each round adds a human pause, and the agent framework above the client has to surface each one as an interrupt.

Declines and cancels end the chain. A decline means the user saw the request and said no; a cancel means they dismissed it. Both should produce a completed result with a clear message, not another input_required.

The Client Side: Driving the Loop From an Agent Runtime

Half the readers of this post are not writing the server. They are writing the agent runtime that calls it, which means they are the MCP client, and the retry loop is their code.

The official 2.x clients handle the mechanics: they parse input_required, dispatch each entry in inputRequests to a callback you register, collect the results, and resend. What you own is the callback, and the callback is where the human is. In an agent runtime, that means the callback cannot block a worker thread for four hours waiting for someone to click; it has to hand the request to whatever your platform already uses to pause and resume a run.

from mcp.client.session import ClientSession from mcp.types import ElicitRequestParams, ElicitResult async def elicitation_handler(params: ElicitRequestParams, context) -> ElicitResult: run_id = context.run_id # whatever your runtime uses to identify this agent run if params.mode == "url": # Hand the URL to the user through your product's UI; do not open it server-side. await approvals.create(run_id, kind="open_url", url=params.url, message=params.message) decision = await approvals.wait(run_id, timeout_s=3600) return ElicitResult(action="accept" if decision.opened else "decline") # form mode await approvals.create(run_id, kind="form", schema=params.requested_schema, message=params.message) decision = await approvals.wait(run_id, timeout_s=3600) if decision.timed_out: return ElicitResult(action="cancel") return ElicitResult(action="accept", content=decision.values) session = ClientSession( transport, elicitation_callback=elicitation_handler, client_info={"name": "platform-agent-runtime", "version": "2.4.0"}, )

Three rules for the callback, learned the hard way by every team that has shipped one:

Declare only what you can render. If your runtime cannot show a form, do not advertise form. If it cannot open a browser for the user, do not advertise url. A capability you declare and cannot honor produces a tool that hangs rather than a tool that fails cleanly.

Bound the wait. A server-side input_required has no TTL in the protocol. Your runtime decides how long a pending request lives. Return cancel on timeout so the server completes with a message, rather than leaving the tool call open forever in your checkpoint store.

Use the framework's interrupt, not a sleep. LangGraph's interrupt(), the OpenAI Agents SDK's RunState serialization, and the equivalent in whatever you run on are built for exactly this pause. The approval-gates post covers the pause-and-resume architecture; an elicitation callback is one more thing that goes through it.

If the server you are calling is a Virtual MCP server in META mode, you will not see input_required for the connect-account case at all. The execute_tool meta tool returns not_connected with a connection_name, the agent calls connect_account and gets a link back as an ordinary tool result, and your runtime surfaces that link the same way it would surface any other tool output. The pause is still yours to build; the protocol round-trip is not.

Client and SDK Support Today

Support is uneven, and a server that assumes URL mode or MRTR without checking will fail for real users.

Surface
Form
URL
MRTR
Official Python SDK 2.x client
Yes
Yes
Yes; dispatches embedded requests to a callback and retries
Official TypeScript v2 client
Yes
Yes
Yes
Official C# SDK 2.x
Yes
Yes
Yes; legacy methods throw on stateless sessions
MCP Inspector 2.x
Yes
Yes
Yes
Claude Desktop and Claude Code
Yes
Yes
Yes; tracks the 2026-07-28 revision
Cursor
Yes
Yes
Not documented as of November 2026
LangChain MCPAdapter
Yes, surfaced as a LangGraph interrupt
Via the same interrupt
Yes
Vercel AI SDK @ai-sdk/mcp
Yes
No; declares elicitation: {}
Not documented as of November 2026
OpenAI Agents SDK
No; offers require_approval per tool call instead
No
No
Anthropic Messages API mcp_servers connector
No
No
No; tool calls only

The practical rule: read io.modelcontextprotocol/clientCapabilities from the request's _meta before asking. A bare elicitation: {} means form only. No elicitation key means the client cannot answer at all, and the tool should fail with a message the model can relay rather than return input_required into the void.

The Security Model, Mode by Mode

Form mode is a data channel, not a consent channel. Validate the content against your own schema on the server, not only the client's rendering of it. Treat an accepted form as untrusted input from the user, because that is what it is. Do not treat a form-mode accept as sufficient authorization for a destructive or irreversible action; the spec community has pushed for exactly that clarification, and the queue-then-execute approval pattern exists because scope and consent are different questions.

URL mode moves the sensitive step out of the protocol. The client shows the target host and asks the user before navigating; the spec requires HTTPS; and the only thing that returns through the protocol is whether the user agreed to open the link. What happened at the link is between the user and whoever served it. The server finds out by checking state on the retry, not by trusting the elicitation result.

Both modes are events. Log every input_required with its keys, every inputResponses action, and the user identity from the inbound token. An auditor asking "who confirmed the deletion of billing-legacy" needs that row. The audit trail post covers the four event categories; elicitation rounds belong in the authorization category when they gate a write.

When the Tool Needs the User's Third-Party Account

The most common reason a tool cannot finish is not a missing form field. It is that the user never connected the account the tool needs. A summarize_inbox tool reaches the point where it has to read Gmail, and this user has no Gmail grant.

This is the case URL mode was designed for, and the shape is precise:

  1. The handler resolves the user from the inbound token and checks whether a connected account exists for the connection it needs.
  2. If not, it returns input_required with a URL-mode request whose url is that user's authorization link for that connection.
  3. The client shows the host, the user consents, the browser opens the hosted authorization page. For an OAuth connector that is the provider's consent screen; for an API key or key-pair connector it is a credential form. Either way the credential lands in the vault, never in the MCP client, never in the model's context.
  4. The user returns to the client and the client retries. The handler reruns, checks the connected account's status again, and this time proceeds.
from mcp.server.mcpserver import MCPServer, Context from mcp.types import InputRequiredError, ElicitRequest from scalekit import ScalekitClient mcp = MCPServer("inbox") actions = ScalekitClient( environment_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ).actions @mcp.tool() async def summarize_inbox(ctx: Context, max_messages: int = 20) -> dict: user_id = ctx.auth.user.id account = actions.get_connected_account( connection_name="gmail", # must match the connection name in the dashboard identifier=user_id, ).connected_account if account.status != "ACTIVE": if "connect_gmail" in ctx.input_responses: # User came back but the grant is still not active: do not loop. return {"status": "not_connected", "detail": "Gmail authorization did not complete. Ask the user to retry."} link = actions.get_authorization_link(connection_name="gmail", identifier=user_id) raise InputRequiredError( input_requests={ "connect_gmail": ElicitRequest( mode="url", message="Connect your Gmail account so the assistant can read your inbox.", url=link.link, ) }, request_state=f"connect:{user_id}", ) messages = actions.execute_tool( tool_name="gmail_fetch_emails", identifier=user_id, connection_name="gmail", tool_input={"max_results": max_messages}, ) return summarize(messages)

Three things in that handler are doing the real work.

The status check runs on every round. The handler does not trust inputResponses["connect_gmail"].action == "accept" as proof the grant exists; URL-mode accept means the user agreed to open the page, nothing more. The connected account's status (PENDING, ACTIVE, EXPIRED, REVOKED, ERROR) is the source of truth, and it is checked after the user comes back.

The guard against looping matters. If the user opened the link and closed it, the retry arrives with an accept and a still-inactive account. Returning a clear completed result is correct; raising another input_required with the same link traps the client in a cycle.

The credential never enters the handler. get_authorization_link returns a URL; execute_tool resolves the user's token from the vault at call time and makes the Gmail request. The handler's memory, the server's logs and the model's context contain user_id and a result, not a token. That is the same property URL mode exists to protect, carried through to the downstream call.

The removed completion notification has a replacement outside the protocol, too. Account events deliver a connected account's lifecycle transitions as they happen, so an agent runtime that wants to resume automatically when the grant lands can subscribe to the ACTIVE transition rather than waiting for the human to click retry. The retry is still what drives the protocol; the event is what tells your runtime it is worth sending.

When the agent reaches connector tools through a Virtual MCP server in META exposure mode, this loop is already built into the five tools the agent sees: execute_tool returns not_connected or reauth_needed naming the connection, the agent calls connect_account, surfaces the link, and retries after the user finishes. The readiness states ready, needs_reauth and needs_connection are the same status model, exposed to the agent instead of to your handler. The handler above is what that looks like when the server is yours.

Recommended Reading: Managing auth state across tool calls

What to Change If You Shipped Elicitation Before July

  • Replace every await ctx.elicit(...) with a check-then-raise at the top of the handler. The await is the bug.
  • Delete any code that sends or waits for notifications/elicitation/complete. It will never arrive.
  • Delete any use of elicitationId and error code -32042.
  • Move writes below the input point. Anything above it runs twice.
  • Audit requestState for secrets. If a token was ever in it, rotate that token.
  • Read client capabilities from _meta per request. There is no initialize to have cached them from.
  • Keep the legacy path for clients that still negotiate 2025-11-25, which the official 2.x servers do when a client sends initialize. On those sessions the old push model still works; on stateless sessions it throws.

The Shape That Survives

Elicitation did not get smaller in the rewrite. It got honest about what a server can assume: nothing beyond the request in front of it. A tool that needs the user asks by returning, keeps only a correlation handle between rounds, reruns cleanly, and checks real state when the user comes back rather than trusting that they did what the message asked.

The credential case is the one that makes the discipline worth it. The user's Gmail grant is the thing the whole protocol was redesigned to keep out of band, and the right pattern keeps it out of the handler as well: a link out, a status check back, a vault in between.

Grep your servers for ctx.elicit and for notifications/elicitation/complete; every hit is a handler that stopped working on 28 July. Then look at how users authorize a connection to see what the URL in a URL-mode request should point at.

FAQs

What is elicitation in MCP?

A way for a server, mid-request, to ask the client to put something in front of the human and return what they did. It is for data the tool needs to finish, not for authorizing the client or approving a destructive action.

What is the difference between form mode and URL mode?

Form mode carries the answer through the client and into the model's context, so it is forbidden for passwords, keys and tokens. URL mode sends the user out of band and returns only whether they agreed to open the link.

What are Multi Round-Trip Requests?

The 2026-07-28 replacement for server-initiated requests. The server answers the original call with a result that says input is required, the client gathers answers and retries the same method, and the handler runs again from the top.

Why must a handler be idempotent before the input point?

Because it reruns on every round and the retry can land on a different instance. Reads before the input check happen each time, so writes belong after it, and nothing secret belongs in the echoed request state.

Which clients support URL mode and the new round-trip model?

The official Python, TypeScript and C# 2.x clients, MCP Inspector 2.x and Claude's clients do. Several SDKs declare form mode only, and some agent runtimes support neither, so read the client's capabilities before asking.

How does a tool connect a user's Gmail or Slack mid-call?

It checks the connected account, returns a URL-mode request pointing at that user's authorization link, and on retry checks the account status again rather than trusting the accept. The credential lands in the vault, never in the handler.

No items found.
On this page
Share this article
Scalekit
Find us on ProductHunt

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.