Raxol.MCP.ToolProvider behaviour (Raxol MCP v2.8.0)

Copy Markdown View Source

Behaviour for widgets that expose MCP tools.

Each interactive widget can implement this behaviour to declare what semantic actions (tools) are available given its current state. The TreeWalker traverses the view tree, calls mcp_tools/1 on each widget that implements this behaviour, and registers the resulting tools with the MCP Registry.

Tool Specs

mcp_tools/1 returns a list of tool spec maps:

%{
  name: "click",
  description: "Click the button",
  inputSchema: %{type: "object", properties: %{}},
  annotations: %{sensitive: true}
}

The name is the action name (e.g., "click", "typeinto"). The TreeWalker namespaces it with the session and widget IDs: "demo.submit_btn.click". A name that is not a lowercase letter followed by up to 31 of `[a-z0-9]` derives no tool, and TreeWalker logs a warning naming it.

The description is static per (provider, action): it never carries app text (labels, titles, tab names) or the widget ID. Descriptions reach the client's tool list, which a model reads as instructions, so app text belongs in tool results only. TreeWalker also strips control, format, separator, private-use and invisible filler characters from the description and from every string and key inside inputSchema.

annotations is optional. Of it the derived tool keeps only sensitive: true and destructiveHint: true; any other key or value (title, readOnlyHint, sensitive: false) is dropped. Give an action that changes app state sensitive: true, so Raxol.MCP.Server refuses it when no authorizer is configured.

Handle Tool Call

handle_tool_call/3 executes the action. It receives the action name, arguments map, and a context map with :widget_id, :widget_state, and :dispatcher_pid.

Return values:

  • {:ok, result} -- pure read, no state change
  • {:ok, result, [message]} -- result + TEA messages to dispatch
  • {:error, reason} -- failure

Messages reach update/2

TreeWalker hands each message, as returned, to the app's update/2 through the session's Dispatcher, and the tool answers only once update/2 has folded it. Messages are app terms, not %Event{}s: an app whose view renders a provider's node adds update/2 clauses for the messages it acts on. id is the node's binary id.

An update/2 that raises or returns an invalid result makes the tool an error result (:update_failed), and the model is unchanged. The Dispatcher call uses the default 5 s timeout: a timeout is an error result, but it does not mean the message was not applied, since update/2 may still fold it afterwards. Check the app's state before retrying a write that timed out.

ProviderAction: message
Tableselect_row: {:select_row, id, index}; sort: {:sort, id, column, direction}, the column string as sent; direction "asc" or "desc" (anything else is an error result)
SelectListselect: {:select_option, id, index}
Tabsselect_tab: {:tab_change, id, index}, index range-checked against the node's :tabs
Modaldismiss: {:modal_dismiss, id}; confirm: {:modal_confirm, id}
TextInputtype_into: {:text_input_change, id, text}; clear: {:text_input_change, id, ""}
TextAreatype_into: {:text_area_change, id, text}; clear: {:text_area_change, id, ""}
PasswordFieldtype_into: {:password_field_change, id, text}; clear: {:password_field_change, id, ""}
Buttonclick: the node's :on_click message (top level on View DSL nodes, under :attrs on Component trees); an error result when it is nil or a function
Checkboxtoggle: {:checkbox_toggle, id, new_checked}
Menuselect: {:menu_select, id, item_id}
Scrubberseek: {:scrubber_seek, id, position}; play: {:scrubber_play, id}; pause: {:scrubber_pause, id}
Treeexpand: {:tree_expand, id, node_id}; collapse: {:tree_collapse, id, node_id}; select_node: {:tree_select, id, node_id}
Viewportscroll_to: {:scroll_to, id, row}
BarChart, LineChart, ScatterChartread-only

Every other action is a read ({:ok, result}) and sends nothing.

Example

defmodule MyApp.Button do
  @behaviour Raxol.MCP.ToolProvider

  @impl Raxol.MCP.ToolProvider
  def mcp_tools(%{attrs: %{disabled: true}}), do: []
  def mcp_tools(state) do
    [%{
      name: "click",
      description: "Click the button",
      inputSchema: %{type: "object", properties: %{}},
      annotations: %{sensitive: true}
    }]
  end

  @impl Raxol.MCP.ToolProvider
  def handle_tool_call("click", _args, context) do
    {:ok, "Clicked", [context.widget_state[:attrs][:on_click]]}
  end
end

Summary

Callbacks

Handles an MCP tool call for this widget.

Returns the list of MCP tool specs for the widget's current state.

Functions

Returns true if the given module implements the ToolProvider behaviour.

Types

context()

@type context() :: %{
  widget_id: String.t(),
  widget_state: map(),
  dispatcher_pid: pid() | nil
}

result()

@type result() :: {:ok, term()} | {:ok, term(), [term()]} | {:error, term()}

tool_spec()

@type tool_spec() :: %{
  optional(:annotations) => map(),
  name: String.t(),
  description: String.t(),
  inputSchema: map()
}

Callbacks

handle_tool_call(name, args, context)

@callback handle_tool_call(name :: String.t(), args :: map(), context :: context()) ::
  result()

Handles an MCP tool call for this widget.

name is the action name (e.g., "click", "type_into") without the widget ID namespace prefix.

args is the arguments map from the MCP client.

context contains :widget_id, :widget_state, and :dispatcher_pid.

mcp_tools(widget_state)

@callback mcp_tools(widget_state :: map()) :: [tool_spec()]

Returns the list of MCP tool specs for the widget's current state.

Called by TreeWalker during view tree traversal. The widget state is the raw map from the view tree (the output of view/1), not the internal component struct.

Functions

tool_provider?(module)

@spec tool_provider?(module()) :: boolean()

Returns true if the given module implements the ToolProvider behaviour.