diff --git a/lib/anubis/client.ex b/lib/anubis/client.ex index 57765c33..63348e49 100644 --- a/lib/anubis/client.ex +++ b/lib/anubis/client.ex @@ -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 @@ -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()] @@ -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 """ @@ -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. """ @@ -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 @@ -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 diff --git a/lib/anubis/client/elicitation.ex b/lib/anubis/client/elicitation.ex new file mode 100644 index 00000000..8dc78432 --- /dev/null +++ b/lib/anubis/client/elicitation.ex @@ -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 diff --git a/lib/anubis/client/state.ex b/lib/anubis/client/state.ex index 719a9c31..85813420 100644 --- a/lib/anubis/client/state.ex +++ b/lib/anubis/client/state.ex @@ -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 @@ -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 @@ -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 diff --git a/lib/anubis/mcp/elicitation_schema.ex b/lib/anubis/mcp/elicitation_schema.ex new file mode 100644 index 00000000..c45c24f1 --- /dev/null +++ b/lib/anubis/mcp/elicitation_schema.ex @@ -0,0 +1,288 @@ +defmodule Anubis.MCP.ElicitationSchema do + @moduledoc """ + Validator for the restricted JSON Schema subset allowed in elicitation requests. + + Per the MCP 2025-06-18 specification, an `elicitation/create` `requestedSchema` + must be a flat object whose properties are all primitives. This module validates + both the schema map itself (`validate/1`) and content payloads against a + previously validated schema (`validate_content/2`). + + Permitted property schemas: + + * `string` with optional `minLength`, `maxLength`, `format` + (one of `"email"`, `"uri"`, `"date"`, `"date-time"`) + * `string` enum with `enum` and optional matching `enumNames` + * `number` / `integer` with optional `minimum`, `maximum` + * `boolean` with optional `default` + """ + + import Peri + + @permitted_string_formats ~w(email uri date date-time) + + @string_property_schema %{ + "type" => {:required, {:literal, "string"}}, + "title" => :string, + "description" => :string, + "minLength" => {:integer, {:gte, 0}}, + "maxLength" => {:integer, {:gte, 0}}, + "format" => {:enum, @permitted_string_formats} + } + + @enum_property_schema %{ + "type" => {:required, {:literal, "string"}}, + "title" => :string, + "description" => :string, + "enum" => {:required, {:list, :string}}, + "enumNames" => {:list, :string} + } + + @numeric_property_schema %{ + "type" => {:required, {:enum, ~w(number integer)}}, + "title" => :string, + "description" => :string, + "minimum" => {:either, {:integer, :float}}, + "maximum" => {:either, {:integer, :float}} + } + + @boolean_property_schema %{ + "type" => {:required, {:literal, "boolean"}}, + "title" => :string, + "description" => :string, + "default" => :boolean + } + + defschema(:requested_schema, %{ + "type" => {:required, {:literal, "object"}}, + "properties" => {:map, :string, {:custom, &__MODULE__.validate_property/1}}, + "required" => {:list, :string} + }) + + @doc """ + Validates a `requestedSchema` map fits the elicitation subset. + + Returns `:ok` or `{:error, reason}` where `reason` is a human-readable string. + """ + @spec validate(term()) :: :ok | {:error, String.t()} + def validate(schema) when is_map(schema) do + with {:ok, validated} <- requested_schema(schema), + :ok <- validate_required_declared(validated) do + :ok + else + {:error, reason} when is_binary(reason) -> {:error, reason} + {:error, errors} when is_list(errors) -> {:error, format_errors(errors)} + end + end + + def validate(_), do: {:error, "requestedSchema must be a map"} + + @doc false + @spec validate_property(term()) :: :ok | {:error, String.t(), keyword()} + def validate_property(prop) when is_map(prop) do + schema = dispatch_property_schema(prop) + + with {:ok, _validated} <- Peri.validate(schema, prop), + :ok <- validate_enum_names_match(prop) do + :ok + else + {:error, errors} when is_list(errors) -> + {:error, format_errors(errors), []} + + {:error, reason} when is_binary(reason) -> + {:error, reason, []} + end + end + + def validate_property(other), do: {:error, "property must be a map, got %{actual}", actual: inspect(other)} + + defp dispatch_property_schema(%{"enum" => _}), do: @enum_property_schema + defp dispatch_property_schema(%{"type" => "string"}), do: @string_property_schema + defp dispatch_property_schema(%{"type" => "number"}), do: @numeric_property_schema + defp dispatch_property_schema(%{"type" => "integer"}), do: @numeric_property_schema + defp dispatch_property_schema(%{"type" => "boolean"}), do: @boolean_property_schema + defp dispatch_property_schema(_), do: @string_property_schema + + defp validate_enum_names_match(%{"enum" => enum, "enumNames" => names}) do + if length(enum) == length(names) do + :ok + else + {:error, "enumNames must have the same length as enum"} + end + end + + defp validate_enum_names_match(_), do: :ok + + defp validate_required_declared(%{"required" => required, "properties" => properties}) + when is_list(required) and is_map(properties) do + case Enum.find(required, fn name -> not Map.has_key?(properties, name) end) do + nil -> :ok + missing -> {:error, "required property #{inspect(missing)} is not declared in properties"} + end + end + + defp validate_required_declared(_), do: :ok + + @doc """ + Validates a content map against an already-validated elicitation schema. + + Returns `:ok` or `{:error, reason}`. + """ + @spec validate_content(term(), map()) :: :ok | {:error, String.t()} + def validate_content(content, %{"type" => "object"} = requested) when is_map(content) do + properties = Map.get(requested, "properties", %{}) + required = Map.get(requested, "required", []) + + with :ok <- reject_unknown_keys(content, properties) do + peri_schema = build_content_schema(properties, required) + + case Peri.validate(peri_schema, content, mode: :strict) do + {:ok, _validated} -> :ok + {:error, errors} when is_list(errors) -> {:error, format_errors(errors)} + end + end + end + + def validate_content(content, %{"type" => "object"}) do + {:error, "content must be a map, got #{inspect(content)}"} + end + + def validate_content(_content, _schema) do + {:error, "schema must be an object schema"} + end + + defp reject_unknown_keys(content, properties) do + case Enum.find(Map.keys(content), fn k -> not Map.has_key?(properties, k) end) do + nil -> :ok + key -> {:error, "unknown property #{inspect(key)}"} + end + end + + defp build_content_schema(properties, required) do + required_set = MapSet.new(required) + + Map.new(properties, fn {name, prop_schema} -> + type = property_to_peri(prop_schema) + type = if MapSet.member?(required_set, name), do: {:required, type}, else: type + {name, type} + end) + end + + defp property_to_peri(%{"enum" => values}), do: {:enum, values} + + defp property_to_peri(%{"type" => "string"} = s) do + constraints = + [] + |> add_constraint(s, "minLength", :min) + |> add_constraint(s, "maxLength", :max) + + base = + case constraints do + [] -> :string + [single] -> {:string, single} + many -> {:string, many} + end + + case Map.get(s, "format") do + nil -> base + format -> {:custom, {__MODULE__, :validate_string_format, [format, base]}} + end + end + + defp property_to_peri(%{"type" => "integer"} = s) do + case numeric_constraints(s) do + [] -> :integer + [single] -> {:integer, single} + many -> {:integer, many} + end + end + + defp property_to_peri(%{"type" => "number"} = s) do + case numeric_constraints(s) do + [] -> {:either, {:integer, :float}} + [single] -> {:either, {{:integer, single}, {:float, single}}} + many -> {:either, {{:integer, many}, {:float, many}}} + end + end + + defp property_to_peri(%{"type" => "boolean"}), do: :boolean + + defp property_to_peri(_), do: :any + + defp add_constraint(acc, schema, json_key, peri_key) do + case Map.fetch(schema, json_key) do + {:ok, value} -> [{peri_key, value} | acc] + :error -> acc + end + end + + defp numeric_constraints(schema) do + [] + |> add_constraint(schema, "minimum", :gte) + |> add_constraint(schema, "maximum", :lte) + end + + @doc false + @spec validate_string_format(term(), String.t(), term()) :: + :ok | {:error, String.t(), keyword()} + def validate_string_format(value, format, base_type) do + with :ok <- run_base_string(value, base_type), + :ok <- check_format(value, format) do + :ok + else + {:error, reason} -> {:error, reason, []} + end + end + + defp run_base_string(value, :string) when is_binary(value), do: :ok + defp run_base_string(value, :string), do: {:error, "expected string, got #{inspect(value)}"} + + defp run_base_string(value, base) do + case Peri.validate(base, value) do + {:ok, _} -> :ok + {:error, errors} when is_list(errors) -> {:error, format_errors(errors)} + end + end + + defp check_format(value, "email") when is_binary(value) do + if String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do + :ok + else + {:error, "value is not a valid email"} + end + end + + defp check_format(value, "uri") when is_binary(value) do + case URI.new(value) do + {:ok, %URI{scheme: scheme}} when is_binary(scheme) and scheme != "" -> :ok + _ -> {:error, "value is not a valid URI"} + end + end + + defp check_format(value, "date") when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, _} -> :ok + _ -> {:error, "value is not a valid ISO 8601 date"} + end + end + + defp check_format(value, "date-time") when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, _, _} -> :ok + _ -> {:error, "value is not a valid ISO 8601 date-time"} + end + end + + defp check_format(value, format) do + {:error, "value #{inspect(value)} is not a valid #{format}"} + end + + defp format_errors(errors) do + errors + |> List.wrap() + |> Enum.map_join("; ", &format_error/1) + end + + defp format_error(%Peri.Error{message: message, path: path}) when path in [nil, []], do: message + defp format_error(%Peri.Error{message: message, path: path}), do: "#{Enum.join(path, ".")}: #{message}" + defp format_error(other), do: inspect(other) +end diff --git a/lib/anubis/mcp/message.ex b/lib/anubis/mcp/message.ex index eed62c8c..5d0b01b3 100644 --- a/lib/anubis/mcp/message.ex +++ b/lib/anubis/mcp/message.ex @@ -9,7 +9,7 @@ defmodule Anubis.MCP.Message do # MCP message schemas - @request_methods ~w(initialize ping resources/list resources/templates/list resources/read prompts/get prompts/list tools/call tools/list logging/setLevel completion/complete roots/list sampling/createMessage) + @request_methods ~w(initialize ping resources/list resources/templates/list resources/read prompts/get prompts/list tools/call tools/list logging/setLevel completion/complete roots/list sampling/createMessage elicitation/create) @init_params_schema %{ "protocolVersion" => {:required, :string}, @@ -116,6 +116,11 @@ defmodule Anubis.MCP.Message do "maxTokens" => :integer } + @elicitation_create_params %{ + "message" => {:required, :string}, + "requestedSchema" => {:required, {:custom, &Anubis.MCP.ElicitationSchema.validate/1}} + } + defschema( :request_schema, %{ @@ -149,6 +154,7 @@ defmodule Anubis.MCP.Message do defp parse_request_params_by_method(%{"method" => "logging/setLevel"}), do: {:ok, @set_log_level_params_schema} defp parse_request_params_by_method(%{"method" => "completion/complete"}), do: {:ok, @completion_complete_params_schema} defp parse_request_params_by_method(%{"method" => "sampling/createMessage"}), do: {:ok, @sampling_create_params} + defp parse_request_params_by_method(%{"method" => "elicitation/create"}), do: {:ok, @elicitation_create_params} defp parse_request_params_by_method(%{"method" => "roots/list"}), do: {:ok, :map} defp parse_request_params_by_method(_), do: {:ok, :map} @@ -236,6 +242,24 @@ defmodule Anubis.MCP.Message do mode: :strict ) + defschema( + :elicitation_result_schema, + %{ + "action" => {:required, {:enum, ~w(accept decline cancel)}}, + "content" => :map + } + ) + + defschema( + :elicitation_response_schema, + Map.put( + get_schema(:response_schema), + "result", + get_schema(:elicitation_result_schema) + ), + mode: :strict + ) + defschema( :error_schema, %{ @@ -559,6 +583,10 @@ defmodule Anubis.MCP.Message do encode_response(response, id, get_schema(:sampling_response_schema)) end + def encode_elicitation_response(response, id) do + encode_response(response, id, get_schema(:elicitation_response_schema)) + end + @doc """ Encodes a response message using a custom schema. diff --git a/lib/anubis/protocol/v2025_06_18.ex b/lib/anubis/protocol/v2025_06_18.ex index 6e6bfc19..9e94c5b7 100644 --- a/lib/anubis/protocol/v2025_06_18.ex +++ b/lib/anubis/protocol/v2025_06_18.ex @@ -32,10 +32,15 @@ defmodule Anubis.Protocol.V2025_06_18 do | @base_features ] - @request_methods V2025_03_26.request_methods() + @request_methods ["elicitation/create" | V2025_03_26.request_methods()] @notification_methods V2025_03_26.notification_methods() + @elicitation_create_params %{ + "message" => {:required, :string}, + "requestedSchema" => {:required, {:custom, &Anubis.MCP.ElicitationSchema.validate/1}} + } + @impl true def version, do: @version @@ -54,6 +59,8 @@ defmodule Anubis.Protocol.V2025_06_18 do end @impl true + def request_params_schema("elicitation/create"), do: @elicitation_create_params + def request_params_schema(method) do V2025_03_26.request_params_schema(method) end diff --git a/lib/anubis/server.ex b/lib/anubis/server.ex index ac9d8b82..4d7fd068 100644 --- a/lib/anubis/server.ex +++ b/lib/anubis/server.ex @@ -96,6 +96,7 @@ defmodule Anubis.Server do end """ + alias Anubis.MCP.ElicitationSchema alias Anubis.Server.Component alias Anubis.Server.Component.Prompt alias Anubis.Server.Component.Resource @@ -245,6 +246,14 @@ defmodule Anubis.Server do {:noreply, Frame.t()} | {:stop, reason :: term(), Frame.t()} + @callback handle_elicitation( + response :: map(), + request_id :: String.t(), + Frame.t() + ) :: + {:noreply, Frame.t()} + | {:stop, reason :: term(), Frame.t()} + @optional_callbacks handle_notification: 2, handle_info: 2, handle_call: 3, @@ -258,6 +267,7 @@ defmodule Anubis.Server do handle_sampling: 3, handle_completion: 3, handle_roots: 3, + handle_elicitation: 3, server_instructions: 0, handle_session_expired: 2 @@ -701,4 +711,68 @@ defmodule Anubis.Server do send(self(), {:send_roots_request, timeout}) :ok end + + @doc """ + Sends an `elicitation/create` request to the client. + + Per the MCP 2025-06-18 specification, the server provides a human-readable + `message` and a restricted-subset JSON `requested_schema` describing the + expected user input. The client presents this to the user and returns one of + three actions: `accept` (with content matching the schema), `decline`, or + `cancel`. + + This is an asynchronous operation. The response will be delivered to your + `handle_elicitation/3` callback. + + The `requested_schema` is validated synchronously before any wire I/O. The + client must advertise the `elicitation` capability or the call returns + `{:error, :capability_not_supported}` after enqueueing. + + ## Schema Subset + + * Top level must be `%{"type" => "object", "properties" => %{...}}` + * Properties may declare `"type"` of `"string"`, `"number"`, `"integer"`, + `"boolean"`, or use `"enum"` (string-only) + * String properties may set `"format"` of `"email"`, `"uri"`, `"date"`, + or `"date-time"` + + Per the spec, **servers MUST NOT request sensitive information** through + elicitation. + + ## Example + + defmodule MyServer.Tools.Greet do + use Anubis.Server.Component, type: :tool + + @impl true + def execute(_args, frame) do + Anubis.Server.send_elicitation_request("What's your name?", %{ + "type" => "object", + "properties" => %{ + "name" => %{"type" => "string", "minLength" => 1} + }, + "required" => ["name"] + }) + + {:reply, "asked for name", frame} + end + end + """ + @spec send_elicitation_request(String.t(), map(), configuration) :: + :ok | {:error, term()} + when configuration: list({:timeout, non_neg_integer() | nil}) + def send_elicitation_request(message, requested_schema, opts \\ []) + when is_binary(message) and is_map(requested_schema) do + with :ok <- ElicitationSchema.validate(requested_schema) do + timeout = Keyword.get(opts, :timeout, 30_000) + + params = %{ + "message" => message, + "requestedSchema" => requested_schema + } + + send(self(), {:send_elicitation_request, params, requested_schema, timeout}) + :ok + end + end end diff --git a/lib/anubis/server/session.ex b/lib/anubis/server/session.ex index db3bc07c..4dd76a46 100644 --- a/lib/anubis/server/session.ex +++ b/lib/anubis/server/session.ex @@ -15,6 +15,7 @@ defmodule Anubis.Server.Session do import Peri + alias Anubis.MCP.ElicitationSchema alias Anubis.MCP.Error alias Anubis.MCP.ID alias Anubis.MCP.Message @@ -343,6 +344,15 @@ defmodule Anubis.Server.Session do handle_roots_timeout(request_id, state) end + def handle_info({:send_elicitation_request, params, requested_schema, timeout}, state) do + request_id = ID.generate_request_id() + handle_elicitation_request_send(request_id, params, requested_schema, timeout, state) + end + + def handle_info({:elicitation_request_timeout, request_id}, state) do + handle_elicitation_timeout(request_id, state) + end + def handle_info(event, %{server_module: module} = state) do if Anubis.exported?(module, :handle_info, 2) do frame = prepare_frame(state) @@ -855,6 +865,9 @@ defmodule Anubis.Server.Session do "roots/list" -> handle_roots(result["roots"] || [], request_id, state) + "elicitation/create" -> + handle_elicitation(result, request_id, request_info, state) + _ -> {:noreply, state} end @@ -976,6 +989,129 @@ defmodule Anubis.Server.Session do end end + # Elicitation request helpers + + defp handle_elicitation_request_send(request_id, params, requested_schema, timeout, state) do + timer_ref = + Process.send_after(self(), {:elicitation_request_timeout, request_id}, timeout) + + request_info = %{ + id: request_id, + method: "elicitation/create", + session_id: state.session_id, + timer_ref: timer_ref, + requested_schema: requested_schema + } + + state = put_in(state.server_requests[request_id], request_info) + + with :ok <- validate_client_capability(state, "elicitation"), + {:ok, request_data} <- + encode_request("elicitation/create", params, request_id), + :ok <- send_to_transport(state.transport, request_data, timeout: state.timeout) do + Logging.server_event("sent_elicitation_request", %{request_id: request_id}) + {:noreply, state} + else + {:error, error} -> + Process.cancel_timer(timer_ref) + + state = %{ + state + | server_requests: Map.delete(state.server_requests, request_id) + } + + Logging.server_event( + "failed_send_elicitation_request", + %{request_id: request_id, error: error}, + level: :error + ) + + {:noreply, state} + end + end + + defp handle_elicitation_timeout(request_id, state) when is_binary(request_id) do + state.server_requests + |> Map.pop(request_id) + |> handle_elicitation_timeout(state) + end + + defp handle_elicitation_timeout({nil, _}, state), do: {:noreply, state} + + defp handle_elicitation_timeout({%{id: request_id}, requests}, state) do + with {:ok, notification} <- + encode_notification("notifications/cancelled", %{ + "requestId" => request_id, + "reason" => "timeout" + }), + :ok <- send_to_transport(state.transport, notification, timeout: state.timeout) do + Logging.server_event( + "elicitation_request_timeout_cancelled", + %{request_id: request_id} + ) + end + + Logging.server_event( + "elicitation_request_timeout", + %{request_id: request_id}, + level: :warning + ) + + {:noreply, %{state | server_requests: requests}} + end + + defp handle_elicitation(result, request_id, request_info, state) do + case sanitize_elicitation_result(result, request_info) do + {:ok, sanitized} -> + dispatch_elicitation(sanitized, request_id, state) + + {:error, reason} -> + Logging.server_event( + "invalid_elicitation_response", + %{request_id: request_id, reason: reason}, + level: :error + ) + + {:noreply, state} + end + end + + defp sanitize_elicitation_result(%{"action" => "accept", "content" => content} = result, %{requested_schema: schema}) + when is_map(content) do + case ElicitationSchema.validate_content(content, schema) do + :ok -> {:ok, result} + {:error, reason} -> {:error, reason} + end + end + + defp sanitize_elicitation_result(%{"action" => "accept"}, _info) do + {:error, "accept action missing content"} + end + + defp sanitize_elicitation_result(%{"action" => action} = result, _info) when action in ~w(decline cancel) do + {:ok, result} + end + + defp sanitize_elicitation_result(_result, _info) do + {:error, "elicitation result missing valid action"} + end + + defp dispatch_elicitation(result, request_id, %{server_module: module} = state) do + if Anubis.exported?(module, :handle_elicitation, 3) do + frame = prepare_frame(state) + + case module.handle_elicitation(result, request_id, frame) do + {:noreply, new_frame} -> + {:noreply, %{state | frame: new_frame}} + + {:stop, reason, new_frame} -> + {:stop, reason, %{state | frame: new_frame}} + end + else + {:noreply, state} + end + end + # Session serialization @doc false diff --git a/test/anubis/client_test.exs b/test/anubis/client_test.exs index 1e1499ab..7eeece40 100644 --- a/test/anubis/client_test.exs +++ b/test/anubis/client_test.exs @@ -1340,6 +1340,212 @@ defmodule Anubis.ClientTest do end end + describe "elicitation" do + setup :initialized_client + + @schema %{ + "type" => "object", + "properties" => %{"name" => %{"type" => "string"}}, + "required" => ["name"] + } + + @tag client_capabilities: %{"elicitation" => %{}} + test "register_elicitation_callback sets the callback", %{client: client} do + callback = fn _msg, _schema -> {:accept, %{"name" => "x"}} end + + :ok = Anubis.Client.register_elicitation_callback(client, callback) + + state = :sys.get_state(client) + assert is_function(state.elicitation_callback, 2) + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "unregister_elicitation_callback removes the callback", %{client: client} do + callback = fn _msg, _schema -> :decline end + + :ok = Anubis.Client.register_elicitation_callback(client, callback) + :ok = Anubis.Client.unregister_elicitation_callback(client) + + state = :sys.get_state(client) + assert is_nil(state.elicitation_callback) + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "handles elicitation/create accept", %{client: client} do + test_pid = self() + + :ok = + Anubis.Client.register_elicitation_callback(client, fn message, schema -> + send(test_pid, {:elicit_called, message, schema}) + {:accept, %{"name" => "octocat"}} + end) + + request_id = "elicit_req_accept" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert decoded["id"] == request_id + assert decoded["result"]["action"] == "accept" + assert decoded["result"]["content"] == %{"name" => "octocat"} + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + assert_receive {:elicit_called, "Name?", @schema} + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "handles elicitation/create decline", %{client: client} do + :ok = + Anubis.Client.register_elicitation_callback(client, fn _msg, _schema -> :decline end) + + request_id = "elicit_req_decline" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert decoded["result"]["action"] == "decline" + refute Map.has_key?(decoded["result"], "content") + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "handles elicitation/create cancel", %{client: client} do + :ok = + Anubis.Client.register_elicitation_callback(client, fn _msg, _schema -> :cancel end) + + request_id = "elicit_req_cancel" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert decoded["result"]["action"] == "cancel" + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "rejects accept content not matching schema", %{client: client} do + :ok = + Anubis.Client.register_elicitation_callback(client, fn _msg, _schema -> + {:accept, %{"name" => 42}} + end) + + request_id = "elicit_req_invalid" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert Map.has_key?(decoded, "error") + assert decoded["error"]["message"] =~ "does not match requested schema" + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "errors when no callback registered", %{client: client} do + request_id = "elicit_req_no_cb" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert decoded["error"]["message"] =~ "No elicitation callback" + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + end + + test "errors when capability not advertised", %{client: client} do + request_id = "elicit_req_no_cap" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert decoded["error"]["message"] =~ "elicitation capability" + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + end + + @tag client_capabilities: %{"elicitation" => %{}} + test "handles elicitation callback exception", %{client: client} do + :ok = + Anubis.Client.register_elicitation_callback(client, fn _msg, _schema -> + raise "boom" + end) + + request_id = "elicit_req_raise" + params = %{"message" => "Name?", "requestedSchema" => @schema} + + expect(Anubis.MockTransport, :send_message, fn _, message, _ -> + decoded = JSON.decode!(message) + assert decoded["error"]["message"] =~ "Elicitation callback error" + :ok + end) + + assert {:ok, encoded} = + Message.encode_request( + %{"method" => "elicitation/create", "params" => params}, + request_id + ) + + GenServer.cast(client, {:response, encoded}) + Process.sleep(100) + end + end + describe "automatic roots notification" do setup :initialized_client diff --git a/test/anubis/mcp/elicitation_schema_test.exs b/test/anubis/mcp/elicitation_schema_test.exs new file mode 100644 index 00000000..b5ba6fa4 --- /dev/null +++ b/test/anubis/mcp/elicitation_schema_test.exs @@ -0,0 +1,243 @@ +defmodule Anubis.MCP.ElicitationSchemaTest do + use ExUnit.Case, async: true + + alias Anubis.MCP.ElicitationSchema + + describe "validate/1" do + test "accepts a flat object with primitive properties" do + schema = %{ + "type" => "object", + "properties" => %{ + "name" => %{"type" => "string"}, + "age" => %{"type" => "integer", "minimum" => 0} + }, + "required" => ["name"] + } + + assert :ok = ElicitationSchema.validate(schema) + end + + test "accepts string with format and length bounds" do + schema = %{ + "type" => "object", + "properties" => %{ + "email" => %{ + "type" => "string", + "format" => "email", + "minLength" => 3, + "maxLength" => 100 + } + } + } + + assert :ok = ElicitationSchema.validate(schema) + end + + test "accepts each permitted format" do + for format <- ~w(email uri date date-time) do + schema = %{ + "type" => "object", + "properties" => %{"v" => %{"type" => "string", "format" => format}} + } + + assert :ok = ElicitationSchema.validate(schema), "format #{format} should be permitted" + end + end + + test "accepts enum strings with matching enumNames" do + schema = %{ + "type" => "object", + "properties" => %{ + "color" => %{ + "type" => "string", + "enum" => ["r", "g", "b"], + "enumNames" => ["Red", "Green", "Blue"] + } + } + } + + assert :ok = ElicitationSchema.validate(schema) + end + + test "accepts boolean with default" do + schema = %{ + "type" => "object", + "properties" => %{"agreed" => %{"type" => "boolean", "default" => false}} + } + + assert :ok = ElicitationSchema.validate(schema) + end + + test "accepts number with min/max" do + schema = %{ + "type" => "object", + "properties" => %{ + "score" => %{"type" => "number", "minimum" => 0, "maximum" => 100} + } + } + + assert :ok = ElicitationSchema.validate(schema) + end + + test "rejects non-object top level" do + assert {:error, _} = ElicitationSchema.validate(%{"type" => "array"}) + assert {:error, _} = ElicitationSchema.validate(%{"type" => "string"}) + end + + test "rejects non-map input" do + assert {:error, _} = ElicitationSchema.validate("not a map") + assert {:error, _} = ElicitationSchema.validate(nil) + end + + test "rejects nested object property" do + schema = %{ + "type" => "object", + "properties" => %{ + "nested" => %{ + "type" => "object", + "properties" => %{"x" => %{"type" => "string"}} + } + } + } + + assert {:error, _} = ElicitationSchema.validate(schema) + end + + test "rejects array property" do + schema = %{ + "type" => "object", + "properties" => %{"tags" => %{"type" => "array", "items" => %{"type" => "string"}}} + } + + assert {:error, _} = ElicitationSchema.validate(schema) + end + + test "rejects unsupported string format" do + schema = %{ + "type" => "object", + "properties" => %{"v" => %{"type" => "string", "format" => "ipv4"}} + } + + assert {:error, _} = ElicitationSchema.validate(schema) + end + + test "rejects enumNames length mismatch" do + schema = %{ + "type" => "object", + "properties" => %{ + "x" => %{"type" => "string", "enum" => ["a", "b"], "enumNames" => ["A"]} + } + } + + assert {:error, reason} = ElicitationSchema.validate(schema) + assert reason =~ "enumNames" + end + + test "rejects required name not present in properties" do + schema = %{ + "type" => "object", + "properties" => %{"a" => %{"type" => "string"}}, + "required" => ["b"] + } + + assert {:error, reason} = ElicitationSchema.validate(schema) + assert reason =~ "required" + end + end + + describe "validate_content/2" do + @schema %{ + "type" => "object", + "properties" => %{ + "name" => %{"type" => "string", "minLength" => 1}, + "age" => %{"type" => "integer", "minimum" => 0, "maximum" => 150}, + "email" => %{"type" => "string", "format" => "email"}, + "active" => %{"type" => "boolean"}, + "color" => %{"type" => "string", "enum" => ["r", "g", "b"]} + }, + "required" => ["name"] + } + + test "accepts a fully populated valid content" do + content = %{ + "name" => "Octocat", + "age" => 3, + "email" => "cat@github.com", + "active" => true, + "color" => "g" + } + + assert :ok = ElicitationSchema.validate_content(content, @schema) + end + + test "accepts content with only required fields" do + assert :ok = ElicitationSchema.validate_content(%{"name" => "x"}, @schema) + end + + test "rejects missing required field" do + assert {:error, _} = ElicitationSchema.validate_content(%{"age" => 1}, @schema) + end + + test "rejects unknown property" do + assert {:error, _} = + ElicitationSchema.validate_content(%{"name" => "x", "extra" => 1}, @schema) + end + + test "rejects wrong type" do + assert {:error, _} = ElicitationSchema.validate_content(%{"name" => 42}, @schema) + assert {:error, _} = ElicitationSchema.validate_content(%{"name" => "x", "age" => "no"}, @schema) + end + + test "rejects out-of-range integer" do + assert {:error, _} = + ElicitationSchema.validate_content(%{"name" => "x", "age" => 200}, @schema) + end + + test "rejects malformed email" do + assert {:error, _} = + ElicitationSchema.validate_content(%{"name" => "x", "email" => "no-at-sign"}, @schema) + end + + test "rejects value not in enum" do + assert {:error, _} = + ElicitationSchema.validate_content(%{"name" => "x", "color" => "purple"}, @schema) + end + + test "validates date format" do + schema = %{ + "type" => "object", + "properties" => %{"d" => %{"type" => "string", "format" => "date"}} + } + + assert :ok = ElicitationSchema.validate_content(%{"d" => "2024-01-15"}, schema) + assert {:error, _} = ElicitationSchema.validate_content(%{"d" => "not-a-date"}, schema) + end + + test "validates date-time format" do + schema = %{ + "type" => "object", + "properties" => %{"d" => %{"type" => "string", "format" => "date-time"}} + } + + assert :ok = + ElicitationSchema.validate_content(%{"d" => "2024-01-15T10:30:00Z"}, schema) + + assert {:error, _} = ElicitationSchema.validate_content(%{"d" => "2024-01-15"}, schema) + end + + test "validates uri format" do + schema = %{ + "type" => "object", + "properties" => %{"u" => %{"type" => "string", "format" => "uri"}} + } + + assert :ok = ElicitationSchema.validate_content(%{"u" => "https://example.com"}, schema) + assert {:error, _} = ElicitationSchema.validate_content(%{"u" => "not a url"}, schema) + end + + test "rejects non-map content" do + assert {:error, _} = ElicitationSchema.validate_content("string", @schema) + assert {:error, _} = ElicitationSchema.validate_content(nil, @schema) + end + end +end diff --git a/test/anubis/server/session_test.exs b/test/anubis/server/session_test.exs index e742244d..f26e65f1 100644 --- a/test/anubis/server/session_test.exs +++ b/test/anubis/server/session_test.exs @@ -359,4 +359,122 @@ defmodule Anubis.Server.SessionTest do assert map_size(state.server_requests) == 0 end end + + describe "elicitation requests" do + setup context do + context + |> Map.put(:client_capabilities, %{"elicitation" => %{}}) + |> initialized_server() + end + + @schema %{ + "type" => "object", + "properties" => %{"name" => %{"type" => "string"}}, + "required" => ["name"] + } + + test "server emits elicitation/create on the wire", %{ + server: session, + transport: transport + } do + :ok = StubTransport.set_test_pid(transport, self()) + + params = %{"message" => "Name?", "requestedSchema" => @schema} + send(session, {:send_elicitation_request, params, @schema, 30_000}) + + Process.sleep(10) + + assert_receive {:send_message, request_data} + assert {:ok, [decoded]} = Message.decode(request_data) + assert Message.is_request(decoded) + assert decoded["method"] == "elicitation/create" + assert decoded["params"]["message"] == "Name?" + assert decoded["params"]["requestedSchema"] == @schema + end + + test "accept response routes to handle_elicitation/3", %{ + server: session, + transport: transport + } do + :ok = StubTransport.set_test_pid(transport, self()) + + params = %{"message" => "Name?", "requestedSchema" => @schema} + send(session, {:send_elicitation_request, params, @schema, 30_000}) + + Process.sleep(10) + assert_receive {:send_message, request_data} + assert {:ok, [decoded]} = Message.decode(request_data) + request_id = decoded["id"] + + response = %{ + "id" => request_id, + "result" => %{"action" => "accept", "content" => %{"name" => "octocat"}} + } + + :ok = GenServer.cast(session, {:mcp_response, response, %{}}) + Process.sleep(10) + + state = :sys.get_state(session) + assert state.frame.assigns.last_elicitation_response == response["result"] + assert state.frame.assigns.last_elicitation_request_id == request_id + assert map_size(state.server_requests) == 0 + end + + test "decline response routes to handle_elicitation/3", %{ + server: session, + transport: transport + } do + :ok = StubTransport.set_test_pid(transport, self()) + + params = %{"message" => "Name?", "requestedSchema" => @schema} + send(session, {:send_elicitation_request, params, @schema, 30_000}) + + Process.sleep(10) + assert_receive {:send_message, request_data} + assert {:ok, [decoded]} = Message.decode(request_data) + request_id = decoded["id"] + + response = %{"id" => request_id, "result" => %{"action" => "decline"}} + :ok = GenServer.cast(session, {:mcp_response, response, %{}}) + Process.sleep(10) + + state = :sys.get_state(session) + assert state.frame.assigns.last_elicitation_response["action"] == "decline" + end + + test "invalid content does not reach handle_elicitation/3", %{ + server: session, + transport: transport + } do + :ok = StubTransport.set_test_pid(transport, self()) + + params = %{"message" => "Name?", "requestedSchema" => @schema} + send(session, {:send_elicitation_request, params, @schema, 30_000}) + + Process.sleep(10) + assert_receive {:send_message, request_data} + assert {:ok, [decoded]} = Message.decode(request_data) + request_id = decoded["id"] + + response = %{ + "id" => request_id, + "result" => %{"action" => "accept", "content" => %{"name" => 42}} + } + + :ok = GenServer.cast(session, {:mcp_response, response, %{}}) + Process.sleep(10) + + state = :sys.get_state(session) + refute Map.has_key?(state.frame.assigns, :last_elicitation_response) + end + + test "send_elicitation_request rejects invalid schema synchronously" do + bad_schema = %{ + "type" => "object", + "properties" => %{"x" => %{"type" => "object"}} + } + + assert {:error, _} = Anubis.Server.send_elicitation_request("hi", bad_schema) + end + end end diff --git a/test/support/stub_server.ex b/test/support/stub_server.ex index a17ac3a4..8765b61d 100644 --- a/test/support/stub_server.ex +++ b/test/support/stub_server.ex @@ -95,6 +95,13 @@ defmodule StubServer do {:noreply, frame} end + @impl true + def handle_elicitation(response, request_id, frame) do + frame = assign(frame, :last_elicitation_response, response) + frame = assign(frame, :last_elicitation_request_id, request_id) + {:noreply, frame} + end + defp handle_tool_call(%{"arguments" => %{"name" => name}, "name" => "greet"}, frame) do Response.tool() |> Response.text("Hello #{name}!") diff --git a/test/support/stub_transport.ex b/test/support/stub_transport.ex index 623a0874..2eba765d 100644 --- a/test/support/stub_transport.ex +++ b/test/support/stub_transport.ex @@ -156,7 +156,7 @@ defmodule StubTransport do end defp forward_to_session(message, state) when Message.is_request(message) do - if message["method"] == "sampling/createMessage" do + if message["method"] in ~w(sampling/createMessage elicitation/create roots/list) do :ok else session_name =