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
@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).
@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
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.
@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.
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.
@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.
@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.
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.
@spec warn_refusal(refusal()) :: :ok
Logs why refusal derived nothing.
@spec warn_refused_session_id(term()) :: :ok
Logs that the session id derives no tools because of its id.