# `Raxol.MCP.Client`
[🔗](https://github.com/DROOdotFOO/raxol/blob/v2.8.0/lib/raxol/mcp/client.ex#L1)

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.

# `call_result`

```elixir
@type call_result() :: %{content: [map()], is_error: boolean()}
```

# `t`

```elixir
@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()) =&gt; %{
      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()}]
}
```

# `tool`

```elixir
@type tool() :: %{
  name: String.t(),
  description: String.t(),
  input_schema: map(),
  annotations: map()
}
```

# `await_ready`

```elixir
@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.

# `call_tool`

```elixir
@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 the `Raxol.Agent.ToolCall.Hook` seam.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `handle_manager_cast`

# `list_tools`

```elixir
@spec list_tools(GenServer.server(), keyword()) :: {:ok, [tool()]} | {:error, term()}
```

List tools available on the MCP server.

# `parse_tool_name`

```elixir
@spec parse_tool_name(String.t()) :: {:ok, {String.t(), String.t()}} | :error
```

Parse a namespaced tool name into `{server, tool}` or `:error`.

# `start_link`

```elixir
@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.

# `status`

```elixir
@spec status(GenServer.server()) :: map()
```

Get the client's current status.

# `stop`

```elixir
@spec stop(GenServer.server()) :: :ok
```

Stop the MCP server and client.

# `tool_name`

```elixir
@spec tool_name(atom(), String.t()) :: String.t()
```

Build a namespaced tool name: `mcp__<server>__<tool>`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
