Phantom.Elicit (phantom_mcp v0.4.5)

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

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 information

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

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

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 waiting Phantom.Session.elicit/3 call.
  • elicitation_id — an application-level identifier you provide. When the user completes the external flow, your backend calls Phantom.Tracker.notify_elicitation_complete/1 with 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
end

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

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.

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

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

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_json(elicit)

url(attrs)

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

Build a URL mode elicitation