Safeguard
Supply Chain Security•

MCP Elicitation Explained: How Servers Ask for Input and How to Secure It

MCP elicitation lets a server pause a tool call to ask the user for input through a form or a URL. How it works on the wire and how to secure it.

Safeguard Security Team
46 min read

MCP elicitation is the part of the Model Context Protocol that lets a server pause in the middle of a tool call and ask the person at the keyboard for something: a confirmation, a choice, a date, or a trip to a web page. It arrived in the 2025-06-18 revision, gained URL mode in 2025-11-25, and moved onto multi round-trip requests in 2026-07-28.

To secure it, treat every elicitation as a prompt written by a third party. Clients must show which server is asking and offer a clean way to decline. Servers must never ask for passwords, API keys, tokens or payment details in a form, and must send anything sensitive through URL mode, bound to the identity of the user who started the flow.

This post goes one level deeper than our earlier pieces on whether an MCP server can ask for a password or API key, MCP sampling, MCP messages, transports and authorization, human in the loop design and whether tool descriptions can be trusted. This one covers elicitation itself: each specification revision's exact wording, the wire format, the threat model and a working server you can build and log.

Key takeaways

  • Elicitation is a client feature: the server asks, the client renders the question, and the user's answer goes back to the server without the model having to see it.
  • It was introduced in MCP revision 2025-06-18 (PR #382). Revision 2025-11-25 added URL mode (SEP-1036), richer enums (SEP-1330) and default values (SEP-1034). Revision 2026-07-28, the current one, moved elicitation onto the Multi Round-Trip Requests pattern (SEP-2322) and removed the elicitationId field and the completion notification.
  • Form mode schemas are deliberately small: a flat object whose properties are strings, numbers, integers, booleans or enums. No nested objects, no arrays of objects.
  • Every response is one of three actions: accept (with content in form mode), decline or cancel.
  • Since 2025-11-25 the specification says servers MUST NOT use form mode for passwords, API keys, access tokens or payment credentials, and MUST use URL mode for them. Clients MUST show which server is asking and offer decline and cancel.
  • URL mode is for third-party OAuth, payments and secret entry that should never pass through the client or the model. It is not a way for a server to authorize the MCP client to itself.
  • The big risks are phishing in a trusted client window, credential harvesting, consent fatigue, injection-driven prompts, answers reaching the wrong server, and tampered requestState.
  • Use a plain tool argument when the model already knows the value. Use elicitation when only the human can supply or approve it.

What is elicitation in the Model Context Protocol?

MCP connects an AI application (the host, through its client) to servers that offer tools, resources and prompts. Most traffic flows from client to server. Three client features run the other way: roots (which files and folders the server may work with), sampling (asking the client's model for a completion) and elicitation (asking the user).

The 2025-06-18 elicitation page describes the feature as "a standardized way for servers to request additional information from users through the client during interactions." The key words are "through the client." The server never talks to the user directly. It describes what it needs, and the client decides how, and whether, to put that question in front of a person. The page is explicit that the protocol "does not mandate any specific user interaction model," so a terminal client might show a dialog, a desktop client a modal form, and a mobile client a sheet.

Elicitation requests are nested inside other server work. A booking tool that finds the date full can ask whether the user wants another date instead of failing.

Which MCP specification version introduced elicitation?

Elicitation first appeared in revision 2025-06-18. The changelog for that revision lists it as major change six: "Add support for elicitation, enabling servers to request additional information from users during interactions," with a link to PR #382 in the specification repository. The PR, titled "Introduce elcitation as new client capability" (the typo is in the original), was opened in April 2025. The 2025-06-18 page carries a note that elicitation "is newly introduced in this version" and that "its design may evolve in future protocol versions." It did.

Revision names are dates, but the versioning page says the identifier marks "the last date backwards incompatible changes were made," not a launch event.

What changed in later versions?

Two later revisions changed elicitation materially.

2025-11-25 added URL mode. The changelog lists "Added support for URL mode elicitation (SEP-1036)," tracked in PR #887, which was opened at the end of June 2025 under the title "URL Mode Elicitation for secure out-of-band interactions." The same revision updated ElicitResult and EnumSchema to support titled, untitled, single-select and multi-select enums (SEP-1330), and added default values for all primitive types (SEP-1034). It also introduced a dedicated error, URLElicitationRequiredError with code -32042, and a notifications/elicitation/complete notification keyed by an elicitationId. Most importantly for security, it rewrote the sensitive information rule from a single sentence into explicit, testable requirements, covered below.

2026-07-28, the current revision according to the specification's versioning page, made MCP stateless. It removed the initialize handshake, removed protocol-level sessions, and introduced the Multi Round-Trip Requests (MRTR) pattern in SEP-2322. Under MRTR, a server no longer sends its own elicitation/create request to the client. Instead it answers the client's request (tools/call, prompts/get or resources/read) with an InputRequiredResult that carries the elicitation inside an inputRequests map, and the client retries the original request with the user's answers in inputResponses. The changelog also records the removal of notifications/elicitation/complete and of the elicitationId field, both "introduced in 2025-11-25," because under MRTR "the client learns the outcome of an out-of-band interaction by retrying the original request."

The same revision deprecated roots and sampling (SEP-2577), with earliest removal in the first revision released on or after 28 July 2027. Elicitation is not on the deprecated features registry. It is the one client feature that kept its place.

Timeline

Date or revisionWhat happenedSource
April 2025PR #382 opened to add elicitation as a client capabilitymodelcontextprotocol GitHub, PR #382
Revision 2025-06-18Elicitation added (form only). Single rule: servers must not request sensitive information2025-06-18 changelog and elicitation page
Late June 2025PR #887 opened for SEP-1036, URL mode elicitationmodelcontextprotocol GitHub, PR #887
Revision 2025-11-25URL mode, -32042 error, completion notification, titled and multi-select enums, defaults, explicit list of forbidden secrets2025-11-25 changelog and elicitation page
27 July 2026TypeScript SDK v2 released, implementing 2026-07-28; v1.x kept for fixes for at least six monthsTypeScript SDK README
Revision 2026-07-28Elicitation delivered through MRTR InputRequiredResult; elicitationId and completion notification removed; roots and sampling deprecated2026-07-28 changelog, MRTR page, deprecated features registry

How does elicitation work on the wire?

The mechanics differ by revision, so it helps to see each one. All three revisions share the same core vocabulary: a capability, a request with a message and a schema (or a URL), and a response with one of three actions.

Step one: the client declares the capability

A server may only ask a client that said it can answer. In 2025-06-18 the client sends this inside its initialize request:

{
  "capabilities": {
    "elicitation": {}
  }
}

In 2025-11-25 the capability grew sub-keys for the two modes:

{
  "capabilities": {
    "elicitation": {
      "form": {},
      "url": {}
    }
  }
}

An empty elicitation object still means form mode only, for backwards compatibility. A client that declares the capability must support at least one mode, and a server "MUST NOT send elicitation requests with modes that are not supported by the client." If it does, the client returns -32602 (invalid params).

In 2026-07-28 there is no handshake. The same object travels with every request in _meta, under the key io.modelcontextprotocol/clientCapabilities. The practical effect is that a server checks the capability per request, not once per session.

Step two: the server sends a form mode request

In 2025-06-18 and 2025-11-25 the server sends a JSON-RPC request of its own. This is the specification's own minimal example, with mode shown as 2025-11-25 allows:

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

Two fields matter. message is the human-readable reason. requestedSchema describes the answer. Clients must treat a request with no mode as form mode, which is how 2025-06-18 servers keep working against newer clients.

The requested schema: flat objects with primitive types

The schema is a restricted subset of JSON Schema. In the specification's words, schemas are "limited to flat objects with primitive properties only," and "complex nested structures, arrays of objects (beyond enums), and other advanced JSON Schema features are intentionally not supported." The allowed property types are:

Property typeAllowed keywords (2025-11-25)Notes
Stringtitle, description, minLength, maxLength, pattern, format, defaultFormats limited to email, uri, date, date-time
Number or integertitle, description, minimum, maximum, default
Booleantitle, description, defaultThe natural shape for a yes or no confirmation
Single-select enumenum, or oneOf with const and title pairs, default2025-06-18 used a non-standard enumNames array; SEP-1330 moved to standard JSON Schema
Multi-select enumtype: array with items holding enum or anyOf, plus minItems, maxItems, defaultThe only array allowed

The 2026-07-28 page lists the same types, though its string example no longer shows pattern. If you rely on pattern, test the client you ship against. The wider protocol adopted JSON Schema 2020-12 as its default dialect in 2025-11-25 (SEP-1613), and 2026-07-28 loosened tool inputSchema to allow any 2020-12 keyword. Elicitation schemas did not get that loosening. They stay small on purpose, because a client has to turn them into a form a person can fill in without reading JSON.

The small schema is also a security property: a flat, typed form is easy to render honestly, with nowhere to hide a field three levels down.

The three response actions

Every elicitation ends in one of three actions, defined the same way in every revision:

ActionMeaning in the specificationContentTypical UI event
accept"User explicitly approved and submitted with data"Form mode: the submitted values. URL mode: omittedSubmit, OK, Confirm
decline"User explicitly declined the request"Typically omittedReject, Decline, No
cancel"User dismissed without making an explicit choice"Typically omittedClosed the dialog, pressed Escape, browser failed to load

A form mode accept looks like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "action": "accept",
    "content": { "name": "octocat" }
  }
}

Keep decline and cancel distinct. Decline is a decision, so a server offers an alternative or stops. Cancel is an absence of decision, so a server might ask later. Treating both as "try again now" builds a nag loop.

How elicitation travels in revision 2026-07-28

Under MRTR the server cannot push a request. It replies to the client's request with an interim result. The specification's pattern page gives the shape:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

The client shows the form, then sends the original tools/call again with a new JSON-RPC id, an inputResponses map keyed the same way ("github_login": { "action": "accept", "content": { "name": "octocat" } }), and the exact requestState string it received. The server rebuilds its context from that state and either finishes or asks again.

This matters for security because servers can now run without sticky sessions, and the state that rides along with the retry passes through the client. The MRTR page says servers "MUST treat requestState as an attacker-controlled input," must protect its integrity with something like an HMAC or AEAD when it influences authorization or business logic, and should bind it to the authenticated principal, a short expiry and the originating request. It also warns that those measures "do not by themselves guarantee single-use," so anything that must happen at most once, such as a redemption, needs a server-side check.

What must clients show, and what must servers never request?

This is where the specification tightened most between revisions, and where the exact wording matters.

What the 2025-06-18 revision said

The original page had one server rule, "Servers MUST NOT use elicitation to request sensitive information," without defining sensitive information. Every client duty was a SHOULD: show which server is asking, allow review and edits before sending, offer clear decline and cancel options, implement approval controls and rate limiting, and make clear "what information is being requested and why." Both parties SHOULD validate content against the schema.

What the 2025-11-25 and 2026-07-28 revisions say

The current text is far more specific. The warning at the top of the page now reads, in substance:

  • Servers MUST NOT use form mode elicitation to request sensitive information "such as passwords, API keys, access tokens, or payment credentials."
  • Servers MUST use URL mode for interactions involving such information.
  • "Sensitive information" means "secrets and credentials that grant access or authorize transactions." Contact or profile data such as a name, email address or username "is not categorically prohibited," and asking for it is "at the discretion of the server and subject to the user's ability to review and decline."

The client duties moved from SHOULD to MUST:

  • Provide UI that makes it clear which server is requesting information.
  • Respect user privacy and provide clear decline and cancel options.
  • For form mode, allow users to review and modify their responses before sending.
  • For URL mode, clearly display the target domain or host and gather consent before navigating.

The security considerations list adds a server MUST at the top: "Servers MUST bind elicitation requests to the client and user identity." It keeps approval controls, the ability to decline at any time, and a clear statement of what and why as client SHOULDs. Rate limiting appears as a client SHOULD in 2025-11-25 and is no longer in the 2026-07-28 list, which is a reason to keep it in your own policy rather than rely on the text.

Rule2025-06-182025-11-25 and 2026-07-28
No secrets through elicitationMUST NOT, "sensitive information" undefinedMUST NOT in form mode; passwords, API keys, access tokens and payment credentials named; MUST use URL mode
Show which server is askingSHOULDMUST
Decline and cancel optionsSHOULDMUST
Review and edit before sendingSHOULDMUST (form mode)
Show target domain and get consent before opening a URLNot applicableMUST
Bind requests to client and user identityNot statedServer MUST
Rate limitingClient SHOULDClient SHOULD in 2025-11-25; not listed in 2026-07-28

One practical note. Claude Code's MCP documentation, describing what a form mode dialog looks like, gives "a username and password prompt" as its example, and its hooks page shows a sample message reading "Please provide your credentials." Those are descriptions of what a client can render, not guidance for server authors. The protocol text is the rule for servers: a password field in a form mode request is out of bounds. If you build a client, you can treat any field whose title or description mentions a password, token, secret or card number as a warning sign worth surfacing to the user.

How does URL mode change the picture for OAuth and payment flows?

Form mode data passes through the client, and in many hosts that means it may also land in logs, transcripts or the model's context. That is fine for "which region?" and wrong for a card number. URL mode exists so that sensitive interactions "must not pass through the MCP client" at all.

How a URL mode request looks

In 2025-11-25 a URL mode request carries a mode, a message, a url and an elicitationId:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "elicitation/create",
  "params": {
    "mode": "url",
    "elicitationId": "550e8400-e29b-41d4-a716-446655440000",
    "url": "https://mcp.example.com/ui/set_api_key",
    "message": "Please provide your API key to continue."
  }
}

In 2026-07-28 the same request rides inside inputRequests and has no elicitationId. A server that needs to correlate the browser flow with the retry encodes its own identifier in requestState.

The response to a URL mode request is only ever an action. The specification is careful about what accept means: it "indicates that the user has consented to the interaction. It does not mean that the interaction is complete." The actual work (typing a key, approving an OAuth consent screen, paying) happens in the user's browser between the user and the server's web front end. The client never sees it.

The secure pattern the specification describes

For credentials and payment details, the 2025-11-25 page describes a pattern: the server sends the user to an HTTPS page with a branded form on a domain the user trusts, the user enters the credential there, the server stores it bound to the user's identity, and later MCP requests use it. The stated benefit is that "sensitive credentials never pass through the LLM context, MCP client or any intermediate MCP servers."

URL mode for third-party OAuth, and what it is not for

The most common use is letting an MCP server act as an OAuth client to some other service, such as a file store or a ticketing system, on the user's behalf. The specification separates two things that are easy to confuse:

  • MCP authorization is the OAuth flow between the MCP client and the MCP server. It is defined in the authorization specification and is required for remote servers that need it.
  • External authorization is optional OAuth between the MCP server and a third-party resource server, started through URL mode elicitation.

A note on the page says URL mode "is not for authorizing the MCP client's access to the MCP server," and the normative text says servers "MUST NOT rely on URL mode elicitation to authorize users for themselves." The four critical requirements are that third-party credentials must not transit through the client, the server must not reuse the client's token for the third party (that would be token passthrough, which the security best practices forbid), the user must authorize the server directly, and the server is responsible for storing and managing the resulting tokens. Credentials obtained through URL mode must never be sent back to the client.

The phishing scenario in the specification, and its fix

The 2025-11-25 and 2026-07-28 pages describe an account takeover that URL mode makes possible if a server is careless. Conceptually:

  1. An attacker, Alice, uses a legitimate MCP server and triggers a URL mode elicitation that would link a third-party account.
  2. The server builds an authorization URL for the third party.
  3. Instead of opening it, Alice sends the link to Bob, another user of the same server.
  4. Bob opens it and approves, believing he is connecting his own account.
  5. The server receives the callback and binds Bob's third-party tokens to Alice's session.

The fix is a rule, not a trick: the server "MUST ensure that the user who started the elicitation request ... is the same user who completes the authorization flow." The specification's non-normative example is a "connect URL" on the MCP server itself (for example https://mcp.example.com/connect?...) rather than a direct link to the third party's authorize endpoint. That page checks the browser session and compares the subject (sub claim) from the MCP server's authorization server with the subject in the session cookie. Only when they match does it redirect to the third party. Whatever mechanism you choose must be "resilient to attacks where an attacker can modify the elicitation URL."

Safe URL handling rules

The specification gives both sides explicit URL rules. Servers:

  1. MUST NOT include sensitive information about the user (credentials, personally identifiable information) in the URL.
  2. MUST NOT send a pre-authenticated URL that grants access to a protected resource, "as the URL could be used to impersonate the user by a malicious client."
  3. SHOULD NOT put clickable URLs in any field of a form mode request.
  4. SHOULD use HTTPS outside development.

Clients:

  1. MUST NOT pre-fetch the URL or any of its metadata.
  2. MUST NOT open the URL without explicit consent.
  3. MUST show the full URL before consent.
  4. MUST open it in a way that does not let the client or the model inspect the page or the user's input. The specification's iOS example: SFSafariViewController is good, WKWebView is not.
  5. SHOULD highlight the domain to reduce subdomain spoofing.
  6. SHOULD warn on ambiguous or suspicious URIs, for example those containing Punycode.
  7. SHOULD NOT render URLs as clickable anywhere except the url field of a URL mode request.

The Punycode warning ties into a long-standing problem. Unicode Technical Standard #39 defines confusable detection for characters that look alike across scripts, which is why a domain that renders as a familiar brand may not be one. Showing the registrable domain in plain ASCII next to the full URL is a cheap, effective defence.

Claude Code's documentation describes one implementation: it asks whether to open the link in your browser, passes the URL to the system's URL handler, and caps the escaped length (about 8,000 characters for a plain URL, roughly 4,000 for one built largely of percent escapes). Past the cap, you can only decline. On connections using revision 2026-07-28, Claude Code declares both form and url in its client capabilities.

What are the risks of MCP elicitation?

Elicitation puts a third party's words inside a window the user trusts. Most of the risks follow from that one fact.

Phishing through a trusted client UI

Users trust their AI client, and a dialog inside it looks like part of the tool. A malicious or compromised server can use that frame to ask for things it should not have, or send the user to a look-alike login page in URL mode. The specification's MUSTs (show which server is asking, show the full URL and its domain, get consent first) exist because the frame itself is the attack surface. A client that renders the server's message as rich text, or lets a server choose its own title bar, makes this worse.

Social engineering for credentials

The form mode ban on secrets only binds honest servers. A dishonest one can still send a field titled "Confirm your access token to continue." Clients can flag schemas whose fields mention passwords, tokens, keys, secrets, card numbers or one-time codes, and remind the user in the dialog that legitimate servers do not ask for secrets in forms. OWASP's 2025 Top 10 for LLM Applications lists sensitive information disclosure (LLM02) as a core risk, and an elicitation form is one more path for that disclosure.

Consent fatigue

A server that asks five confirmations for routine work teaches people to accept without reading, which is when the sixth dialog does harm. Clients can push back with rate limits and a per-server "stop asking" option. Servers can ask only when needed: the Python SDK's resolver example elicits a delete confirmation only when the folder is not empty, and returns the answer directly otherwise. OWASP's excessive agency entry (LLM06) recommends human approval for high-impact actions, which works only if approvals stay rare enough to be read.

Prompt injection that triggers elicitation

If an attacker can inject instructions into content the model reads (a web page, an issue, a file), they can steer it into calling a tool whose server then elicits. The dialog that follows looks legitimate because the server is legitimate. OWASP's prompt injection entry (LLM01) describes this class of indirect manipulation. Elicitation does not create prompt injection, but it gives an injected plan a way to reach the human. The defence is to keep the elicitation message under the server's control, never built from model-supplied text, and to state in it exactly which action is pending. A dialog that says "Delete 214 files in /srv/reports?" is harder to misuse than one that says "Continue?"

Data sent to the wrong server

A host often connects to several servers. A generic dialog that does not name the requester invites an answer meant for one tool to land in another's form. The client MUST to name the server covers this, and the server MUST to bind requests to client and user identity covers shared remote servers. In 2025-11-25, completion notifications also had to go only "to the client that initiated the elicitation request."

Tampered or replayed request state

In 2026-07-28 the server's continuation state travels through the client, so a modified client can edit or replay it, or present one user's state on another user's request. The MRTR rules answer with integrity protection, principal binding, a short expiry and a request digest. The Python SDK seals requestState by default with a key generated at process start and a default lifetime of 600 seconds, binds it to the OAuth client, issuer and subject when the SDK validated the token, and requires shared keys of at least 32 bytes when a retry can land on a different instance.

Automation that answers for the user

Claude Code's Elicitation hook can accept, decline or cancel a request without showing a dialog, and its ElicitationResult hook can change or block the user's answer before it reaches the server. Useful for logging and repeatable answers, risky if a hook accepts too broadly. The documentation warns that an ElicitationResult hook's action replaces the user's, so a hook that returns accept without checking the input can turn a decline into an accept. It also notes that a URL mode request answered accept by a hook never opens the URL. Review hooks like privileged code.

Threat summary

ThreatWhere it startsPrimary controlOwner
Phishing in a trusted dialogMalicious or compromised serverName the server, plain-text rendering, show full URL and domainClient
Credential harvesting in a formMalicious server ignoring the MUST NOTFlag secret-like fields, user education in the dialogClient
Account takeover through a forwarded linkAttacker forwarding a URL mode linkConnect URL that checks the same user before redirectingServer
Consent fatigueOver-eager server designAsk only when needed, rate limits, per-server muteBoth
Injection-driven elicitationUntrusted content read by the modelServer-authored messages that name the exact actionServer
Answer reaches the wrong serverGeneric client UI, shared serversShow the requester, bind to client and user identityBoth
Tampered or replayed requestStateModified clientAEAD or HMAC, principal binding, expiry, request digestServer
Over-broad auto-answersClient hook or callbackNarrow matchers, check action, never auto-accept URL modeClient operator

How do elicitation, sampling and roots differ?

All three are client features, so the server asks and the client answers. They answer very different questions.

  • Roots tell the server which file:// locations the client is willing to expose. No human interaction is required per request. Deprecated in 2026-07-28, with the suggested migration being to pass directories or files through tool parameters, resource URIs or server configuration.
  • Sampling lets the server ask the client's model for a completion, optionally with tools since 2025-11-25. The specification says "there SHOULD always be a human in the loop with the ability to deny sampling requests." Deprecated in 2026-07-28, with the migration being to call a model provider's API directly.
  • Elicitation asks the human, not the model. It is the only one of the three that is still active in the current revision.

When should a server use a tool argument instead?

A tool argument is the right choice when the model already has, or can reasonably infer, the value, and when nothing bad happens if the model gets it wrong. A date range, a search query or a file path the user just mentioned are arguments. The Python SDK's documentation puts the rule well: "If the answer needs nesting, it should have been an argument to the tool."

Elicitation is the right choice when the value must come from the person and not from the model. That covers three cases: a decision with consequences (confirm a deletion, approve a spend), a preference the model cannot know (which of three matching accounts), and a fact only the user has that is not a secret (a display name). For secrets, the answer is URL mode, never a form and never a tool argument, because tool arguments are generated by the model and recorded in its context.

There is also an integrity argument. A confirmation passed as a tool argument, such as confirm: true, is produced by the model and can be produced by an injected instruction. A confirmation collected by elicitation comes from the user through the client. If the action matters, the approval should not come from the same component that proposed it.

Comparison table

MechanismWho sees the dataWhat the user approvesMain risksStatus in 2026-07-28
Elicitation, form modeUser, client and server; may reach logs or the model depending on the hostEach answer, with review and edit before sendingCredential phishing in forms, consent fatigue, injection-driven prompts, wrong-server answersActive
Elicitation, URL modeUser and the server's web page; client sees only the URLOpening a specific URL, then whatever the page asksLook-alike domains, forwarded links (account takeover), pre-authenticated URLsActive
SamplingClient's model and the server; often the user if the client shows the requestIdeally each sampling request and its resultPrompt injection through server-written prompts, cost abuse, data pulled from contextDeprecated
RootsServer learns the directory listWhich folders to expose, usually onceOver-broad roots exposing unrelated filesDeprecated
Plain tool argumentsModel, client and serverUsually the tool call as a whole, if the client asksModel-generated values, injected arguments, secrets landing in the model contextActive

Step by step: build, test and log an MCP server that uses elicitation

This walkthrough uses the official Python SDK, version 2.3.0 on PyPI at the time of writing. Its v2 line renames the high-level server class to MCPServer and adds a resolver mechanism that works on every protocol revision: on a 2025-11-25 or earlier connection the SDK sends a classic elicitation/create request; on a 2026-07-28 connection it returns an InputRequiredResult and handles the retry. The server below asks for a confirmation and one non-sensitive choice before pruning build artifacts, and logs every elicitation it sends and every answer it gets.

The TypeScript SDK offers the same feature through ctx.mcpReq.elicitInput() in its v2 packages (@modelcontextprotocol/server and @modelcontextprotocol/client). Its documentation notes that on a 2026-07-28 connection elicitInput throws and a handler should return the request instead.

Step 1: create a project

mkdir elicit-demo && cd elicit-demo
uv init --python 3.12
uv add "mcp[cli]==2.3.0"
uv run python -c "import mcp, importlib.metadata as m; print(m.version('mcp'))"

Pin the exact SDK version in a lockfile. Elicitation behaviour changed between revisions and between SDK major versions, so a floating version is a good way to lose a confirmation dialog without noticing.

Step 2: write the server

Save this as server.py. It never deletes anything real; the artifact list is in memory.

import json
import logging
import sys
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver import (
    AcceptedElicitation,
    CancelledElicitation,
    DeclinedElicitation,
    Elicit,
    ElicitationResult,
    Resolve,
)

# Log to stderr: on stdio, stdout carries the protocol.
logging.basicConfig(stream=sys.stderr, level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("elicit-demo")

mcp = MCPServer("artifact-pruner")

ARTIFACTS: dict[str, list[str]] = {
    "web": [f"build-{n}" for n in range(1, 31)],
    "api": [],
}

class PruneChoice(BaseModel):
    confirm: bool = Field(description="Prune old build artifacts now?")
    keep: Literal["last-5", "last-10", "last-20"] = Field(
        default="last-10", description="How many recent builds to keep"
    )

async def ask_before_pruning(project: str) -> PruneChoice | Elicit[PruneChoice]:
    builds = ARTIFACTS.get(project, [])
    if not builds:
        log.info(json.dumps({"event": "no_elicitation", "project": project,
                             "reason": "nothing to prune"}))
        return PruneChoice(confirm=True, keep="last-10")
    # The message is written by the server, names the exact action,
    # and never includes model-supplied text beyond the validated project key.
    message = (f"artifact-pruner wants to prune build artifacts for project "
               f"'{project}' ({len(builds)} builds stored). Nothing else is touched.")
    log.info(json.dumps({"event": "elicitation_sent", "project": project,
                         "message": message,
                         "schema": PruneChoice.model_json_schema()}))
    return Elicit(message, PruneChoice)

@mcp.tool()
async def prune_artifacts(
    project: Literal["web", "api"],
    choice: Annotated[ElicitationResult[PruneChoice], Resolve(ask_before_pruning)],
) -> str:
    """Prune old build artifacts for a project after the user confirms."""
    match choice:
        case AcceptedElicitation(data=PruneChoice(confirm=True, keep=keep)):
            n = int(keep.split("-")[1])
            before = len(ARTIFACTS[project])
            ARTIFACTS[project] = ARTIFACTS[project][-n:]
            log.info(json.dumps({"event": "elicitation_result", "action": "accept",
                                 "confirm": True, "keep": keep,
                                 "removed": before - len(ARTIFACTS[project])}))
            return f"Pruned {project}: kept {keep}, removed {before - len(ARTIFACTS[project])}."
        case AcceptedElicitation():
            log.info(json.dumps({"event": "elicitation_result", "action": "accept",
                                 "confirm": False}))
            return "User left the box unticked. Nothing pruned."
        case DeclinedElicitation():
            log.info(json.dumps({"event": "elicitation_result", "action": "decline"}))
            return "User declined. Nothing pruned."
        case CancelledElicitation():
            log.info(json.dumps({"event": "elicitation_result", "action": "cancel"}))
            return "User dismissed the dialog. Nothing pruned; ask again later."

if __name__ == "__main__":
    transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
    mcp.run(transport=transport)

A few design choices are deliberate:

  • The project argument is a Literal, so the model cannot pass free text that ends up inside the dialog message.
  • The schema is flat: one boolean and one string enum (Pydantic turns a Literal of strings into an enum). The SDK documentation warns that a nested model makes ctx.elicit raise before anything is sent.
  • confirm never appears in the tool's input schema. The model supplies project; the resolver supplies choice. The model cannot pre-approve its own action.
  • All four outcomes are handled, including accept with the box unticked. The TypeScript SDK documentation makes the same point: accepted content is "schema-valid, still untrusted."
  • Logs go to stderr. Revision 2025-11-25 clarified that stdio servers may use stderr for all logging, and stdout carries protocol messages.

Step 3: run it and inspect it

Start it on Streamable HTTP so a test client can connect:

uv run python server.py streamable-http
# Listens on http://127.0.0.1:8000/mcp by default

The SDK documentation notes that json_response=True on this transport leaves no back channel for a mid-request ctx.elicit() call, which then raises NoBackChannelError. Leave it at the default when testing legacy connections. You can also open the server in the MCP Inspector with uv run mcp dev server.py, which launches it over stdio.

Step 4: test with a scripted client that logs both sides

Save this as client.py. The callback stands in for a real dialog: it prints the requesting server, refuses anything that looks like a secret, refuses URL mode outright in this test, and logs the request and the answer as JSON lines.

import asyncio
import json
import re
import sys

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

SECRET_HINTS = re.compile(r"pass(word)?|token|secret|api[_ -]?key|card|cvv|otp", re.I)

def log(event: dict) -> None:
    print(json.dumps(event), file=sys.stderr)

async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    if isinstance(params, ElicitRequestURLParams):
        log({"event": "url_elicitation", "url": params.url, "message": params.message})
        return ElicitResult(action="decline")
    schema = params.requested_schema or {}
    fields = json.dumps(schema.get("properties", {}))
    log({"event": "form_elicitation", "message": params.message, "schema": schema})
    if SECRET_HINTS.search(fields) or SECRET_HINTS.search(params.message):
        log({"event": "refused", "reason": "secret-like field in form mode"})
        return ElicitResult(action="decline")
    answer = {"confirm": True, "keep": "last-5"}
    log({"event": "answer", "action": "accept", "content": answer})
    return ElicitResult(action="accept", content=answer)

async def main() -> None:
    async with Client("http://127.0.0.1:8000/mcp", mode="legacy",
                      elicitation_callback=on_elicit) as client:
        result = await client.call_tool("prune_artifacts", {"project": "web"})
        print(result.content)

asyncio.run(main())

Run it in a second terminal:

uv run python client.py

You should see the form request and answer on the client's stderr, the elicitation_sent and elicitation_result lines on the server's stderr, and a final result saying 25 builds were removed. Now change the answer to {"confirm": False, "keep": "last-5"} and run it again: the server should report that nothing was pruned. Then return ElicitResult(action="cancel") and check the cancel branch.

Step 5: check the "cannot ask" path

Remove elicitation_callback=on_elicit from the client and run it again. The Python SDK documentation says the call then fails with a protocol error, "Elicitation not supported," because a client that registered no callback never declared the capability. Your server should have a sensible answer for that case. For destructive actions, the right answer is to do nothing and say why.

Also call the tool with {"project": "api"}. The resolver finds nothing to prune and returns without asking, which is the "ask only when needed" behaviour that keeps consent meaningful.

Step 6: test in a client that supports elicitation

Claude Code supports both modes. Add the server over HTTP:

claude mcp add --transport http artifact-pruner http://127.0.0.1:8000/mcp

Then ask Claude to prune old builds for the web project. Claude Code shows a dialog with the form fields; selecting Decline sends decline, and pressing Esc sends cancel, according to its hooks documentation. Check that the dialog names the server, that the message is the one your server wrote, and that the defaults (last-10) are pre-filled.

Step 7: log every request and response on the client side

Claude Code's ElicitationResult hook runs after the user answers and receives the server name, the action, the mode and the content. A hook that prints nothing and exits 0 lets the answer through unchanged, so it is a safe place to log. Add this to your project's .claude/settings.json:

{
  "hooks": {
    "Elicitation": [
      {
        "matcher": "artifact-pruner",
        "hooks": [
          { "type": "command", "command": "jq -c '{t: now, ev: .hook_event_name, server: .mcp_server_name, mode, message, url, schema: .requested_schema}' >> ~/.claude/elicitation-audit.jsonl" }
        ]
      }
    ],
    "ElicitationResult": [
      {
        "matcher": "artifact-pruner",
        "hooks": [
          { "type": "command", "command": "jq -c '{t: now, ev: .hook_event_name, server: .mcp_server_name, mode, action, content}' >> ~/.claude/elicitation-audit.jsonl" }
        ]
      }
    ]
  }
}

Both commands only append to a file and print nothing to stdout, so the dialog still appears and the user's answer passes through untouched. Do not log content for servers that might collect personal data you are not allowed to retain; drop that field from the second command if in doubt.

If you want a policy rather than a log, the documented pattern for refusing URL mode from a specific server is a small Elicitation hook script that exits with code 2 when the mode is url. Neither the user nor the server sees why it declined, so keep a log line next to it.

Step 8: review the audit trail

jq -s 'group_by(.server) | map({server: .[0].server, asks: length,
       accepts: map(select(.action == "accept")) | length})' ~/.claude/elicitation-audit.jsonl

Look for three things: servers that ask far more often than their job needs, forms whose fields look like credentials, and URL mode requests to domains you do not recognise. Any of those is a reason to remove a server, not to click faster.

A checklist for teams building MCP clients and hosts

  1. Name the requesting server using the name the user configured, not a string the server chose.
  2. Render message, title and description as plain text: no links, HTML or images.
  3. Validate answers against requestedSchema, and reject schemas that are not flat.
  4. Flag fields that look like secrets.
  5. Offer decline and cancel as separate controls, and send the one the user chose.
  6. For URL mode, show the full URL and the registrable domain in ASCII, warn on Punycode, never pre-fetch, and open it in the system browser.
  7. Rate limit per server and offer "stop asking for this session."
  8. Keep form answers out of the model's context unless the user would expect them there, and keep URL mode flows out entirely.
  9. Echo requestState exactly, only on the retry it belongs to.
  10. Log server, mode, message, URL, schema and action, and keep content out of logs by default.

Common mistakes

  • Asking for a token "just this once" in a form. The current text names passwords, API keys, access tokens and payment credentials. Use URL mode and a page on your own domain.
  • Treating URL mode accept as success. It only means the user agreed to open the link. Check on the retry, or in your own state, whether the flow actually finished.
  • Linking straight to a third-party authorize endpoint. Without a connect page that checks the same user, a forwarded link binds someone else's tokens to the attacker's account.
  • Using URL mode to authorize the MCP client itself. That is the job of MCP authorization. The specification forbids relying on URL mode for it.
  • Putting the user's email or an access code in the URL. Servers must not include sensitive user information or pre-authenticated access in elicitation URLs.
  • Building the dialog message from model output. Write messages on the server from validated values.
  • Collapsing decline and cancel. Re-asking straight after a decline is a nag loop.
  • Trusting accepted content. Schema-valid is not the same as safe. Check that the confirmation box was actually ticked, and bound numbers and lengths.
  • Leaving requestState unsigned. In 2026-07-28 it passes through the client. Seal it, bind it to the principal, give it an expiry and enforce single use where it matters.
  • Shipping an auto-accept hook. An ElicitationResult hook that returns accept without checking the original action turns declines into accepts.
  • Assuming every client supports elicitation. Declare a fallback for clients that never declared the capability, and for non-interactive sessions. Claude Code's documentation, for example, says that in a non-interactive session it asks the server to cancel a task that requests input, and serves elicitation/create for tasks only in interactive terminal sessions.

FAQ

What is MCP elicitation in simple terms?

A way for an MCP server to ask the user a question mid-task. The server sends a message plus a small form or a URL; the user accepts, declines or cancels.

Which MCP version added elicitation?

Revision 2025-06-18 added it, through PR #382. Revision 2025-11-25 added URL mode through SEP-1036, and revision 2026-07-28 moved it onto the Multi Round-Trip Requests pattern.

Can an MCP server ask for my password through elicitation?

Not legitimately. Since 2025-11-25 servers must not use form mode for passwords, API keys, access tokens or payment credentials, and must use URL mode so the secret never passes through the client. If a form asks for a secret, decline and remove the server.

What is the difference between form mode and URL mode?

Form mode collects data inside the client, which sees the answer. URL mode sends you to an HTTPS page, and the client only learns whether you agreed to open it. Forms suit confirmations and choices; URLs suit credentials, OAuth and payments.

What does decline mean versus cancel?

Decline is an explicit no, such as clicking Reject. Cancel is a dismissal without a choice, such as closing the dialog or pressing Escape. Servers should handle each differently, typically by offering an alternative after a decline and asking again later after a cancel.

Is elicitation deprecated in MCP 2026-07-28?

No. Revision 2026-07-28 deprecated roots, sampling and logging, but elicitation remains active. What changed is the transport pattern: the server returns an InputRequiredResult and the client retries with the answers.

Can elicitation be triggered by prompt injection?

Indirectly, yes. Injected content can steer the model into calling a tool whose server then asks the user something. Keep dialog messages server-authored, name the exact pending action, and keep confirmations out of model-generated tool arguments.

Does Claude Code support MCP elicitation?

Yes. Its documentation says it shows a dialog for form mode, asks before opening a link in URL mode, and declares both modes on 2026-07-28 connections. Hooks can log, answer or block requests.

How do I log MCP elicitation requests and responses?

On the server, log each request and each action as JSON lines on stderr. On the client, use the SDK's elicitation callback or, in Claude Code, hooks that append to a file and print nothing.

Where Safeguard fits

Safeguard does not render elicitation dialogs, inspect MCP traffic between your client and third-party servers, or decide which servers your users should trust. Those controls live in your client, your server and your policies, as described above. The overlap is in the software. An MCP server is a Python or TypeScript service built from the protocol SDK, a web framework, an OAuth library, a schema validator and their transitive dependencies, and an MCP client is the same story. Safeguard can scan those repositories and images (safeguard scan --dir ., safeguard scan --image <name>), match components against vulnerability databases, apply reachability analysis, show which projects carry a given SDK version, and gate builds with policies. For assistants that reach your systems through MCP, Safeguard's own MCP server lets clients such as Claude query risk with the tools an administrator chooses. MCP Guardrail and the AI Gateway runtime guardrail proxy are in early access, so ask before you plan around either.

The elicitation flow is only as trustworthy as the SDKs, web frameworks and OAuth libraries that implement it, and those are ordinary open source dependencies that receive security fixes like any other. When a fix lands in one of them, or has not landed yet, the gap between disclosure and your running server is the part you control.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

None of these demos shows an elicitation dialog. Each shows a Safeguard surface built on the same principle: a named actor asks and a person decides at the point of risk. You can download Safeguard and follow the CLI docs to try them.

This demo connects Claude to the Safeguard MCP server with OAuth and lets you choose which tools Claude can use. The OAuth step happens in the browser rather than through a form in the chat, which is the same split the specification draws between URL mode and form mode. The tool allow-list is set by a person, not by the model.

Click through it at your own pace, or open it full screen.

This is a real CLI session: asked to fix a lodash CVE, the CLI reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with approval, prints a summary and pushes nothing. Each approval prompt plays the role a form mode confirmation plays in an MCP server: the tool proposes, the person accepts or declines, and /permissions shows how modes from Ask to Strict change what runs without a prompt.

Click through it at your own pace, or open it full screen.

This real Android session sends work to a computer running the CLI and approves the command on the phone with Allow once, Allow for turn or Deny. It is an approval that travels to wherever the person is, which is the shape of a well designed elicitation: three clear answers, and the work waits until one is chosen.

Click through it at your own pace, or open it full screen.

Quick check

An MCP server you use opens a form titled "Reconnect" with one field, "Personal access token". The client shows the server's name. What should you do?

Decline. Since revision 2025-11-25 the specification says servers must not use form mode for access tokens and must use URL mode instead, which sends you to a page on the server's own domain. A form asking for a token is either a bug or an attempt to collect a credential, and either way the server should not get it.

Your server sends a URL mode elicitation that links directly to a third party's OAuth authorize endpoint, and binds the resulting tokens to whichever MCP session started the request. What is the flaw?

Nothing checks that the person who completes the OAuth flow is the person who started it. An attacker can forward the link to a victim and receive the victim's tokens. Point the elicitation at a connect page on your own server that verifies the browser session's subject matches the MCP user before redirecting to the third party.

Sources

Last reviewed: 24 October 2026. MCP revises its specification often and SDK APIs move with it; check the versioned specification and your SDK's changelog before you build on a specific behaviour.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.