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. 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 |
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. The focus lens filters to ~15 tools per interaction based on:
- Current focused Component
- Mouse hover (in
:hoverfocus mode) - Modal stack (modals shadow background Components)
- Recently interacted-with Components
tools =
Raxol.MCP.FocusLens.filter(all_tools,
mode: :focused,
focused_id: "search_input",
max_tools: 15
)
length(tools) # ~15, not ~100The lens is attention-aware: agents see what a human would see, not a flat dump of every possible action.
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.
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.
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, and raxol_stop carry the annotation; the three read tools do not.
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
- Symphony: orchestrator that exposes its own MCP surface