{"slug": "elicitation-in-mcp-asking-the-user-with-a-form-or-a-link", "title": "Elicitation in MCP: asking the user with a form or a link", "summary": "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`.", "body_md": "# Elicitation in MCP: asking the user with a form or a link\n\nSome 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.\n\nThis 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.\n\n## In brief\n\n1. **Elicitation asks the user, not the model.** It travels only in`inputRequests` , in an MRTR result or a task waiting for input, never unsolicited.\n2. **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.\n3. **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` .\n4. **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` .\n5. **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.\n\n## Two modes: a form or a link\n\nAn 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:\n\n| Mode | What the user sees | Use it for | \n|---|---|---|\n| Form | A dialog rendered by the host from a JSON schema the server supplies | Confirmations, choices, small structured values | \n| URL | A prompt to open a link in the browser | Out-of-band flows: third-party sign-in, consent screens, payments, anything sensitive | \n\n### Form mode\n\nThe 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:\n\n```\n{\n  \"method\": \"elicitation/create\",\n  \"params\": {\n    \"mode\": \"form\",\n    \"message\": \"Which environment should I deploy to?\",\n    \"requestedSchema\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"environment\": { \"type\": \"string\", \"enum\": [\"dev\", \"staging\", \"prod\"] },\n        \"notify\": { \"type\": \"boolean\", \"description\": \"Post in #deployments\" }\n      },\n      \"required\": [\"environment\"]\n    }\n  }\n}\n```\n\n### URL mode\n\nThe 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.\n\n### The answer\n\nThe retry carries the user’s answer in `inputResponses`, under the key the server used in `inputRequests`, with one of three actions:\n\n| `action` | Meaning | `content` | \n|---|---|---|\n| `accept` | The user submitted (form) or agreed (URL) | The form’s values, in form mode | \n| `decline` | The user explicitly said no | None | \n| `cancel` | The user dismissed the prompt without choosing | None | \n\nHandle all three: `decline` and `cancel` are normal outcomes, not errors. Inside MRTR, a form looks like this:\n\n## Signing in to another service with a link\n\nA 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:\n\nIn 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.\n\n### In Python\n\nWith 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`:\n\n``` python\nimport secrets\n\nfrom mcp.server import MCPServer\nfrom mcp.server.mcpserver import Context\nfrom mcp.types import ElicitRequest, ElicitRequestURLParams, InputRequiredResult\n\nmcp = MCPServer(\"billing\")\n\n# Stand-in for the server's grant store, keyed by user. The OAuth callback\n# (not shown) finds the user by the flow id in the link and saves the grant.\ngrants: dict[str, str] = {}\nflows: dict[str, str] = {}  # flow id -> the user who started that sign-in\n\n@mcp.tool()\nasync def list_invoices(ctx: Context) -> str | InputRequiredResult:\n    \"\"\"List the open invoices in the user's billing account.\"\"\"\n    user = \"user-123\"  # stub: a real server takes the user from the access token\n    answer = (ctx.input_responses or {}).get(\"login\")\n    if answer is not None and answer.action != \"accept\":\n        return \"The billing account isn't connected, so no invoices were read.\"\n    if user in grants:\n        return \"2 open invoices.\"  # a real tool calls the billing API with the grant\n    require_url_elicitation(ctx)  # shown below: -32021 unless the request declared URL mode\n    flow_id = ctx.request_state or secrets.token_urlsafe(16)  # the same flow on a retry\n    flows[flow_id] = user\n    link = ElicitRequestURLParams(\n        message=\"Open this link to connect your billing account.\",\n        url=f\"https://billing.example.com/connect?flow={flow_id}\",\n    )\n    return InputRequiredResult(input_requests={\"login\": ElicitRequest(params=link)}, request_state=flow_id)\n```\n\n`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:\n\n``` python\nimport json\n\nimport anyio\nfrom mcp import Client\nfrom mcp.types import ElicitResult\n\nasync def on_elicit(ctx, params):  # passing a callback declares elicitation (form and url)\n    return ElicitResult(action=\"accept\")\n\nasync def main():\n    async with Client(mcp, elicitation_callback=on_elicit) as client:\n        first = await client.session.call_tool(\"list_invoices\", {}, allow_input_required=True)\n        shown = first.model_dump(by_alias=True, exclude_none=True, exclude={\"meta\"})\n        shown[\"requestState\"] = shown[\"requestState\"][:12] + \"...\"  # sealed by the SDK\n        print(json.dumps(shown, indent=2))\n\n        grants[\"user-123\"] = \"grant\"  # stand-in: the OAuth callback has saved the grant\n        retry = await client.session.call_tool(\n            \"list_invoices\", {},\n            input_responses={\"login\": ElicitResult(action=\"accept\")},\n            request_state=first.request_state,\n            allow_input_required=True,\n        )\n        print(retry.result_type, retry.content[0].text)\n\nif __name__ == \"__main__\":\n    anyio.run(main)\n```\n\nIt printed:\n\n```\n{\n  \"resultType\": \"input_required\",\n  \"inputRequests\": {\n    \"login\": {\n      \"method\": \"elicitation/create\",\n      \"params\": {\n        \"mode\": \"url\",\n        \"message\": \"Open this link to connect your billing account.\",\n        \"url\": \"https://billing.example.com/connect?flow=Nx6OuJMUoLLAjtrN1LWEyg\"\n      }\n    }\n  },\n  \"requestState\": \"v1.LF5yUJ7c6...\"\n}\ncomplete 2 open invoices.\n```\n\nA retry before the sign-in finished got the same link back, and `decline` and `cancel` each got a complete result.\n\nTwo 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.\n\n## When the client can’t ask\n\nClients 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.\n\nIf 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.\n\nIn Python, `Elicit` checks the capability for you, but a hand-built `InputRequiredResult` isn’t checked, so the billing tool calls this first:\n\n``` python\nfrom mcp import MCPError\nfrom mcp.types import MISSING_REQUIRED_CLIENT_CAPABILITY\n\ndef require_url_elicitation(ctx: Context) -> None:\n    caps = ctx.client_capabilities\n    if not (caps and caps.elicitation and caps.elicitation.url):\n        raise MCPError(\n            code=MISSING_REQUIRED_CLIENT_CAPABILITY,\n            message=\"list_invoices needs URL-mode elicitation to connect the billing account.\",\n            data={\"requiredCapabilities\": {\"elicitation\": {\"url\": {}}}},\n        )\n```\n\nA client that declared no elicitation got an `MCPError` with code `-32021` and that `data`.\n\n### A path with no one there\n\nThe 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.\n\nThe billing tool above has no such path: its sign-in has to happen in the browser, through URL mode, so it answers `-32021`.\n\n## Trust and host support\n\n- Hosts must make clear which server is asking, and the user can always decline.\n- Elicitations that arrive through Tasks get the same trust model and treatment as direct ones; a task isn’t a higher-trust channel.\n- Don’t collect secrets through form mode; use URL mode.\n- Keep forms small. For more than a handful of fields, consider an MCP App (a later part).\n\n### Host support, October 2026\n\n| Host | Elicitation | \n|---|---|\n| [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 | \n| 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” | \n| Cursor | Form and URL modes, per [a gateway vendor’s documentation](https://zuplo.com/docs/mcp-gateway/connect-clients/cursor.md) | \n| Others | Check current vendor docs | \n\n## Method and caveats\n\n- Built from my guide’s elicitation chapter and the URL-mode section of its MRTR chapter, written against the 2026-07-28 specification.\n- 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.\n- 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.\n- 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.", "url": "https://wpnews.pro/news/elicitation-in-mcp-asking-the-user-with-a-form-or-a-link", "canonical_source": "https://pournasserian.com/writing/mcp-2026-elicitation", "published_at": "2026-10-20 00:00:00+00:00", "updated_at": "2026-10-20 23:49:07.560443+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Model Context Protocol", "Multi Round-Trip Requests", "elicitation/create", "inputRequests", "inputResponses", "requestState", "elicitationId"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/elicitation-in-mcp-asking-the-user-with-a-form-or-a-link", "markdown": "https://wpnews.pro/news/elicitation-in-mcp-asking-the-user-with-a-form-or-a-link.md", "text": "https://wpnews.pro/news/elicitation-in-mcp-asking-the-user-with-a-form-or-a-link.txt", "jsonld": "https://wpnews.pro/news/elicitation-in-mcp-asking-the-user-with-a-form-or-a-link.jsonld"}}