# Elicitation in MCP: asking the user with a form or a link

> Source: <https://pournasserian.com/writing/mcp-2026-elicitation>
> Published: 2026-10-20 00:00:00+00:00

# 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.
