MCP as a Rendering Target
View SourceMCP is a rendering target alongside terminal, browser, and SSH. The Component tree is the source of truth, and MCP tools and resources are projections of it, so the same running module serves a human and an agent without either seeing a different truth. See ADR-0012 for the design rationale.
Quick start
mix mcp.server
This starts an MCP server on stdio, with tools auto-derived from your app's Component tree and named per session (<session>.<widget>.<action>). Wire it into Claude Code or any MCP client.
# In your app
defmodule MyApp do
use Raxol.Core.Runtime.Application
# ... your normal init/update/view ...
end
# In an MCP client
session = Raxol.MCP.Test.start_session(MyApp)
session
|> type_into("search", "elixir")
|> click("submit")
|> assert_component("results", fn c -> c[:content] != nil end)The agent sees a structured Component tree, not a flat screenshot. It picks the action it wants from a typed schema.
Tool derivation
Each interactive Component implements Raxol.MCP.ToolProvider. The protocol exposes semantic actions per Component:
| Component | Actions |
|---|---|
Button | click |
TextInput | type_into, clear, get_value |
SelectList | select, get_selected, get_options |
Checkbox | toggle, get_checked |
Modal | confirm, dismiss |
Table | select_row, sort, get_rows |
Tree | expand, collapse, select_node |
Scrubber | seek, play, pause, get_position |
A write action reaches your app's update/2 as a plain message, such as {:sort, "results", "stars", "desc"} from a Table's sort, and the tool answers once update/2 has run. An update/2 that raises or returns an invalid result makes the call an error result. A call that times out (after 5 s) is an error result too, but update/2 may still apply the message afterwards, so check the app's state before retrying it. Add update/2 clauses for the messages your app acts on; Raxol.MCP.ToolProvider lists every action's message. A button whose on_click is missing or a function gives an error result.
Every call acts on its Component as it is now: the tool finds it by id in your app's view/1 of the session's current model, read from the dispatcher at call time. A Button's click sends the on_click the current render has, a Checkbox's toggle flips the current value, and a read such as get_rows, get_value or get_tabs answers from the current model, so a sort followed by get_rows gets the rows sorted. A Component that is no longer in the view, no longer offers the action (a disabled Button offers none) or is now another kind of Component gives an error result naming its id, such as widget `confirm` is not in the session's current view. Those errors never count toward the tool's circuit breaker.
Derived tools are named <session>.<widget>.<action>: demo.results.sort is the sort action of the Component with id results in the headless session demo. Two sessions of one app each get their own tools, and stopping one removes only its own. Each session also gets <session>.discover_tools, which searches that session's tools by keyword. The session id is the first segment of every name, so Raxol.Headless.start/2 refuses an id that is not 1-32 characters of [A-Za-z0-9_-] (Raxol.MCP.TreeWalker.id?/1), and a Raxol.MCP.ToolSynchronizer started directly with one derives no tools at all, discover_tools included, registers none of its raxol://session/<id>/... resources, and a warning names the id: an id with a . (such as t.v2) would otherwise read as session t, and a control character would reach the client's tool and resource lists.
A session's tool set is sticky: it is every tool its renders have derived so far, and a Component that leaves the view keeps its tools until the session stops. A modal opened, dismissed and opened again changes the set, and sends notifications/tools/list_changed, once. A tool is registered again only when its provider or its listed definition (description, input schema, annotations) changes. A derived write brings the set up to date before it answers. A session's synchronizer owns its tools in Raxol.MCP.Registry (register_tools/3 with owner:): if it dies without stopping, the registry removes them and sends one list_changed, and when it stops it unregisters only the tools it still owns.
Because the set only grows while the session runs, a view that gives each item its own widget id (a row per record, a page of buttons per screen) adds tools for every item it ever renders, and each new one sends a list_changed. Until the per-session cap planned in P1b2 lands, give repeated items a fixed set of ids (by slot, not by record) or keep them out of derivation with @mcp_exclude true. Widgets that share an id share its tools: a call acts on the first one in the view of the tool's kind that offers the action.
An MCP client may rename tools. OMP lowercases a name, turns every run of characters outside [a-z0-9_] into _, collapses and trims _, and uses tool if nothing is left, so demo.x_y.click and demo.x-y.click would reach it as one tool. Raxol.MCP.Registry computes that name (Raxol.MCP.Registry.omp_name/1) at registration and refuses a tool whose name is already taken there by another. The session logs the refusal and offers the tool again on every render, so it registers once the name is free. raxol_start and Raxol.Headless.start/2 refuse to start a session any of whose first render's tools is refused, and stop it, so neither a client nor an Elixir caller holds a session it cannot fully drive: a client's session s started first holds the names of an Elixir caller's S, whose start/2 answers {:error, {:name_collisions, names}}, naming only its own refused tools. OMP then strips an exact <server>_ prefix once, which omp_name/1 does not model: name the server raxol in your OMP config (the raxol:raxol plugin key, or the .mcp.json key raxol), which is why raxol_start refuses the session id raxol (its raxol.nav_menu.click would reach OMP as session nav's nav.menu.click). The reservation covers only those keys. Under another key that a session id can spell, such as dev, session dev's dev.nav_menu.click reaches OMP as nav_menu_click, the same name as session nav's nav.menu.click, and OMP keeps only one of them.
A call to a derived name whose session is not running in this server answers a tool error that names the session and points to raxol_list, not an unknown-method error: OMP keeps the names from its last listing, even across runs. Headless decides this, not the generic server: Raxol.Application starts Raxol.MCP.Server with unknown_tool_reply: &Raxol.Headless.McpTools.unknown_tool_reply/1, which answers for a name of the derived shape whose session is not in Raxol.Headless.list/0 and is not one of the reserved ids raxol and agent. A server started without that option answers every unknown name with -32601.
OMP's tools.approval deny and prompt entries also match a digit-free legacy form of each name, so sessions a1 and a2 share legacy names and one legacy entry applies to both.
Session ids are not secret from a connected client. Gating raxol_list hides only the list itself: ids still show up in tool and resource names (tools/list, resources/list, completion), in raxol_start's refusals and in the stale-session reply. Per-connection session ownership (a client sees and controls only the sessions it started) is planned as the single rule for this; until then, treat every session in a server as visible to every client of that server.
Add @mcp_exclude true to a Component's attrs to suppress tool derivation, useful for internal scaffolding Components that shouldn't show up in the agent's action menu.
Focus lens
A Component tree with 50 Components generates 100+ tools. That's too many for an LLM to reason about. Raxol.MCP.FocusLens.filter/2 cuts a tool list to about 15 based on:
- The focused Component (
:focused_id) - The hovered Component (
:hover_id, in:hovermode)
tools =
Raxol.MCP.FocusLens.filter(all_tools,
mode: :focused,
session_id: "demo",
focused_id: "search_input",
max_tools: 15
)
length(tools) # ~15, not ~100Nothing filters what a client lists for you: the server lists every registered tool, and the lens is for code that builds its own tool list. It works within one session: a tool belongs to the session whose id is exactly the first . segment of its name, so another session's tools for a Component with the same id never leak in, and the session's discover_tools searches only that session.
Since the session's registered set is sticky, discover_tools also finds the tools of Components no longer on screen. Calling one answers the error that names the Component, so a search result is a lead to check against the current view, not a promise the Component is there.
Resources
Model state is exposed as MCP resources via projections declared on the app:
defmodule MyApp do
use Raxol.Core.Runtime.Application
@mcp_resource "myapp://state/cart"
def project_cart(model), do: %{items: model.cart, total: cart_total(model)}
endThe MCP client can read myapp://state/cart to inspect what the agent is working with. Updates stream as diffs through Raxol.MCP.Diff, so the agent doesn't need to re-fetch the full state every turn.
Agents
With raxol_agent started, a node booted for MCP (raxol mcp, mix mcp.server: the :mcp startup mode) also serves agent_spawn, agent_list, agent_ask, agent_status and agent_stop, and one resource per agent, raxol://agent/<id>/journal (Raxol.Agent.McpTools). In any other mode that runs the MCP registry, :full included, they register only with config :raxol_agent, mcp_tools: true; false keeps them out everywhere. The setting is read once, when Raxol.Agent.McpTools first starts, so it belongs in config/*.exs, and the tools answer to the node's :mcp_authorizer like every other tool. agent_spawn starts only the apps in config :raxol_agent, :mcp_spawnable_apps, and the tools act only on agents that server started or that :mcp_exposed_agents names. These are Raxol agents (use Raxol.Agent), not headless sessions.
Test harness
Raxol.MCP.Test is a pipe-friendly test harness:
import Raxol.MCP.Test
import Raxol.MCP.Test.Assertions
test "submit flow" do
session = start_session(MyApp)
session
|> type_into("email", "user@example.com")
|> type_into("password", "secret")
|> click("submit")
|> assert_component("status", fn c -> c.content == "Logged in" end)
|> stop_session()
endThe harness goes through the same MCP transport as a real client, so what your tests exercise is what an agent will hit.
Context tree
Raxol.MCP.ContextTree assembles a unified view of state from:
- TEA model
- Component tree (with focus lens applied)
- Active agents (
Raxol.Agent.Registry) - Swarm state (when distributed)
- Pending notifications
The tree is streamed as diffs over the MCP connection, so agents track changes incrementally rather than polling.
Authorization
Raxol.MCP.Server evaluates an authorizer before running a tool. It is a 3-arity fun returning :allow, {:ask, prompt}, or {:deny, reason}, and Raxol.Application supplies it explicitly:
config :raxol,
mcp_authorizer: fn tool, _args, _ctx ->
if String.starts_with?(tool, "raxol_"), do: :allow, else: {:deny, :not_mine}
end,
# Guards resources/read, tools/list, prompts/get and the other read surfaces.
# Receives the METHOD name in the tool-name position.
mcp_read_authorizer: fn _method, _args, _ctx -> :allow endA value that is not a 3-arity fun raises at boot rather than being ignored, so a deployment cannot believe it configured a policy while running without one.
The third argument is the authorization context: %{conn_id: term(), transport: :stdio | :sse | :unknown}, both server-derived. conn_id is the connection the request arrived on (:default for stdio and direct callers, a per-session value for SSE); transport is what the embedder declared the server fronts, via transport: on Raxol.MCP.Server.start_link/1 or Raxol.MCP.Supervisor, and reads :unknown when nobody declared one. Nothing the client asserts is in there, and nothing should be added: a policy keyed on a client-declared name or version is bypassed by declaring a different one.
In a release, the fun form only works from config/runtime.exs. config/config.exs is evaluated at build time and its result is written to sys.config, which holds serializable terms only; an anonymous function is not one. Name the function instead, which survives into sys.config:
# config/config.exs, or runtime.exs: either works for this form.
config :raxol, mcp_authorizer: {MyApp.McpPolicy, :authorize}The module and its 3-arity function are checked when the authorizer is resolved, so a typo is a boot failure naming the module rather than a tool that denies for a reason nobody can see.
Defaults
Configuration binds in every environment. mcp_allowed_tools restricts the tools in dev exactly as it does in production; the environment decides only what happens when nothing is configured.
Unconfigured outside production resolves to Raxol.MCP.Authorizer.allow_all/0: what the implicit nil already did, now visible at the call site so mix mcp.server keeps working.
Unconfigured in production resolves to a deny-by-default allowlist:
config :raxol,
mcp_allowed_tools: ["raxol_screenshot"],
# Defaults to the three LISTING methods. prompts/get returns prompt content
# rather than names, and completion/complete is an enumeration primitive, so
# neither is a default; resources/read and the subscribe pair stream live
# model state and are likewise absent.
mcp_allowed_read_methods: ~w(tools/list resources/list prompts/list)Empty is the default for tools, so nothing runs until a deployment names it. That is tighter than the nil it replaced, not merely more explicit: Authorizer.decide/4 treats nil as allow, so a production server previously ran any tool nobody had annotated sensitive.
The default must not be allow_all in production, and a non-nil default must not by itself open the network. Raxol.MCP.Server.authorization_configured?/1 is therefore not authorizer != nil; it is authorizer != nil and authorizer_source == :configured, and the SSE boot gate (Raxol.MCP.Deployment.enforce_authorization!/2) reads that.
:authorizer_source is what separates a policy an operator wrote from a fallback raxol picked. Naming :mcp_authorizer or :mcp_allowed_tools is what makes a network transport bootable, including mcp_allowed_tools: [], since exposing a transport that serves nothing is a coherent choice and it is the operator's to make. The two READ keys deliberately do not count, because this gate is about running tools; the read seam has its own boot gate instead (see below). A framework fallback never counts, however strict it is: otherwise the gate stops being a forcing function, which is the whole reason it exists.
Embedding Raxol.MCP.Server or Raxol.MCP.Supervisor directly? :authorizer_source defaults to :default, so forgetting the option leaves the gate shut rather than satisfying it by omission. Pass authorizer_source: :configured when your own configuration decided the policy.
Reads cannot take the same empty default: tools/list is a read, so denying every read would leave an allowlisted tool undiscoverable and the server unusable rather than closed. The split is by what a method discloses. Listing methods reveal names the server already advertises, while resources/read streams state.
logging/setLevel goes through the read seam too, with the method name and %{"level" => level}. It is not in the default list, so a production deployment whose clients set the level adds "logging/setLevel" to mcp_allowed_read_methods.
tools/list is filtered per entry as well as gated as a method. Each tool goes through the same authorizer tools/call would consult, and only :allow is advertised; {:ask, _} hides exactly like {:deny, _}, since an entry needing an approval the caller has not got is not callable, and a policy that raises costs that tool its listing rather than failing the whole listing. A nil authorizer advertises everything.
The read seam itself still answers :allow when no read authorizer is configured, which is what stdio embedders have always had, so the fail-closed pressure sits at boot instead: Raxol.MCP.Deployment.enforce_read_authorization!/2 refuses to mount the SSE transport without a :read_authorizer, naming :mcp_read_authorizer in the refusal, and a server started with transport: :sse runs the same check at its own boot. Satisfying the tool gate alone used to be enough to serve live model state, through resources/read, to any client that connected.
Annotations
A tool annotated destructiveHint: true or sensitive: true never runs without an authorizer, enforced twice: the server refuses to boot when such a tool is already registered, and refuses the call itself for one registered afterwards. The runtime half needs the tool registry to be reachable to classify a tool, and fails closed when it is not, so a call during a registry restart is refused rather than treated as non-sensitive. Of the headless tools, raxol_start, raxol_send_key, raxol_stop and raxol_get_model carry the annotation; raxol_screenshot and raxol_list do not. raxol_get_model is a read, but it returns the whole model, and an app keeps its secrets there. Of the agent tools, agent_spawn, agent_ask and agent_stop carry it; agent_list, agent_status and the journal resource do not.
Derived tools keep two annotations of their tool spec, and only when true: sensitive and destructiveHint. They can only add the authorizer requirement. Any other key or value (title, readOnlyHint, sensitive: false) is dropped, because it is the provider's own claim and a client acts on it (OMP's plan mode reads readOnlyHint). Every built-in action that changes app state is sensitive: true: click, type_into, clear, toggle, select, select_row, select_node, select_tab, sort, expand, collapse, dismiss, confirm, seek, play, pause and scroll_to. The get_* reads are not, and a TextInput with a mask_char derives no get_value, as PasswordField derives none. No MCP channel a client can use without an authorizer returns either value: raxol://session/<id>/widgets reports no value for them, raxol://session/<id>/context drops the value of a password field or a node with a mask_char, the layout engine draws a masked :text_input as its mask, so raxol_screenshot shows the mask, and raxol_get_model, which returns the model the plaintext lives in, is sensitive. An authorizer that allows it hands the client the plaintext. The context resource also includes the model projections the app declares in mcp_resources/0, so a secret an app projects there is readable. So a server embedded with no authorizer (Raxol.MCP.Supervisor or Raxol.MCP.Server without :authorizer) still lists and reads widgets, but refuses to drive them, or to return a model, with sensitive_tool_unguarded. The boot refusal scans static tools only: derived tools come and go with sessions and their views, and a Server restarted while sessions are live must still boot, so for them the tools/call refusal is what holds. A tool counts as derived by origin, not by a dotted name or a key in its tool map: only Raxol.MCP.ToolSynchronizer registers a session's tools as derived (Raxol.MCP.Registry.register_tools/3 with derived: true), so a static tool with a dotted name, or one whose map carries derived: true, is still checked at boot. Raxol.MCP.AgentBridge, in raxol_agent, follows the same rules: an Action declared sensitive: true becomes a sensitive tool, and an Action's name, description and input schema go through the same checks and cleaning as a widget provider's spec.
A network deployment needs a function authorizer, not just a name allowlist. Derived tool names come and go with sessions and their views, so a fixed mcp_allowed_tools list cannot name them, and allow_all hands every widget to every client. A name the list does hold ties the tool to a session id the client picks, not to an app: s1.form.click authorizes that name for whatever startable app a client starts as s1. SSE connections also share sessions: every connection reaches every headless session and its derived tools, and the connection id comes from the client's mcp-session-id header, so it is not an identity to authorize on.
A derived tool's name binds to a widget id, not to the Component on screen when the name was listed. A call acts on whichever Component of that kind has the id in the session's view when it runs, and an approval granted for the name, such as OMP's Approve <tool> for this session, covers that Component too, whatever its label or on_click is by then. An authorizer that must tell two renders apart has to decide on the session's current state, not on the tool name.
Derived tool descriptions are static per provider and action. App text (labels, titles, tab names) and widget ids never appear in them, only in tool results such as get_tabs. Raxol.MCP.TreeWalker strips control, format (bidi overrides, zero-width characters, BOM, tag characters), line/paragraph separator and private-use characters, variation selectors, the combining grapheme joiner, the Hangul fillers and the braille blank from every description, and from every string and every key inside a tool's inputSchema (titles, enums, defaults, examples, property names). An atom is cleaned as its name. A model reads the tool list as part of its instructions, so app text there would be a prompt injection with the app's authority.
A widget id and an action name both become part of the tool name. A provider widget whose id is not 1-32 characters of [A-Za-z0-9_-] derives no tools, an action whose name is not a lowercase letter followed by up to 31 of [a-z0-9_] derives no tool, and a warning names each. So does a spec whose description is neither a string nor an atom, whose inputSchema holds a term JSON cannot encode (a tuple, a struct, a pid, a function, an improper list, a key that is neither a string nor an atom), whose map has two keys equal once cleaned (:amount and "amount"), or whose 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 replace another, or hold up every render while it is cleaned.
Startable modules
raxol_start starts a module for whichever client called it, so it only starts a module on Raxol.Headless.McpTools.startable/0. That is the list under :headless_startable when it is set, and otherwise the playground demos except Raxol.Playground.Demos.ReplDemo (it evaluates Elixir) and Raxol.Playground.Demos.BeamDashboardDemo (it sets the VM-wide :scheduler_wall_time flag and shows VM internals). The default set is pinned in a test, so a new demo becomes startable only through a reviewed change. A set list replaces the demos, so a deployment names exactly what clients may start by module:
config :raxol, :headless_startable, [MyApp]To keep the demos as well, list them alongside your app; Raxol.Headless.McpTools.default_startable/0 returns them. Every entry must be a loadable module (Code.ensure_loaded?/1). A value that is not a list of loadable modules stops the boot with a config error (Raxol.Headless.McpTools.register/1 raises), and if it is changed at runtime, raxol_start answers with that error and starts nothing. The error does not count against the tool's circuit breaker, so fixing the config is enough.
Anything else is refused before the name is resolved, so an unlisted name never becomes an atom. Elixir callers of Raxol.Headless.start/2, such as Raxol.MCP.Test and Raxol.Recording.Video, are trusted and are not limited by the list. The path argument is a separate opt-in (RAXOL_HEADLESS_PATH_ROOT) and does not consult the list, because the module is only known after the file is compiled.
raxol_start requires an id matching ^[a-z][a-z0-9]{0,6}$ and refuses any other id rather than substituting one. No _ or . keeps the session boundary clear after OMP's renaming (a_b.c and a.b_c would both become a_b_c), and seven characters keep the longest demo tool name within OMP's 64. raxol (the server key, see Tool derivation) and agent (Raxol.MCP.AgentBridge, in raxol_agent, names the tool it makes from an Action agent.<action>, and OMP would rename agent.read_file and this session's agent.read.file alike; the agent_* tools under Agents are static tools of their own) are reserved. Session ids are binaries only and never become atoms. Every session id, Elixir callers' included, is 1-32 characters of [A-Za-z0-9_-], the rule a widget id follows: an id reaches every client verbatim through raxol_list, session-id completion, resource URIs and tool names, so Raxol.Headless.start/2 answers any other :id (invalid UTF-8, a newline, a dot, 33 characters) with {:error, {:invalid_session_id, id}} and starts nothing. Its default id, the module's last segment underscored, is made to fit: a character outside the rule becomes _, and a name over 32 characters becomes its first 23, _ and 8 hex digits of the module's hash.
OMP's 64 counts its mcp__raxol_raxol_ prefix (17 characters, for the raxol:raxol plugin key), which leaves 47 for <session>_<widget>_<action>. A widget id may be 32 characters, so a 7-character session, a 32-character widget and the 17-character get_visible_range come to 58 characters, 75 with the prefix. Past 64 OMP lists the first 55 characters, _ and a hash: still callable, but every action of such a widget can look alike to a reader. The session logs a warning, once per name, for each derived tool past 64 with the prefix; widget ids of at most 21 characters keep every built-in action readable under a 7-character session.
Transports
stdio is exempt by design, since it already inherits the OS process boundary.
Raxol.Headless.McpTools.inject_into_tidewave/1 is not exempt. Tidewave dispatches out of its own ETS map, so a raw callback written there would never reach Raxol.MCP.Server; what gets injected is a closure that re-enters through tools/call on the server named by :server. Both halves of the record are substituted (the dispatch map AND the advertised tool entries' own :callback), so a consumer reaching for tool.callback cannot bypass the authorizer either. A server that is down denies rather than falling back to the raw callback, and the wait is bounded so one slow tool cannot hold the surface open indefinitely.
That gates Raxol's tools, not Tidewave's. Tidewave ships its own, and nothing here constrains them: project_eval evaluates Elixir in the running node. Whether that endpoint may be reached at all is an endpoint-level decision: authentication and bind address, not tool policy.
Property tests
Raxol.MCP.ToolProvider is functor-law-tested: tool derivation commutes with Component composition. If you compose two Components, the derived tools are the same as the tools you'd get by deriving them separately and merging. This catches bugs where a wrapping Component would accidentally hide tools from a child.
See also
- ADR-0012: design rationale
- Agent Framework: agents that consume MCP