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
sequenceDiagram
participant User
participant Client
participant Server
Note over Server,Client: Server initiates elicitation
Server->>Client: elicitation/create
Note over Client,User: Human interaction
Client->>User: Present elicitation UI
User-->>Client: Provide requested information
Note over Server,Client: Complete request
Client-->>Server: Return user response
Note over Server: Continue processing with new informationForm 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) 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
endSupported 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
All property types accept :name, :required, :title, and :description.
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 opens the URL in a browser and waits for the server to signal completion.
Client support
Cursor supports elicitation (both form and URL mode). Claude Desktop does not support elicitation at this time.
This mode requires two identifiers:
- JSON-RPC
request_id— managed automatically by Phantom. The client includes this in its JSON-RPC response to unblock the waitingPhantom.Session.elicit/3call. elicitation_id— an application-level identifier you provide. When the user completes the external flow, your backend callsPhantom.Tracker.notify_elicitation_complete/1with this ID to notify the client that the URL workflow is finished.
sequenceDiagram
participant Server
participant Client
participant Browser
participant App as Your App (URL)
Server->>Client: elicitation/create (mode: url, elicitationId)
Client->>Browser: Open URL
Browser->>App: User completes flow
App->>Server: Flow complete
Server->>Client: notifications/elicitation/complete (elicitationId)
Client-->>Server: JSON-RPC response (action: accept)The elicitation_id must be embedded in the URL so that your
backend can identify which elicitation to complete when the user
finishes the external flow. Generate the ID yourself, include
it in the URL, and use Phantom.Session.elicit/3 with url/1:
def my_tool(params, session) do
elicitation_id = UUIDv7.generate()
url = "https://example.com/oauth?elicitation_id=#{elicitation_id}"
elicitation = Phantom.Elicit.url(%{
message: "Please authenticate",
url: url,
elicitation_id: elicitation_id
})
case Phantom.Session.elicit(session, elicitation) do
{:ok, %{"action" => "accept", "content" => content}} ->
{:reply, Tool.text("Authenticated"), session}
{:ok, _rejected} ->
{:reply, Tool.error("Auth rejected"), session}
:not_supported ->
{:reply, Tool.error("URL elicitation not supported"), session}
:timeout -> {:reply, Tool.error("Timed out"), session}
:error -> {:reply, Tool.error("Failed"), session}
end
endThen in your callback controller, extract the ID and notify:
def callback(conn, %{"elicitation_id" => elicitation_id}) do
Phantom.Tracker.notify_elicitation_complete(elicitation_id)
# Render a success page for the user
endURL 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.
Returning {:elicitation_required, elicitations}
For tools that cannot proceed without user interaction, you can
return {:elicitation_required, elicitations} directly from
the tool handler. This returns a JSON-RPC error with code -32042
containing the elicitation specs, allowing the client to initiate
the flow:
def my_tool(_params, _session) do
{:elicitation_required, [
Phantom.Elicit.url(%{
message: "Please authenticate first",
url: "https://example.com/oauth?elicitation_id=unique-id",
elicitation_id: "unique-id"
})
]}
end
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.
Build a URL mode elicitation
Types
@type enum_property() :: %{ name: String.t(), required: boolean(), type: :enum, title: String.t(), description: String.t(), enum: [String.t() | {value :: String.t(), title :: String.t()}], multi: boolean(), min: pos_integer(), max: pos_integer() }
@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.
Build a URL mode elicitation