The Model Context Protocol (MCP) provides a standardized way for servers to request additional information from users through the client during interactions. This flow allows clients to maintain control over user interactions and data sharing while enabling servers to gather necessary information dynamically. Servers request structured data from users with JSON schemas to validate responses.
https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation
You write the same handler code for every protocol version, and Phantom picks the transport.
Under MCP 2025-11-25 and earlier, the server sends elicitation/create on
the session's open SSE stream while the call is still running:
sequenceDiagram
participant User
participant Client
participant Server
Client->>Server: tools/call
Server->>Client: elicitation/create (SSE)
Client->>User: Present elicitation UI
User-->>Client: Provide requested information
Client-->>Server: Response to elicitation/create
Server-->>Client: tools/call resultUnder MCP 2026-07-28 there is no session stream. The call ends with an
input_required result that carries the request in inputRequests and an
encrypted requestState. The client asks the user, then calls again with
the answer in inputResponses and the same requestState. Any node can
receive the second call:
sequenceDiagram
participant User
participant Client
participant Server
Client->>Server: tools/call
Server-->>Client: input_required (inputRequests, requestState)
Client->>User: Present elicitation UI
User-->>Client: Provide requested information
Client->>Server: tools/call (inputResponses, requestState)
Server-->>Client: tools/call resultPhantom.Session.elicit/3 supports two styles. Waiting inline with
await: true returns the answer in place. Re-entry with :state ends the
call and runs the handler again with session.state set. Under 2026-07-28
they differ in what must survive between the two calls:
Waiting inline (await: true) | Re-entry (:state) | |
|---|---|---|
| Between calls | The handler's process waits on the node that started it | :state travels in the encrypted requestState |
| Second call | Any node resumes the waiting process | Any node runs the handler again |
| Router options | None | :secret_key_base and :request_state_salt, the same on every node |
| Fails when | That node restarts, or :timeout passes (default 5 minutes) | The keys change, or the client drops requestState |
Prefer re-entry when you run more than one node or deploy often.
Form mode
Form mode presents a structured form to the user within the client. The server defines the schema and the client renders it as a form. This is the default mode and works with any client that declares elicitation support.
Build a form elicitation with build/1 (or form/1) and send it
with Phantom.Session.elicit/3:
@elicit_name Phantom.Elicit.build(%{
message: "What is your info?",
requested_schema: [
%{type: :string, name: "name", required: true, title: "Your name"},
%{type: :string, name: "email", required: true, title: "Email", format: :email},
%{type: :enum, name: "role", required: true, title: "Role",
enum: [{"dev", "Developer"}, {"pm", "Product Manager"}]}
]
})
def my_tool(params, session) do
case Phantom.Session.elicit(session, @elicit_name, await: true) do
{:ok, %{"action" => "accept", "content" => content}} ->
{:reply, Tool.text("Hello #{content["name"]}"), session}
{:ok, _rejected} ->
{:reply, Tool.error("Rejected"), session}
:not_supported ->
# Client doesn't support elicitation; use a fallback
{:reply, Tool.text("Hello stranger"), session}
:timeout -> {:reply, Tool.error("Timed out"), session}
:error -> {:reply, Tool.error("Failed"), session}
end
endUnder 2026-07-28 this returns :not_supported unless the current request
declares elicitation in its client capabilities, because each request
declares its own.
Supported property types
:string— options::min_length,:max_length,:pattern(string orRegex),:format(:email,:uri,:date,:date_time):boolean— options::default:number/:integer— options::minimum,:maximum:enum— options::enum(list of values or{value, title}tuples),:multi(boolean),:min,:max,:default(a value, or a list of values when:multi)
All property types accept :name, :required, :title, and :description.
Re-entry form elicitation
As an alternative to inline blocking with await: true, call
Phantom.Session.elicit/3 without :await and pass :state. The
handler is re-entered on continuation with session.state populated:
def my_tool(%{"name" => name},
%Session{state: %{step: :got_name, params: orig}} = session) do
{:reply, Tool.text("Hello #{name}"), session}
end
def my_tool(params, session) do
{:noreply,
Session.elicit(session, @elicit_name, state: %{step: :got_name, params: params})}
endUnder 2026-07-28, Phantom answers with an input_required result and
encrypts :state into its requestState. The client's next call carries
the user's answer and the requestState back, Phantom decrypts it onto
session.state, and the handler runs again with the answer merged into
its params. Under earlier versions, Phantom sends elicitation/create
over SSE and runs the handler again when the answer arrives.
The router needs :secret_key_base and :request_state_salt for
2026-07-28, the same on every node; see Phantom.Router. Keep :state
small, because it goes to the client on every round trip. It is
encrypted, so the client cannot read or change it.
To build the result yourself, pass to_input_requests/1 to
Phantom.Tool.input_required/1 as :input_requests.
Input requests in a task
A task in input_required lists its questions under keys you choose.
Render each one with to_input_request/1:
%{"confirm" => Phantom.Elicit.to_input_request(elicit)}The client answers with tasks/update. See the Tasks guide.
URL mode
URL mode directs the user to an external URL (e.g., an OAuth flow or a custom form hosted by your application). The client asks the user for consent, opens the URL, and answers right away.
Client support
Cursor supports elicitation (both form and URL mode). Claude Desktop does not support elicitation at this time.
An accept only means the user agreed to open the URL, not that
they finished. Your app knows when they finish, so keep a record
of the flow and check it before continuing.
This mode uses two identifiers:
- JSON-RPC
request_id— managed automatically by Phantom. elicitation_id— an identifier you provide and embed in the URL, so your page knows which flow it is finishing.
sequenceDiagram
participant Server
participant Client
participant Browser
participant App as Your App (URL)
Server->>Client: elicitation/create (mode: url, elicitationId)
Client-->>Server: JSON-RPC response (action: accept)
Client->>Browser: Open URL
Browser->>App: User completes flow
App->>App: Record completion for the user
opt 2025-11-25 and earlier
Server->>Client: notifications/elicitation/complete (elicitationId)
endBind the flow to the user who started it, and have your page check that the signed-in user is the same one. Otherwise someone could send their link to another user and have that user complete it for them.
Returning {:elicitation_required, elicitations}
The simplest way to use URL mode is to end the call until your record says the user is done. The client sends the user to each URL and then calls the tool again:
def my_tool(_params, session) do
user = session.assigns.user
if MyApp.Auth.authorized?(user) do
{:reply, Tool.text("Authenticated"), session}
else
elicitation_id = UUIDv7.generate()
MyApp.Auth.start(user, elicitation_id)
{:elicitation_required, [
Phantom.Elicit.url(%{
message: "Please authenticate first",
url: "https://example.com/oauth/#{elicitation_id}",
elicitation_id: elicitation_id
})
]}
end
endAnd in your page's controller:
def callback(conn, %{"elicitation_id" => elicitation_id}) do
case MyApp.Auth.get(elicitation_id) do
%{user_id: user_id} = flow when user_id == conn.assigns.current_user.id ->
MyApp.Auth.complete(flow)
render(conn, :done)
_ ->
conn |> put_status(:forbidden) |> render(:wrong_user)
end
endHow the call ends depends on the client's protocol version:
- 2025-11-25 and earlier — a JSON-RPC error with code
-32042containing the elicitation specs. The retry is a new call. - 2026-07-28 — an
input_requiredresult. If the client declines or cancels, Phantom ends the call with a tool error; if it accepts, the handler runs again with the IDs it issued insession.state.elicitation_ids, for apps that track flows by ID. Returning{:elicitation_required, ...}again asks again.
Phantom does not remember these elicitations, so
Phantom.Tracker.notify_elicitation_complete/1 does not reach them.
Waiting inline
Phantom.Session.elicit/3 with await: true returns the client's
answer to a URL elicitation, which comes before the user finishes:
case Phantom.Session.elicit(session, elicitation, await: true) do
{:ok, %{"action" => "accept"}} ->
# Wait for your page to record completion, e.g. on PubSub.
if MyApp.Auth.await_authorized(user, :timer.minutes(5)),
do: {:reply, Tool.text("Authenticated"), session},
else: {:reply, Tool.error("Timed out"), session}
{:ok, _declined_or_cancelled} ->
{:reply, Tool.error("Auth rejected"), session}
:not_supported ->
{:reply, Tool.error("URL elicitation not supported"), session}
_timeout_or_error ->
{:reply, Tool.error("Failed"), session}
endUnder 2025-11-25 and earlier, Phantom remembers the elicitation_id,
so your page may also call Phantom.Tracker.notify_elicitation_complete/1
to tell the client the flow finished. 2026-07-28 has no such
notification.
Phantom.Session.elicit_url/4 is a shortcut that generates the
elicitation_id for you. Because the URL cannot include it, your
page cannot tell which flow it is finishing; use it only when the
user is enough to find the flow.
URL mode client support
URL mode requires the client to advertise "url" in its
elicitation capabilities (e.g., elicitation: %{"url" => %{}}).
If the client only sends elicitation: %{}, URL mode returns
:not_supported while form mode still works.
Summary
Functions
Generate a deterministic JSON-RPC request id for an elicitation.
Build a form mode elicitation
Build the elicitation/create JSON-RPC request, assign it a
deterministic id, and register it with Phantom.Tracker so
the client's eventual response can route back.
Render an Elicit struct as one embedded elicitation/create request, to
place under a key of your choosing in inputRequests, such as a task's
:input_requests (see Phantom.Tasks).
Render an Elicit struct as the MCP 2026-07-28 inputRequests map,
which a tool or prompt returns in an input_required result.
Build a URL mode elicitation
Types
@type number_property() :: %{ name: String.t(), required: boolean(), type: :number | :integer, title: String.t(), description: String.t(), minimum: pos_integer(), maximum: pos_integer() }
@type string_property() :: %{ name: String.t(), required: boolean(), type: :string, title: String.t(), description: String.t(), min_length: pos_integer(), max_length: pos_integer(), pattern: String.t() | Regex.t(), format: :email | :uri | :date | :datetime }
@type t() :: %Phantom.Elicit{ elicitation_id: String.t() | nil, message: String.t(), mode: :form | :url, requested_schema: [ number_property() | boolean_property() | enum_property() | string_property() ] | nil, url: String.t() | nil }
Functions
@spec build(%{ message: String.t(), requested_schema: [ number_property() | boolean_property() | enum_property() | string_property() ] }) :: t()
Generate a deterministic JSON-RPC request id for an elicitation.
Two dispatches of the same tool call (e.g. when a proxy retries across nodes) produce the same elicitation id, so the client sees identical duplicates rather than two logically distinct requests.
When tool_call_id is nil (elicit called outside a request
context), falls back to UUIDv7.generate/0 for uniqueness.
Build a form mode elicitation
Build the elicitation/create JSON-RPC request, assign it a
deterministic id, and register it with Phantom.Tracker so
the client's eventual response can route back.
Returns {request, ref}. The caller should write the request
to its transport and receive do {:phantom_elicitation_response, ^ref, response}.
Calling this function does not block.
Render an Elicit struct as one embedded elicitation/create request, to
place under a key of your choosing in inputRequests, such as a task's
:input_requests (see Phantom.Tasks).
%{"confirm" => Phantom.Elicit.to_input_request(elicit)}
Render an Elicit struct as the MCP 2026-07-28 inputRequests map,
which a tool or prompt returns in an input_required result.
Build a URL mode elicitation