Raxol.MCP.Registry (Raxol MCP v2.8.0)

Copy Markdown View Source

ETS-backed registry for MCP tools and resources.

Any module can register tools and resources. The registry stores definitions alongside callback functions that are invoked when tools are called or resources are read.

Reads (list_tools, call_tool, list_resources, read_resource) go directly to ETS with read_concurrency: true -- no GenServer bottleneck. Writes (register_*, unregister_*) serialize through the GenServer.

Tool Registration

tools = [
  %{
    name: "raxol_screenshot",
    description: "Capture a screenshot",
    inputSchema: %{type: "object", properties: %{id: %{type: "string"}}},
    callback: fn args -> {:ok, [%{type: "text", text: "screenshot data"}]} end
  }
]
Registry.register_tools(registry, tools)

A tool registered with register_tools(registry, tools, derived: true) is a derived tool: static_tools/1 lists only the others, and that is what Raxol.MCP.Server's boot check scans. Only Raxol.MCP.ToolSynchronizer passes the option, for a session's tools (those Raxol.MCP.TreeWalker derives from its view, and its discover_tools meta-tool); a :derived key in a tool map is ignored.

Resource Registration

resources = [
  %{
    uri: "raxol://session/demo/model",
    name: "Session Model",
    description: "Current TEA model state",
    callback: fn -> {:ok, %{counter: 5}} end
  }
]
Registry.register_resources(registry, resources)

Which errors open a circuit

Every callback runs behind a Raxol.MCP.CircuitBreaker keyed on the tool, resource or prompt. A raise or an exit always counts as a failure; a returned {:error, reason} counts unless the registration declares a :fault? classifier:

%{
  name: "web3_get_transaction",
  description: "...",
  inputSchema: %{type: "object"},
  callback: &Tools.get_transaction/1,
  fault?: &Tools.fault?/1
}

:fault? is a 1-arity function called with the reason the callback returned. false means the error is an answer about the question -- a not-found, a malformed cursor -- and records nothing. Anything else is an availability fault and records a failure. The registry stays generic: it knows no taxonomy, only who to ask.

A callback that raises or exits answers {:error, {:callback_raised, module}} or {:error, {:callback_exited, atom}}. The exception message is logged, never returned, because the return value reaches a model.

Owners

register_tools/3, register_resources/3 and register_prompts/3 take an owner: pid. The registry monitors each owner, and when one exits it removes everything the owner still holds and, if that included tools, sends one notifications/tools/list_changed to its :server (start option, default Raxol.MCP.Server). Registering a name again moves it to the new registration's owner, or to none. A registration without an owner is never removed automatically, so a short-lived caller (a test, a task) does not take its tools with it when it exits.

Unregistering, by name or by an owner's exit, also forgets the entry's circuit-breaker state. await_owner_cleanup/2 is a barrier for callers that must not answer until a dead owner's entries are gone.

Summary

Functions

Wait until the registry holds no tools, resources or prompts for owner.

Call a registered tool by name with arguments.

Returns a specification to start this module under a supervisor.

Get circuit breaker status for a tool, resource, or prompt key.

Get a prompt by name, rendering it with the given arguments.

List all registered prompts (definitions without callbacks).

List all registered resources (definitions without callbacks).

List all registered tools (definitions without callbacks).

The name OMP (mcp/tool-bridge.ts, at the version ADR-0042 pins) makes of a tool name before it adds its mcp__<server>_ prefix: lowercased, each run outside [a-z0-9_] turned into _, runs of _ collapsed, _ trimmed from both ends, and "tool" if nothing is left.

Read a registered resource by URI.

Registers tools and/or resources in one call after validating each tool shape via Raxol.MCP.ToolDef.validate/1.

Manually reset a circuit breaker.

Start the registry, linked to the calling process.

list_tools/1 without the tools registered with derived: true: the tools an embedder registered itself, rather than a session's view.

Unregister resources by URI. With owner: pid, only those pid still owns (see unregister_tools/3).

Types

prompt_def()

@type prompt_def() :: %{
  :name => String.t(),
  :description => String.t(),
  :arguments => [map()],
  :callback => (map() -> {:ok, [map()]} | {:error, term()}),
  optional(:fault?) => (term() -> boolean())
}

resource_def()

@type resource_def() :: %{
  :uri => String.t(),
  :name => String.t(),
  :description => String.t(),
  :callback => (-> {:ok, term()} | {:error, term()}),
  optional(:fault?) => (term() -> boolean())
}

tool_def()

@type tool_def() :: %{
  :name => String.t(),
  :description => String.t(),
  :inputSchema => map(),
  :callback => (map() -> {:ok, term()} | {:error, term()}),
  optional(:fault?) => (term() -> boolean())
}

Functions

await_owner_cleanup(registry \\ __MODULE__, owner)

@spec await_owner_cleanup(GenServer.server(), pid()) :: :ok

Wait until the registry holds no tools, resources or prompts for owner.

call_tool(registry \\ __MODULE__, name, arguments)

@spec call_tool(GenServer.server(), String.t(), map()) ::
  {:ok, term()} | {:error, term()}

Call a registered tool by name with arguments.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

circuit_status(registry \\ __MODULE__, key)

@spec circuit_status(GenServer.server(), Raxol.MCP.CircuitBreaker.key()) :: map()

Get circuit breaker status for a tool, resource, or prompt key.

get_prompt(registry \\ __MODULE__, name, arguments)

@spec get_prompt(GenServer.server(), String.t(), map()) ::
  {:ok, [map()]} | {:error, term()}

Get a prompt by name, rendering it with the given arguments.

handle_manager_cast(msg, state)

Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.

list_prompts(registry \\ __MODULE__)

@spec list_prompts(GenServer.server()) :: [map()]

List all registered prompts (definitions without callbacks).

list_resources(registry \\ __MODULE__)

@spec list_resources(GenServer.server()) :: [map()]

List all registered resources (definitions without callbacks).

list_tools(registry \\ __MODULE__)

@spec list_tools(GenServer.server()) :: [map()]

List all registered tools (definitions without callbacks).

omp_name(name)

@spec omp_name(String.t()) :: String.t()

The name OMP (mcp/tool-bridge.ts, at the version ADR-0042 pins) makes of a tool name before it adds its mcp__<server>_ prefix: lowercased, each run outside [a-z0-9_] turned into _, runs of _ collapsed, _ trimmed from both ends, and "tool" if nothing is left.

OMP also strips an exact <server>_ prefix from the result once, which this does not model. Raxol's server key is raxol, so raxol_start refuses the session id raxol: that session's raxol.nav_menu.click would reach OMP as session nav's nav.menu.click. OMP's 64-character limit (55 characters, _ and an 8-character hash) applies to the prefixed name and, barring a hash collision, keeps distinct names distinct, so it is not modeled either: past it OMP shows a hashed name, about which Raxol.MCP.ToolSynchronizer warns.

read_resource(registry \\ __MODULE__, uri)

@spec read_resource(GenServer.server(), String.t()) ::
  {:ok, term()} | {:error, term()}

Read a registered resource by URI.

register_all(registry \\ __MODULE__, opts)

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

Registers tools and/or resources in one call after validating each tool shape via Raxol.MCP.ToolDef.validate/1.

Reduces the common consumer boilerplate:

:ok = Registry.register_tools(registry, tools)
:ok = Registry.register_resources(registry, resources)

to a single call:

:ok = Registry.register_all(registry, tools: tools, resources: resources)

Returns {:error, {:invalid_tool, index, reasons}} when validation fails and {:error, {:name_collision, [{refused_name, holder_name}]}} when a tool's omp_name/1 is taken, by a registered tool or by another tool in the batch. Either way nothing is registered: no tools, resources or prompts.

Note: this helper requires the Raxol.MCP.Registry module to be loaded at runtime. Consumers with raxol_mcp as an optional dep should still gate the call with Code.ensure_loaded?(Raxol.MCP.Registry). Optional deps are an Elixir-level concern; no helper can paper over a missing module.

register_prompts(registry \\ __MODULE__, prompts)

@spec register_prompts(GenServer.server(), [prompt_def()]) :: :ok

Register one or more prompts.

register_prompts(registry, prompts, opts)

@spec register_prompts(GenServer.server(), [prompt_def()], keyword()) :: :ok

register_prompts/2 with owner: pid (see "Owners").

register_resources(registry \\ __MODULE__, resources)

@spec register_resources(GenServer.server(), [resource_def()]) :: :ok

Register one or more resources.

register_resources(registry, resources, opts)

@spec register_resources(GenServer.server(), [resource_def()], keyword()) :: :ok

register_resources/2 with owner: pid (see "Owners").

register_tools(registry \\ __MODULE__, tools, opts \\ [])

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

Register one or more tools.

A client may rename tools: OMP (at the version docs/adr/0042-lean-on-omp.md pins) lowercases a tool name, turns each run of characters outside [a-z0-9_] into _, collapses repeated _ and trims them from both ends (omp_name/1). Two distinct names that come out the same there, such as "s.x_y.click" and "s.x.y.click", would reach the client as one tool. So a tool whose omp_name/1 is already held by a differently named tool is refused, and the rest are registered: {:error, {:name_collision, [{refused_name, holder_name}]}}. Registering a name again replaces its tool, as before. register_all/2 refuses the whole batch instead.

A tool whose :name is not a binary (or that has none) refuses the whole batch, {:error, {:invalid_tool_name, name}}, and the registry keeps serving the tools it holds.

derived: true registers them as derived, which leaves them out of static_tools/1 and so out of Raxol.MCP.Server's boot check (the tools/call refusal still holds them). It is for Raxol.MCP.ToolSynchronizer alone: a tool registered with it skips the check that refuses to boot an unguarded server on a sensitive tool.

owner: pid makes the registration owned (see "Owners").

reset_circuit(registry \\ __MODULE__, key)

@spec reset_circuit(GenServer.server(), Raxol.MCP.CircuitBreaker.key()) :: :ok

Manually reset a circuit breaker.

start_link(init_opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Start the registry, linked to the calling process.

:server names the Raxol.MCP.Server told when an owner's exit removes tools: an atom, a pid, {:global, name} or {:via, module, name}; any other value is {:error, {:invalid_server, server}} and starts nothing. The default is the application's Raxol.MCP.Server, so a registry started beside a private server passes that server here, or its owners' exits notify the application's clients instead.

static_tools(registry \\ __MODULE__)

@spec static_tools(GenServer.server()) :: [map()]

list_tools/1 without the tools registered with derived: true: the tools an embedder registered itself, rather than a session's view.

unregister_prompts(registry \\ __MODULE__, names)

@spec unregister_prompts(GenServer.server(), [String.t()]) :: :ok

Unregister prompts by name.

unregister_resources(registry \\ __MODULE__, uris, opts \\ [])

@spec unregister_resources(GenServer.server(), [String.t()], keyword()) :: :ok

Unregister resources by URI. With owner: pid, only those pid still owns (see unregister_tools/3).

unregister_tools(registry \\ __MODULE__, names, opts \\ [])

@spec unregister_tools(GenServer.server(), [String.t()], keyword()) :: :ok

Unregister tools by name.

With owner: pid, only the named tools pid still owns go (see "Owners"): a name another owner has registered since stays.