Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions lib/anubis/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ defmodule Anubis.Client do
import Peri

alias Anubis.Client.Cache
alias Anubis.Client.Elicitation
alias Anubis.Client.Handlers
alias Anubis.Client.Operation
alias Anubis.Client.Request
Expand All @@ -99,14 +100,14 @@ defmodule Anubis.Client do

require Message

@client_capabilities ~w(roots sampling)a
@client_capabilities ~w(roots sampling elicitation)a

@default_protocol_version Protocol.latest_version()
@default_operation_timeout to_timeout(second: 30)

@type t :: GenServer.server()

@type capability :: :roots | :sampling
@type capability :: :roots | :sampling | :elicitation
@type capability_opts :: [list_changed?: boolean()]
@type capabilities_input :: [capability() | {capability(), capability_opts()} | map()]

Expand Down Expand Up @@ -190,14 +191,16 @@ defmodule Anubis.Client do
- `:roots` - Capabilities related to the roots resource
- `:listChanged` - Whether the client can handle listChanged notifications
- `:sampling` - Capabilities related to sampling
- `:elicitation` - Capabilities related to elicitation (server-initiated user input requests, 2025-06-18)

MCP describes these client capabilities on it [specification](https://spec.modelcontextprotocol.io/specification/2024-11-05/client/)
MCP describes these client capabilities on its [specification](https://spec.modelcontextprotocol.io/specification/2025-06-18/client/)
"""
@type capabilities :: %{
optional(:roots | String.t()) => %{
optional(:listChanged | String.t()) => boolean
},
optional(:sampling | String.t()) => %{}
optional(:sampling | String.t()) => %{},
optional(:elicitation | String.t()) => %{}
}

@typedoc """
Expand Down Expand Up @@ -899,6 +902,45 @@ defmodule Anubis.Client do
GenServer.call(client, :unregister_sampling_callback)
end

@typedoc """
Elicitation callback function type.

Called when the server sends an `elicitation/create` request. The callback
receives the human-readable `message` and the `requestedSchema` (a restricted
JSON Schema subset). It must return one of:

* `{:accept, content}` — user submitted `content` (a flat map matching the schema)
* `:decline` — user explicitly declined
* `:cancel` — user dismissed without an explicit choice
* `{:error, reason}` — internal error; sent back as a JSON-RPC error
"""
@type elicitation_callback ::
(message :: String.t(), requested_schema :: map() ->
{:accept, map()} | :decline | :cancel | {:error, String.t()})

@doc """
Registers a callback function to handle elicitation requests from the server.

The client must advertise the `elicitation` capability during initialization
for servers to send `elicitation/create` requests.

Per the MCP specification, the client SHOULD present the request to the user
with clear UI, allow them to review and modify their response, and provide
decline/cancel options.
"""
@spec register_elicitation_callback(t, elicitation_callback) :: :ok
def register_elicitation_callback(client, callback) when is_function(callback, 2) do
GenServer.call(client, {:register_elicitation_callback, callback})
end

@doc """
Unregisters the elicitation callback.
"""
@spec unregister_elicitation_callback(t) :: :ok
def unregister_elicitation_callback(client) do
GenServer.call(client, :unregister_elicitation_callback)
end

@doc """
Closes the client connection and terminates the process.
"""
Expand Down Expand Up @@ -1021,6 +1063,14 @@ defmodule Anubis.Client do
{:reply, :ok, State.clear_sampling_callback(state)}
end

def handle_call({:register_elicitation_callback, callback}, _from, state) do
{:reply, :ok, State.set_elicitation_callback(state, callback)}
end

def handle_call(:unregister_elicitation_callback, _from, state) do
{:reply, :ok, State.clear_elicitation_callback(state)}
end

def handle_call({:register_progress_callback, token, callback}, _from, state) do
{:reply, :ok, State.register_progress_callback(state, token, callback)}
end
Expand Down Expand Up @@ -1239,6 +1289,10 @@ defmodule Anubis.Client do
{:noreply, Sampling.handle_request(request, state)}
end

defp handle_server_request(%{"method" => "elicitation/create"} = request, state) do
{:noreply, Elicitation.handle_request(request, state)}
end

@impl true
def handle_info({:request_timeout, request_id}, state) do
case State.handle_request_timeout(state, request_id) do
Expand Down
150 changes: 150 additions & 0 deletions lib/anubis/client/elicitation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
defmodule Anubis.Client.Elicitation do
@moduledoc false

use Anubis.Logging

alias Anubis.Client.State
alias Anubis.MCP.ElicitationSchema
alias Anubis.MCP.Error
alias Anubis.MCP.Message
alias Anubis.Telemetry

@spec handle_request(msg :: map(), State.t()) :: State.t()
def handle_request(%{"id" => id} = msg, state) do
params = Map.get(msg, "params", %{})

case validate_elicitation_capability(state) do
:ok ->
handle_elicitation_with_callback(id, params, state)

{:error, reason} ->
send_elicitation_error(id, reason, "capability_disabled", %{}, state)
end
end

defp validate_elicitation_capability(state) do
if Map.has_key?(state.capabilities, "elicitation") do
:ok
else
{:error, "Client does not have elicitation capability enabled"}
end
end

defp handle_elicitation_with_callback(id, params, state) do
case State.get_elicitation_callback(state) do
nil ->
send_elicitation_error(
id,
"No elicitation callback registered",
"elicitation_not_configured",
%{},
state
)

callback when is_function(callback, 2) ->
execute_elicitation_callback(id, params, callback, state)
end
end

defp execute_elicitation_callback(id, params, callback, state) do
message = Map.get(params, "message", "")
requested_schema = Map.get(params, "requestedSchema", %{})

Task.start(fn ->
try do
case callback.(message, requested_schema) do
{:accept, content} when is_map(content) ->
handle_accept(id, content, requested_schema, state)

:decline ->
send_elicitation_response(id, %{"action" => "decline"}, state)

:cancel ->
send_elicitation_response(id, %{"action" => "cancel"}, state)

{:error, reason} ->
send_elicitation_error(id, reason, "elicitation_error", %{}, state)
end
rescue
e ->
send_elicitation_error(
id,
"Elicitation callback error: #{Exception.message(e)}",
"elicitation_callback_error",
%{},
state
)
end
end)

state
end

defp handle_accept(id, content, requested_schema, state) do
case ElicitationSchema.validate_content(content, requested_schema) do
:ok ->
send_elicitation_response(id, %{"action" => "accept", "content" => content}, state)

{:error, reason} ->
send_elicitation_error(
id,
"Elicitation content does not match requested schema: #{reason}",
"invalid_elicitation_content",
%{},
state
)
end
end

defp send_elicitation_response(id, result, state) do
case Message.encode_elicitation_response(%{"result" => result}, id) do
{:ok, encoded} ->
transport = state.transport
:ok = transport.layer.send_message(transport.name, encoded, timeout: state.timeout)

Telemetry.execute(
Telemetry.event_client_response(),
%{system_time: System.system_time()},
%{id: id, method: "elicitation/create"}
)

{:error, [%Peri.Error{} | _] = errors} ->
send_elicitation_error(
id,
"Invalid elicitation response",
"invalid_elicitation_response",
errors,
state
)

{:error, reason} ->
send_elicitation_error(
id,
"Invalid elicitation response: #{inspect(reason)}",
"invalid_elicitation_response",
reason,
state
)
end
end

defp send_elicitation_error(id, message, code, reason, %{transport: transport} = state) do
error = %Error{code: -1, message: message, data: %{"reason" => reason}}
{:ok, response} = Error.to_json_rpc(error, id)
:ok = transport.layer.send_message(transport.name, response, timeout: state.timeout)

Logging.client_event(
"elicitation_error",
%{id: id, error_code: code, error_message: message},
level: :error
)

Telemetry.execute(
Telemetry.event_client_error(),
%{system_time: System.system_time()},
%{id: id, method: "elicitation/create", error_code: code}
)

state
end
end
36 changes: 36 additions & 0 deletions lib/anubis/client/state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ defmodule Anubis.Client.State do
progress_callbacks: %{String.t() => Client.progress_callback()},
log_callback: Client.log_callback() | nil,
sampling_callback: (map() -> {:ok, map()} | {:error, String.t()}) | nil,
elicitation_callback:
(String.t(), map() -> {:accept, map()} | :decline | :cancel | {:error, String.t()})
| nil,
roots: %{String.t() => Client.root()},
ready_waiters: [GenServer.from()],
transport_parse_state: map | nil
Expand All @@ -37,6 +40,7 @@ defmodule Anubis.Client.State do
progress_callbacks: %{},
log_callback: nil,
sampling_callback: nil,
elicitation_callback: nil,
roots: %{},
ready_waiters: [],
transport_parse_state: nil
Expand Down Expand Up @@ -614,6 +618,38 @@ defmodule Anubis.Client.State do
%{state | sampling_callback: nil}
end

@doc """
Sets the elicitation callback function.

Callback receives `(message, requested_schema)` and returns one of
`{:accept, content}`, `:decline`, `:cancel`, or `{:error, reason}`.
"""
@spec set_elicitation_callback(
t(),
(String.t(), map() ->
{:accept, map()} | :decline | :cancel | {:error, String.t()})
) :: t()
def set_elicitation_callback(state, callback) when is_function(callback, 2) do
%{state | elicitation_callback: callback}
end

@doc """
Gets the elicitation callback function.
"""
@spec get_elicitation_callback(t()) ::
(String.t(), map() ->
{:accept, map()} | :decline | :cancel | {:error, String.t()})
| nil
def get_elicitation_callback(state), do: state.elicitation_callback

@doc """
Clears the elicitation callback function.
"""
@spec clear_elicitation_callback(t()) :: t()
def clear_elicitation_callback(state) do
%{state | elicitation_callback: nil}
end

# Helper functions

defp valid_capability?(_capabilities, ["ping"]), do: true
Expand Down
Loading
Loading