MCP (Model Context Protocol) client for consuming external tool servers.
One JSON-RPC session machine over a pluggable transport: it performs the
initialize handshake where the peer's era requires one, discovers available
tools via tools/list, and executes tool calls via tools/call. The wire is
Raxol.MCP.Client.Transport (ADR-0037 decision 1): a local subprocess
(Transport.Stdio) or a hosted HTTPS endpoint (Transport.Http).
Usage
{:ok, client} = Client.start_link(
name: :my_server,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
)
{:ok, remote} = Client.start_link(
name: :intel,
url: "https://mcp.example.com/mcp",
headers: [{"authorization", "Bearer ..."}]
)
{:ok, tools} = Client.list_tools(client)
{:ok, result} = Client.call_tool(client, "read_file", %{path: "/tmp/hello.txt"})
Client.stop(client)The spec discriminator is :command XOR :url. A spec carrying both, or
neither, is {:error, {:invalid_spec, spec}} with its header values
redacted; a :url spec in a build without mint is
{:error, :no_http_client}.
Header values reaching this module are ALREADY RESOLVED LITERALS. Nothing
here reads ${env:VAR} or op://...: resolution and the provenance rule
that gates it live in raxol_agent, where .mcp.json is parsed.
Concurrency and the in-flight window
A GenServer serializes calls on entry, which is not the same as bounding
the requests in flight: two callers used to get two concurrent requests on
one session, and a TronScan-shaped upstream answers 500 to both
(docs/proposals/web3-upstream-survey.md:72-78). So the window is explicit
(ADR-0037 decision 6). :concurrency is :stateless, :pooled or
:serialized; every policy has a finite in-flight cap, and a :serialized
origin has a cap of one. Excess work waits in a bounded queue that answers
{:error, :busy} on overflow rather than admitting work it cannot start.
Every admitted request carries a timer. On expiry the client replies
{:error, :timeout} itself and drops the entry, which is what stops
pending from growing without bound: before this, an entry was removed only
by a reply, a JSON-RPC error or a subprocess exit status, and a caller whose
GenServer.call timed out left its entry behind forever. An HTTP transport
has no exit status at all.
Recovery
A connect that fails, and a handshake that fails, both land the client in
:closed carrying the reason and schedule a retry whose delay starts at
:reconnect_ms (1 s) and doubles to one minute. Nothing here stops the
process: start_link/1 links the CALLER, so a {:stop, _} would take the
agent down over one bad spec. A session the origin rejects mid-flight fails
that one request and is re-established in place, on the same handle.
The exception is a 401 at connect or handshake: the headers were resolved
once, so a retry could only present the same rejected credential. The client
stays :closed with {:connect_failed, reason}, schedules nothing, and
answers any await_ready/2 caller at once. The credential's owner replaces
the client.
A spec with fail_fast: true keeps the retry but stops await_ready/2
from waiting through it: each failed connect or handshake answers the
callers already waiting with {:error, {:connect_failed, reason}}, and a
caller that arrives while the client is still :closed after that failure
gets the same answer at once. An owner that runs its own retry policy (and
stops this client to do so) uses it to see the failure at once instead of
at its deadline.
Tool Namespacing
Tools are namespaced with the server name prefix: mcp__<server>__<tool>.
Use tool_name/2 to build namespaced names, and parse_tool_name/1 to
decompose them.
Summary
Functions
Block until the session is usable, or timeout passes.
Call a tool on the MCP server.
Returns a specification to start this module under a supervisor.
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.
List tools available on the MCP server.
Parse a namespaced tool name into {server, tool} or :error.
Start an MCP client linked to the calling process.
Get the client's current status.
Stop the MCP server and client.
Build a namespaced tool name: mcp__<server>__<tool>.
Types
@type t() :: %Raxol.MCP.Client{ backoff_ms: pos_integer() | nil, call_timeout: pos_integer(), concurrency: Raxol.MCP.Client.Transport.concurrency(), config: map() | nil, declared_concurrency: Raxol.MCP.Client.Transport.concurrency() | nil, error: term() | nil, fail_fast: boolean(), handle: Raxol.MCP.Client.Transport.handle() | nil, in_flight_cap: pos_integer(), init_timeout: pos_integer(), name: atom(), next_id: pos_integer(), pending: %{ required(pos_integer()) => %{ from: GenServer.from() | :init | :notify, method: String.t(), timer: reference() | nil } }, queue: :queue.queue() | nil, queue_limit: non_neg_integer(), queued: non_neg_integer(), reconnect_ms: pos_integer(), registry: atom() | nil, status: :starting | :initializing | :ready | :closed, tools: [tool()] | nil, transport: module(), version: String.t() | nil, waiters: [{GenServer.from(), reference()}] }
Functions
@spec await_ready(GenServer.server(), timeout()) :: {:ok, map()} | {:error, term()}
Block until the session is usable, or timeout passes.
{:ok, status} once the handshake has round-tripped (immediately, for an
era that has none), or {:error, {:not_ready, status}} when the budget
passes first. A client that is retrying a failed connect keeps the caller
waiting rather than answering for the state it is passing through (unless
the spec sets fail_fast: true, see Recovery), which
is the whole point: readiness is an event, and polling status/1 in a
sleep loop is a race dressed up as a test helper.
@spec call_tool(GenServer.server(), String.t(), map(), keyword()) :: {:ok, call_result()} | {:error, term()}
Call a tool on the MCP server.
Options:
:timeout- caller-side timeout, default 30 s.:reservation- the spend-gate handle for a priced tool. A tool the spec declares as priced, called with no handle, is{:error, :unmetered_call}and no request is issued (ADR-0037 decision 7). The value is opaque here; it is minted at theRaxol.Agent.ToolCall.Hookseam.
Returns a specification to start this module under a supervisor.
See Supervisor.
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.
@spec list_tools(GenServer.server(), keyword()) :: {:ok, [tool()]} | {:error, term()}
List tools available on the MCP server.
Parse a namespaced tool name into {server, tool} or :error.
@spec start_link(keyword() | map()) :: GenServer.on_start() | {:error, term()}
Start an MCP client linked to the calling process.
The spec is a keyword list or a map. :name is required; :command XOR
:url selects the transport. Refused specs never start a process, so an
invalid spec costs no supervision noise.
@spec status(GenServer.server()) :: map()
Get the client's current status.
@spec stop(GenServer.server()) :: :ok
Stop the MCP server and client.
Build a namespaced tool name: mcp__<server>__<tool>.