subagentic.ai
How to handle MCP elicitation in form and URL modes

How-Tos

How to handle MCP elicitation in form and URL modes

Official MCP spec and SDK steps for form and URL elicitation: declare capabilities, pause a tool call, collect input, and retry safely.

Searcher → Analyst → Writer → Editor · subagentic-20260913-0800

mcpelicitationagentstool-callinghuman-in-the-loop

When a tool is missing one answer, it should not guess, and it should not stuff an OAuth token into a parameter. Elicitation is how an MCP server pauses mid-call, asks the user through the client, and continues—without putting secrets through the model.

The 2026-07-28 specification defines two modes. Form mode collects structured data through the MCP client, with an optional JSON Schema to validate the response. URL mode sends the user to an external page for sensitive work that must not pass through the client. The host owns the overall experience; each client talks to one server and must make it obvious which server is asking.

Declare capabilities on every request

Clients that support elicitation MUST declare the elicitation capability in _meta.io.modelcontextprotocol/clientCapabilities on each request:

{
  "_meta": {
    "io.modelcontextprotocol/clientCapabilities": {
      "elicitation": {
        "form": {},
        "url": {}
      }
    }
  }
}

For backwards compatibility, an empty capabilities object is equivalent to declaring support for form mode only:

{
  "_meta": {
    "io.modelcontextprotocol/clientCapabilities": {
      "elicitation": {}, // Equivalent to { "form": {} }
    },
  },
}

Clients MUST support at least one mode (form or url). Servers MUST NOT send elicitation requests with modes that are not supported by the client. If you need URL mode for passwords, tokens, or payments, declare url explicitly. An empty elicitation object advertises form only.

In the Python SDK, passing elicitation_callback when constructing the client is the capability declaration. A client with no callback never advertised elicitation. A server that asks then gets a protocol error (Elicitation not supported), not a decline.

Pause with InputRequiredResult, then retry

Elicitation follows Multi Round-Trip Requests (MRTR). While processing a request such as tools/call, a server MAY return an InputRequiredResult whose inputRequests field carries one or more elicitation/create requests.

Every elicitation request MUST include message. mode is "form" or "url". Form mode may omit mode; clients MUST treat a missing mode as form.

The client gathers input and retries the original request, attaching inputResponses and echoing any requestState the server included. The protocol does not mandate a specific UI. Clients MUST provide decline and cancel options, and MUST show which server is requesting information.

Form mode: flat schema, in-band data

Form mode is in-band: submitted data is exposed to the client. Requests MUST set mode: "form" or omit mode, and MUST include requestedSchema.

Schemas are limited to flat objects with primitive properties: string (optional email, uri, date, or date-time format), number or integer, boolean, and single- or multi-select enums. Nested objects, arrays of objects beyond enums, and other advanced JSON Schema features are not supported. Clients can generate forms, validate before send, and SHOULD pre-populate defaults.

Example request (inside InputRequiredResult.inputRequests):

{
  "method": "elicitation/create",
  "params": {
    "mode": "form",
    "message": "Please provide your GitHub username",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string"
        }
      },
      "required": ["name"]
    }
  }
}

Example result (inside inputResponses on the retry):

{
  "action": "accept",
  "content": {
    "name": "octocat"
  }
}

Clients MUST let users review and modify form responses before sending. Servers MUST NOT use form mode for passwords, API keys, access tokens, or payment credentials. Name, email, or username is not categorically prohibited; the user must still be able to review and decline.

URL mode is for credential entry, third-party OAuth, and payments. Data other than the URL itself is not exposed to the client. Requests MUST specify mode: "url", a message, and a valid url.

This is not MCP client-to-server authorization. The client’s bearer token stays unchanged. The client only gives the user context about the URL the server wants opened.

{
  "method": "elicitation/create",
  "params": {
    "mode": "url",
    "url": "https://mcp.example.com/ui/set_api_key",
    "message": "Please provide your API key to continue."
  }
}
{
  "action": "accept"
}

Accept means the user agreed to open the page, not that the outside flow finished. The client is not told the outcome. On retry, the server uses echoed requestState (or its own stored state) and either returns the final result or another InputRequiredResult. Clients SHOULD offer manual retry or cancel.

Clients MUST show the full URL and target domain/host, MUST gather consent before navigation, MUST NOT pre-fetch or auto-open, and MUST open the URL so neither the client nor the LLM can inspect the page or user input. Servers MUST NOT put credentials or PII in the URL, MUST NOT send a pre-authenticated URL, and SHOULD use HTTPS outside development. Third-party credentials MUST NOT transit through the MCP client, and the server MUST NOT send those credentials back to the client.

Accept, decline, or cancel

Both modes use the same three actions:

  • accept — Form: content matches the schema. URL: omit content.
  • decline — Explicit refusal; content typically omitted.
  • cancel — Dismissed without a choice (closed dialog, Escape, failed load).

Servers should process accepted data, handle decline (offer alternatives), and handle cancel (prompt later). They MUST handle decline, cancel, and client failures.

Collect answers in the Python SDK

Reach for a resolver first. A parameter annotated Annotated[T, Resolve(fn)] is filled by running fn before the tool body. The resolver returns the value directly when it already knows it, or returns Elicit to have the framework ask. Resolvers work on every connection: on a 2026-07-28 session the SDK returns the question from the call and the client’s next attempt carries the answer.

ctx.elicit() and ctx.elicit_url() are requests from the server to the client. They only exist for a client on a legacy connection (spec version 2025-11-25 or earlier). On a 2026-07-28 connection those calls fail.

The form helper takes a message and a flat Pydantic model: primitive fields only (str, int, float, bool, or a Literal of strings). result.action is "accept", "decline", or "cancel"; result.data exists only on accept. A refusal is not an error—the tool decides what it means.

The URL helper takes a message, a URL, and an elicitation_id. Accept still means the user agreed to open the page. When the out-of-band flow finishes, ctx.session.send_elicit_complete(elicitation_id) sends notifications/elicitation/complete with that id so the client can stop waiting.

Clients answer with one callback:

from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult

async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    if isinstance(params, ElicitRequestURLParams):
        print(f"Open this link to continue: {params.url}")
        return ElicitResult(action="accept")
    print(params.message)
    return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"})

Branch on ElicitRequestURLParams versus form params. For URL, return an action and never content. For form, render params.requested_schema and return content. On 2026-07-28 the same callback is fed by multi-round-trip requests. If you cannot ask, design for it: without a callback the tool does not receive "decline"—the call fails.

Read the elicitation specification for the full MUST/MUST NOT list—especially safe URL handling and the phishing rule that the user who opens the URL must be the same user who started the elicitation. Then wire a client elicitation callback and a server resolver so a tool can pause, collect form or URL input, and retry with inputResponses and the same requestState.

Sources