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.
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.
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.
Register one or more prompts.
register_prompts/2 with owner: pid (see "Owners").
Register one or more resources.
register_resources/2 with owner: pid (see "Owners").
Register one or more tools.
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 prompts by name.
Unregister resources by URI. With owner: pid, only those pid still
owns (see unregister_tools/3).
Unregister tools by name.
Types
Functions
@spec await_owner_cleanup(GenServer.server(), pid()) :: :ok
Wait until the registry holds no tools, resources or prompts for owner.
@spec call_tool(GenServer.server(), String.t(), map()) :: {:ok, term()} | {:error, term()}
Call a registered tool by name with arguments.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec circuit_status(GenServer.server(), Raxol.MCP.CircuitBreaker.key()) :: map()
Get circuit breaker status for a tool, resource, or prompt key.
@spec get_prompt(GenServer.server(), String.t(), map()) :: {:ok, [map()]} | {:error, term()}
Get a prompt by name, rendering it with the given arguments.
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.
@spec list_prompts(GenServer.server()) :: [map()]
List all registered prompts (definitions without callbacks).
@spec list_resources(GenServer.server()) :: [map()]
List all registered resources (definitions without callbacks).
@spec list_tools(GenServer.server()) :: [map()]
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.
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.
@spec read_resource(GenServer.server(), String.t()) :: {:ok, term()} | {:error, term()}
Read a registered resource by URI.
@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.
@spec register_prompts(GenServer.server(), [prompt_def()]) :: :ok
Register one or more prompts.
@spec register_prompts(GenServer.server(), [prompt_def()], keyword()) :: :ok
register_prompts/2 with owner: pid (see "Owners").
@spec register_resources(GenServer.server(), [resource_def()]) :: :ok
Register one or more resources.
@spec register_resources(GenServer.server(), [resource_def()], keyword()) :: :ok
register_resources/2 with owner: pid (see "Owners").
@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").
@spec reset_circuit(GenServer.server(), Raxol.MCP.CircuitBreaker.key()) :: :ok
Manually reset a circuit breaker.
@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.
@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.
@spec unregister_prompts(GenServer.server(), [String.t()]) :: :ok
Unregister prompts by name.
@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).
@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.