MCP 2026-07-28 one lesson per page
23 lessons
Security-criticalInteractions · SecurityTrack 1 · 4 / 9

When the server needs the user: elicitation, sampling, roots

Servers can ask for three things through input_required: information from the user (elicitation), a model completion (sampling), or the user's folders (roots). Only one of them is not deprecated, and it has a strict rule about secrets.

Listen to this lessonAudio overview in Gemini Notebook · about 15–25 min · opens in a new tab

The API key that never touched the chat

A billing server needs the user's payment-provider API key to connect their account.

The wrong way: a form-mode elicitation asking for api_key. The key now travels through the client, may be logged, may end up in the model's context, and is one prompt-injection away from leaking. The spec forbids this outright.

The right way: URL mode. The client shows "mcp.example.com wants to open https://mcp.example.com/ui/set_api_key". The user checks the domain, agrees, and types the key on the server's own page. The client only learns "the user accepted". On the retry, the server already has the key. The secret never crossed the MCP connection.

Elicitation: elicitation/create inside inputRequests.

  • Form mode: requestedSchema is a flat object of primitives: string (formats email, uri, date, date-time), number or integer, boolean, enum. No nested objects or arrays of objects.
  • URL mode: url + message, for "sensitive interactions that must not pass through the MCP client".
  • Servers MUST NOT use form mode for passwords, API keys, access tokens or payment credentials, and MUST use URL mode for them.
  • Results: accept (with content in form mode; none in URL mode), decline, or cancel. In URL mode, accept means "the user consented", not "the interaction is complete".
  • Clients MUST show which server is asking, offer decline and cancel, let users review form answers, and for URL mode show the full URL and get consent. They MUST NOT pre-fetch or auto-open the URL.
  • Servers MUST NOT send a pre-authenticated URL, MUST NOT rely on URL elicitation to authorize users for themselves, and MUST NOT pass credentials obtained this way back to the client.
  • Capability: "elicitation": {"form": {}, "url": {}}. An empty object means form only. Servers MUST NOT use a mode the client didn't declare.

Sampling (sampling/createMessage) borrows the client's model. It's deprecated; integrate with an LLM API directly. If you meet it: maxTokens is required, model preferences are hints, and a human SHOULD be able to review and deny the request. Roots (roots/list) is deprecated too, and was always guidance, never access control.

The server can't talk to the user directly; only the host can. Elicitation gives it a safe, structured way to ask. Splitting off URL mode keeps secrets out of the client, the model's context and the logs, which is where prompt injection and accidental leaks happen.

URL mode: the secret goes around the client
UserClientServertools/call connect_billinginput_required {mode: "url", url}show full URL + domain, ask consentacceptUser enters the key on the server's own page, outside MCPretry: inputResponses {action: "accept"}result: billing connected
Solid arrows are requests, dashed are responses or notifications. Red ✕ is the unsafe or failing step; green is the step that keeps you safe.

Asking for a password

Unsafeform mode: forbidden for secrets
{  "method": "elicitation/create",  "params": {    "mode": "form",    "message": "Enter your API key",    "requestedSchema": {      "type": "object",      "properties": { "api_key": { "type": "string" } },      "required": ["api_key"]    }  }}
SafeURL mode
{  "method": "elicitation/create",  "params": {    "mode": "url",    "url": "https://mcp.example.com/ui/set_api_key",    "message": "Please provide your API key to continue."  }}

The URL-mode request is verbatim from the spec; the form-mode one is an illustration of what not to do. A URL-mode accept carries no content: just { "action": "accept" }.

A legitimate form, and the answer

Exampleform mode request
{  "method": "elicitation/create",  "params": {    "mode": "form",    "message": "Please provide your GitHub username",    "requestedSchema": {      "type": "object",      "properties": { "name": { "type": "string" } },      "required": ["name"]    }  }}
Examplethe client's answer, sent in inputResponses
{  "action": "accept",  "content": {    "name": "octocat"  }}
  • Secrets in forms are a spec violation and a leak waiting to happen: logs, transcripts, model context.
  • Assuming yes. Servers SHOULD NOT assume elicitation succeeds and MUST handle decline and cancel.
  • Treating URL accept as done. It only means the user agreed to go; the server must check that the flow actually finished.
  • Building on sampling or roots in new code: both are deprecated, with at least twelve months before removal.
First time here? Set up the test helper (once per terminal)
ShellSetup
# 1. In a SECOND terminal, start the reference server (Node 18+, no dependencies)curl -sO https://www.diegozuluaga.dev/mcpa/reference-server.mjsnode reference-server.mjs                 # http://localhost:3000/mcp, logs appear here # 2. In THIS terminal, define the helper every test uses (bash or zsh)export MCP=http://localhost:3000/mcpMETA='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'mcp() {  # usage: mcp <method> '<json body>' [extra curl args...]  curl -sS -N "$MCP" \    -H 'Content-Type: application/json' \    -H 'Accept: application/json, text/event-stream' \    -H 'MCP-Protocol-Version: 2026-07-28' \    -H "Authorization: Bearer ${MCP_USER:-alice}" \    -H "Mcp-Method: $1" "${@:3}" -d "$2" \    -w '\nHTTP %{http_code}\n'}# Demo auth: the reference server treats the bearer token as the user's name.# Prefix a command with MCP_USER=bob to act as someone else.
ShellTests
# 1. A client that only supports form mode (empty elicitation capability): the server can't askMETA_F='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}'mcp tools/call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"connect_billing","arguments":{},'"$META_F"'}}' -H 'Mcp-Name: connect_billing'# expect: 400 and -32021, requiredCapabilities ["elicitation.url"] # 2. A client that supports URL mode: input_required with mode "url"META_U='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{},"url":{}}}}'mcp tools/call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"connect_billing","arguments":{},'"$META_U"'}}' -H 'Mcp-Name: connect_billing' | tee /tmp/bill.txtSTATE=$(sed -n 's/.*"requestState":"\([^"]*\)".*/\1/p' /tmp/bill.txt) # 3. The user accepted (and typed the key on the server's page): retrymcp tools/call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"connect_billing","arguments":{},"inputResponses":{"billing_key":{"action":"accept"}},"requestState":"'"$STATE"'",'"$META_U"'}}' -H 'Mcp-Name: connect_billing' # 4. Same again but the user declined: the server must handle it (isError result, no crash)mcp tools/call '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"connect_billing","arguments":{},"inputResponses":{"billing_key":{"action":"decline"}},"requestState":"'"$STATE"'",'"$META_U"'}}' -H 'Mcp-Name: connect_billing'

Answer all 4 correctly and this lesson is marked as learned.

Q1A server needs the user's database password. How should it ask?

Q2A URL-mode elicitation comes back with { "action": "accept" }. What does that tell the server?

Q3A client declares "elicitation": {} with no sub-fields. Which modes may the server use?

Q4Spot the bug: to save the user a click, a client automatically opens the URL from a URL-mode elicitation in a background browser tab.

Feedback or a correction? Email diego [at] diegozuluaga [dot] dev or open an issue on GitHub.

Content CC BY 4.0 · Code MIT

Tip: ← and → move between lessons. Hover any heading and press # to copy a link to it.