Phantom.Elicit (phantom_mcp v0.7.3)

Copy Markdown View Source

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 result

Under 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 result

Phantom.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 callsThe handler's process waits on the node that started it:state travels in the encrypted requestState
Second callAny node resumes the waiting processAny node runs the handler again
Router optionsNone:secret_key_base and :request_state_salt, the same on every node
Fails whenThat 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
end

Under 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 or Regex), :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})}
end

Under 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)
    end

Bind 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
end

And 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
end

How the call ends depends on the client's protocol version:

  • 2025-11-25 and earlier — a JSON-RPC error with code -32042 containing the elicitation specs. The retry is a new call.
  • 2026-07-28 — an input_required result. 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 in session.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}
end

Under 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

boolean_property()

@type boolean_property() :: %{
  name: String.t(),
  required: boolean(),
  type: :boolean,
  title: String.t(),
  description: String.t(),
  default: boolean()
}

enum_property()

@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(),
  default: String.t() | [String.t()]
}

json()

@type json() :: %{
  message: String.t(),
  requestedSchema: %{
    type: String.t(),
    required: [String.t()],
    properties: %{required(String.t()) => map()}
  }
}

number_property()

@type number_property() :: %{
  name: String.t(),
  required: boolean(),
  type: :number | :integer,
  title: String.t(),
  description: String.t(),
  minimum: pos_integer(),
  maximum: pos_integer()
}

string_property()

@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
}

t()

@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

build(attrs)

@spec build(%{
  message: String.t(),
  requested_schema: [
    number_property() | boolean_property() | enum_property() | string_property()
  ]
}) :: t()

deterministic_id(session_id, tool_call_id, elicitation)

@spec deterministic_id(String.t(), term() | nil, t()) :: String.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.

form(attrs)

Build a form mode elicitation

prepare_request(session_id, tool_call_id, elicitation)

@spec prepare_request(String.t(), term() | nil, t()) :: {struct(), reference()}

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.

to_input_request(elicit)

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)}

to_input_requests(elicit)

Render an Elicit struct as the MCP 2026-07-28 inputRequests map, which a tool or prompt returns in an input_required result.

to_json(elicit)

url(attrs)

@spec url(%{message: String.t(), url: String.t(), elicitation_id: String.t()}) :: t()

Build a URL mode elicitation