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.
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.
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.
{"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" }.
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'