Elicitation in MCP: asking the user with a form or a link The 2026-07-28 Model Context Protocol specification removes the `elicitationId` and `notifications/elicitation/complete` notification that servers used in 2025-11-25 to signal the end of a URL elicitation flow, making the retry of the original request the completion signal, according to a technical write-up on the specification. Under the new version, elicitation travels only in `inputRequests` within a Multi Round-Trip Requests result or a task's `input_required` state, in either form mode (a host-rendered dialog from a flat `requestedSchema` of primitive properties) or URL mode, where sensitive flows such as third-party sign-in, consent screens and payments happen in the browser so passwords and card numbers never pass through the AI application. A server MUST NOT send an elicitation the request did not declare support for, and must return error `-32021` if the operation cannot proceed without it; servers matching a browser flow to the retry must keep their own identifier in `requestState`. Elicitation in MCP: asking the user with a form or a link 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 https://pournasserian.com/writing/mcp-2026-request-state . It builds on the Multi Round-Trip Requests MRTR of part 13 https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests . The facts are as I read them in October 2026. In brief 1. Elicitation asks the user, not the model. It travels only in inputRequests , in an MRTR result or a task waiting for input, never unsolicited. 2. 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. 3. The retry is the completion signal. elicitationId and the completion notification are gone; a server that has to match a browser flow to the retry keeps its own identifier in requestState . 4. 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 . 5. 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 https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr , 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 : python import secrets from mcp.server import MCPServer from mcp.server.mcpserver import Context from mcp.types import ElicitRequest, ElicitRequestURLParams, InputRequiredResult mcp = MCPServer "billing" Stand-in for the server's grant store, keyed by user. The OAuth callback not shown finds the user by the flow id in the link and saves the grant. 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: python 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 https://modelcontextprotocol.io/specification/2026-07-28/basic/index 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 https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests 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: python 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 https://pournasserian.com/writing/mcp-2026-tool-errors-and-design 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 https://code.claude.com/docs/en/mcp | 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 https://developers.openai.com/plugins/build/mcp-server 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 https://zuplo.com/docs/mcp-gateway/connect-clients/cursor.md | | 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 mcp 2.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.