Raxol.Headless.McpTools (Raxol v2.8.0)

View Source

MCP tool definitions for Raxol headless sessions.

Registers raxol_start, raxol_screenshot, raxol_send_key, raxol_get_model, raxol_stop, and raxol_list as MCP tools via Raxol.MCP.Registry.

Authorization

The three tools that change something -- raxol_start, raxol_send_key, raxol_stop -- are annotated sensitive, so Raxol.MCP.Server refuses to run them when no authorizer is configured. So is raxol_get_model, a read that returns the whole model: an app keeps its secrets there, a masked input's plaintext included. raxol_screenshot (which draws a masked input as its mask) and raxol_list are not gated.

The annotation is only half of it: it does nothing until an authorizer exists at the call site, and denies permanently if one never does. Raxol.Application supplies one (config :raxol, :mcp_authorizer, defaulting to allow_all/0 outside production and to a deny-by-default allowlist in production), which is what makes these annotations meaningful rather than merely restrictive.

Both entry points land on that same gate. register/1 is the ordinary one; inject_into_tidewave/1 routes through Raxol.MCP.Server as well rather than handing Tidewave the raw callbacks, so neither surface can drift from the policy the other enforces.

Startable modules

A "module" argument starts only a module on startable/0. That is config :raxol, :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). 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 too (default_startable/0 returns them). Every entry must be a loadable module (Code.ensure_loaded?/1): register/1 raises on a value that is not, so a bad config stops the boot, and a value changed at runtime makes raxol_start answer with the same config error rather than start anything.

The list does not bind the "path" argument. When a deployment sets RAXOL_HEADLESS_PATH_ROOT (or config :raxol, :headless_path_root), raxol_start compiles and starts any module defined in a file under that root, listed or not, because the module is only known once the file is compiled. Unset, the argument is refused.

The list binds the MCP tool, which starts a module for an untrusted caller. Elixir callers of Raxol.Headless.start/2 (Raxol.MCP.Test, Raxol.Recording.Video) are trusted and start any Raxol application.

Summary

Functions

Every playground demo except Raxol.Playground.Demos.ReplDemo, which evaluates Elixir, and Raxol.Playground.Demos.BeamDashboardDemo, which sets the VM-wide :scheduler_wall_time flag and shows VM internals: what raxol_start may start when config :raxol, :headless_startable is not set.

Injects Raxol tools into Tidewave's ETS-based tool registry.

Register all headless tools with the given MCP Registry.

The modules raxol_start may start: config :raxol, :headless_startable when set (it replaces the default), else default_startable/0.

Returns the list of Raxol MCP tool definitions.

The Raxol.MCP.Server :unknown_tool_reply for Headless sessions (Raxol.Application passes it).

Functions

default_startable()

@spec default_startable() :: [module()]

Every playground demo except Raxol.Playground.Demos.ReplDemo, which evaluates Elixir, and Raxol.Playground.Demos.BeamDashboardDemo, which sets the VM-wide :scheduler_wall_time flag and shows VM internals: what raxol_start may start when config :raxol, :headless_startable is not set.

inject_into_tidewave(opts \\ [])

@spec inject_into_tidewave(keyword()) ::
  :ok
  | {:error,
     :inject_timeout
     | :tidewave_not_started
     | :tidewave_owner_not_alive
     | {:sys_replace_failed, term()}}

Injects Raxol tools into Tidewave's ETS-based tool registry.

Call after Tidewave.MCP has initialized (typically from Application.start). Safe to call multiple times -- tools are merged, not duplicated.

What is injected is NOT the raw tool callback. Tidewave dispatches straight out of its own map, so a callback written there would never reach Raxol.MCP.Server and no authorizer, annotation, or elicitation would apply to it -- the one route that bypasses every gate the rest of this module relies on. Each entry is instead a closure that re-enters through tools/call on the server named by :server, so this surface inherits exactly the policy the supervisor was started with. If that server is not running, calls are refused rather than executed.

This gates Raxol's tools, not Tidewave's

Tidewave's own tools are untouched and ungated by anything here; project_eval in particular evaluates Elixir in the running node. Whether that endpoint may be reached at all is an endpoint-level decision (authentication, bind address), not one this function can make.

The Tidewave ETS table is :protected, so the insert must run in the owning process. We spawn a task linked to that process to do the write.

Options

  • :server -- the MCP server calls are routed through. Defaults to Raxol.MCP.Server.

register(registry \\ Raxol.MCP.Registry, opts \\ [])

@spec register(GenServer.server(), keyword()) ::
  :ok | {:error, {:name_collision, [{String.t(), String.t()}]}}

Register all headless tools with the given MCP Registry.

Called from Raxol.Application after the MCP supervisor and Headless are both running. opts go to Raxol.MCP.Registry.register_tools/3 (owner: pid). Raises ArgumentError when config :raxol, :headless_startable is invalid (see startable/0), so a bad config stops the boot instead of surfacing at the first raxol_start.

Answers {:error, {:name_collision, [{refused, holder}]}} when the registry refused some of the tools because another tool, holder, already has the same client-side name (Raxol.MCP.Registry.register_tools/2); the rest are registered.

startable()

@spec startable() :: {:ok, [module()]} | {:error, String.t()}

The modules raxol_start may start: config :raxol, :headless_startable when set (it replaces the default), else default_startable/0.

Answers {:error, sentence} when the configured value is not a list of loadable modules (Code.ensure_loaded?/1, which loads one that is on the code path but not loaded yet).

tools(registry \\ Raxol.MCP.Registry)

Returns the list of Raxol MCP tool definitions.

raxol_start starts sessions whose derived tools are registered in registry (default Raxol.MCP.Registry), the registry register/1 puts these tools in.

unknown_tool_reply(name)

@spec unknown_tool_reply(String.t()) :: String.t() | nil

The Raxol.MCP.Server :unknown_tool_reply for Headless sessions (Raxol.Application passes it).

A client keeps the tool names it listed last (OMP caches them across runs), so a call to <session>.<widget>.<action> or <session>.discover_tools whose session is not in Raxol.Headless.list/0 answers "session <session> is not running in this server; call raxol_list", which the caller can act on. A name of that shape whose session is running, whose session is one of the ids raxol_start reserves (raxol, agent), or whose segments no session could derive (Raxol.MCP.TreeWalker.id?/1), and any other name answer nil: the server's -32601. The server also hosts any dotted tools an embedder registers, so an unknown one of that shape is answered as a stopped session too.