Raxol.MCP.TreeWalker (Raxol MCP v2.8.0)

Copy Markdown View Source

Traverses a view element tree and derives namespaced MCP tools from widgets.

The view tree is the output of a TEA app's view/1 function -- nested maps with :type, :id, :attrs, :children keys. TreeWalker walks this tree, finds widgets that implement Raxol.MCP.ToolProvider, calls mcp_tools/1 to get tool specs, namespaces them by session and widget ID, and wraps callbacks that dispatch TEA messages through the session's Dispatcher.

Usage

context = %{dispatcher_pid: dispatcher_pid, session_id: "demo"}
tools = TreeWalker.derive_tools(view_tree, context)
Raxol.MCP.Registry.register_tools(tools)

Tool names are namespaced as "session_id.widget_id.action", e.g., "demo.search_input.type_into", so two sessions rendering the same widget each get their own tool. Nodes without an :id key are skipped (layout containers like :column, :row). The session id and the widget id become part of a tool name the client lists, so a session whose id is not 1-32 characters of [A-Za-z0-9_-] derives no tools at all, and a provider whose widget id is not derives none of its own (id?/1). Neither may hold a ., so the first segment of a derived name is always its whole session id. The action name is the last segment: one that is not a lowercase letter followed by up to 31 of [a-z0-9_] derives no tool.

Everything else a provider writes reaches the client's tool list as well, which a model reads as instructions. Control, format (bidi, zero-width, BOM, tag), line/paragraph separator, private-use and invisible filler characters (variation selectors, combining grapheme joiner, Hangul fillers, braille blank) are stripped from a tool's description and from every string and every key inside its inputSchema (titles, enums, defaults, examples, property names). Of a spec's annotations only sensitive: true and destructiveHint: true are kept: they can only add the authorizer requirement, while any other key (title, readOnlyHint, ...) is a provider's self-report a client would act on.

An atom is encoded as its name, so it is cleaned as that name: a description atom becomes its cleaned name, and a schema key or leaf atom stays an atom when its name is clean and becomes the cleaned string otherwise. A spec derives no tool, and is refused like a misnamed action, when its description is neither a string nor an atom (nil means none), when its inputSchema holds anything JSON cannot encode (a tuple, a struct, a pid, a function, an improper list, a key that is neither a string nor an atom), when two keys of one map are equal once cleaned and stringified (:amount and "amount", or "amount" and "amount\u200B"), or when its inputSchema nests maps and lists deeper than 32 levels or holds more than 10,000 values (each map, list and leaf counts one). One such spec would otherwise make every client's tools/list unencodable, let one property definition silently replace another, or hold up every render while it is cleaned.

A derived tool is registered as one by Raxol.MCP.ToolSynchronizer (Raxol.MCP.Registry.register_tools/3 with derived: true), which is what Raxol.MCP.Server's boot check reads to tell it from a static tool.

Excluding Widgets

Set mcp_exclude: true in a widget's attrs to suppress tool derivation:

%{type: :text_input, id: "internal", attrs: %{mcp_exclude: true}}

The widget and its children still render normally -- only MCP tool exposure is suppressed. Useful for decorative or internal widgets.

Explicit provider marker (attrs.component_module)

Components whose root emits a plain layout type (:column, :row) can declare their providing module explicitly instead of growing this package's type map (the harness block Components do this):

%{type: :column, id: "blk", attrs: %{component_module: MyBlock}}

An explicit marker wins over the built-in type map; the same ToolProvider guard applies, so a module that does not implement the behaviour still derives nothing, and a non-atom marker is ignored.

Summary

Types

Why a provider derived less than it declared: {:widget_id, id} for a widget whose id derives no tools, {:action_name, widget_id, name} for one spec whose action name derives no tool, {:action_spec, widget_id, name, fault} for one whose description or input schema does (see the moduledoc).

:not_json for a term JSON cannot encode, {:duplicate_key, key} for two keys that are both key once cleaned, :too_large for an input schema deeper than 32 levels or larger than 10,000 values.

Functions

Whether name may name an action: a lowercase letter followed by up to 31 of [a-z0-9_]. It becomes part of a listed tool name.

An action spec's description and inputSchema as the client's tool list gets them: cleaned as the moduledoc describes, or the spec_fault/0 that refuses the spec. Public so that a tool derived from another source goes through the same cleaning.

The built-in declaration-type -> Component-module map used when context carries no :type_map.

Derives MCP tool definitions from a view element tree, and logs every refusal (a widget id, action name or action spec that derives nothing) as a warning.

derive_tools/2 without logging: returns the tools and the set of refusals (see refusal/0) instead. A session id that is not (id?/1) derives no tools and refuses no widget. A caller that derives on every render (Raxol.MCP.ToolSynchronizer) logs each new refusal once with warn_refusal/1, and a refused session id with warn_refused_session_id/1.

Whether id may be a segment of a derived tool name: a binary of 1-32 characters of [A-Za-z0-9_-]. The session id and every widget id must be. A segment is the text the client lists, so it holds no . (which would read as a segment boundary) and no control or format characters.

Logs why refusal derived nothing.

Logs that the session id derives no tools because of its id.

Whether reason is the {:widget_unavailable, widget_id, why} a derived tool answers (see "Live resolution" in derive_tools/2): widget_id passes id?/1 and why is :absent, :disabled or :type_changed. Any tool can return a term of that shape, so this is what makes it safe to name the widget to a client.

Types

context()

@type context() :: %{
  :session_id => String.t(),
  optional(:dispatcher_pid) => pid() | nil,
  optional(:type_map) => %{required(atom()) => module()},
  optional(:view) => (-> {:ok, map() | [map()]} | {:error, term()}),
  optional(:synced) => (-> term())
}

refusal()

@type refusal() ::
  {:widget_id, String.t()}
  | {:action_name, String.t(), term()}
  | {:action_spec, String.t(), String.t(), spec_fault()}

Why a provider derived less than it declared: {:widget_id, id} for a widget whose id derives no tools, {:action_name, widget_id, name} for one spec whose action name derives no tool, {:action_spec, widget_id, name, fault} for one whose description or input schema does (see the moduledoc).

spec_fault()

@type spec_fault() :: :not_json | {:duplicate_key, String.t()} | :too_large

:not_json for a term JSON cannot encode, {:duplicate_key, key} for two keys that are both key once cleaned, :too_large for an input schema deeper than 32 levels or larger than 10,000 values.

Functions

action_name?(name)

@spec action_name?(term()) :: boolean()

Whether name may name an action: a lowercase letter followed by up to 31 of [a-z0-9_]. It becomes part of a listed tool name.

clean_spec(description, input_schema)

@spec clean_spec(term(), term()) ::
  {:ok, String.t() | nil, term()} | {:error, spec_fault()}

An action spec's description and inputSchema as the client's tool list gets them: cleaned as the moduledoc describes, or the spec_fault/0 that refuses the spec. Public so that a tool derived from another source goes through the same cleaning.

default_type_map()

@spec default_type_map() :: %{required(atom()) => module()}

The built-in declaration-type -> Component-module map used when context carries no :type_map.

Exposed so the main app can assert it against Raxol.UI.Registry; this package cannot derive the map from that registry (main raxol is not a dependency), so conformance is a test, not a compile-time guarantee.

derive_tools(tree, context)

@spec derive_tools(map() | [map()], context()) :: [Raxol.MCP.Registry.tool_def()]

Derives MCP tool definitions from a view element tree, and logs every refusal (a widget id, action name or action spec that derives nothing) as a warning.

Returns a list of Registry.tool_def() maps with namespaced names and wrapped callbacks that dispatch TEA messages. Also logs a session id refused (id?/1). A caller that derives on every render and wants each refusal logged once uses derive_with_refusals/2 instead.

Options in context

  • :session_id - the session's id, the first segment of every tool name (required)
  • :dispatcher_pid - pid of the session's Dispatcher (for message dispatch)
  • :type_map - optional override for widget type -> module mapping
  • :view - a function answering the session's current view tree, {:ok, tree} or {:error, reason}. With it, every call resolves its widget by id in that tree, so it acts on the widget as it is now, not as it was when the tool was derived. Without it (a static tree), a call acts on the node it was derived from.
  • :synced - a function called after a call's messages have been dispatched and before it answers, to bring the session's tool set up to date with their effect

Live resolution

With :view in the context, a call whose widget is no longer in the view, no longer offers the action (a disabled Button offers none) or is now another kind of widget answers {:error, {:widget_unavailable, widget_id, :absent | :disabled | :type_changed}} (widget_unavailable?/1). Those answers are about the question, not the tool's health, so they never count toward its circuit breaker (:fault?). When several widgets in the view share the id, the call acts on the first, in view order, that is of the tool's kind and offers the action. A call into a session whose dispatcher has exited answers {:error, {:session_not_running, session_id}}.

Every tool also carries :provider, the widget's module: with its name and listed definition it is what Raxol.MCP.ToolSynchronizer keys a session's tool set on.

derive_with_refusals(tree, context)

@spec derive_with_refusals(map() | [map()], context()) ::
  {[Raxol.MCP.Registry.tool_def()], MapSet.t(refusal())}

derive_tools/2 without logging: returns the tools and the set of refusals (see refusal/0) instead. A session id that is not (id?/1) derives no tools and refuses no widget. A caller that derives on every render (Raxol.MCP.ToolSynchronizer) logs each new refusal once with warn_refusal/1, and a refused session id with warn_refused_session_id/1.

id?(id)

@spec id?(term()) :: boolean()

Whether id may be a segment of a derived tool name: a binary of 1-32 characters of [A-Za-z0-9_-]. The session id and every widget id must be. A segment is the text the client lists, so it holds no . (which would read as a segment boundary) and no control or format characters.

warn_refusal(arg)

@spec warn_refusal(refusal()) :: :ok

Logs why refusal derived nothing.

warn_refused_session_id(id)

@spec warn_refused_session_id(term()) :: :ok

Logs that the session id derives no tools because of its id.

widget_unavailable?(arg1)

@spec widget_unavailable?(term()) :: boolean()

Whether reason is the {:widget_unavailable, widget_id, why} a derived tool answers (see "Live resolution" in derive_tools/2): widget_id passes id?/1 and why is :absent, :disabled or :type_changed. Any tool can return a term of that shape, so this is what makes it safe to name the widget to a client.