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

The seam between `Raxol.MCP.Client`'s one session machine and a wire.

ADR-0037 decision 1. Before it, the client was a JSON-RPC session machine
welded to a port: the config, the connect, the write, the read and the
teardown each assumed stdio, and roughly 60 of 395 lines were the part that
could not reach a URL. Everything else -- `pending`, `next_id`, `status`, the
tool cache and every `handle_result/3` branch -- is transport-independent and
stays in the client. A transport owns connection, framing and delivery, and
nothing else.

## Why `send/3` carries the id and returns a handle

An HTTP request is a round trip, not a write. A transport has to associate a
response with the request that caused it, and it has to be able to fail ONE
in-flight request without failing the connection, which is the
`{:failed, id, reason, handle}` arm of `decode_info/2`.

A stdio transport ignores the id on the way out: one pipe carries every
request and the id only matters on the way back.

`{:notify, id}` is a notification: encoded with no JSON-RPC id, because
nothing answers one, and reported under `id` all the same. It is still a
request in flight -- one POST on the session -- so the client holds an
entry for it and the transport says when it is done. A bare `nil` id is
the same notification with nothing tracking it, which no in-tree caller
uses any more.

## Why `cancel/2` exists

The client's own `call_timeout` can expire while the transport is still
working: an HTTP exchange's wall time is the dial plus the read deadline
plus a chunk overshoot, which together exceed the default 30 s. The entry
leaves `pending` at that point, so without `cancel/2` the next queued
request is admitted BESIDE a task that is still holding a socket -- two
concurrent requests on a `:serialized` session. `cancel/2` drops the work
for one id and returns the handle; for a transport with nothing to cancel
it is the identity.

## Why the request is a map rather than `iodata()`

ADR-0037 decision 1 specifies `send(handle, id, iodata())`, and decision 2's
own table then requires `Mcp-Method` and `Mcp-Name` headers on every
modern-era post. A transport handed opaque iodata cannot set either header
without re-parsing the body it was just given, so the third argument is the
request as `%{method:, params:}` (plus an optional `:reservation`) and
encoding belongs to the transport, which is also where framing already lives.
One encoder per framing, not one encoder plus one parser.

## Why `session/1` exists

A modern-era origin is specified not to answer `initialize`, and a legacy one
requires it. The client cannot know which without asking, and it must know
before it sends anything: offering a handshake to a modern server leaves the
client in `:initializing` forever. `session/1` is answered after `connect/1`
and reports the negotiated session profile: whether to handshake, the
protocol revision in force for this connection, and the concurrency policy
the origin's era implies (ADR-0037 decision 6). A spec that declares
`:concurrency` overrides the last of those.

## Why `decode_info/2` has `:closed` and `:settled` arms

ADR-0037 lists three returns. A fourth is forced by the code the same
decision requires to keep working unchanged: `{:exit_status, code}` fails
EVERY pending entry and marks the session closed (`client.ex:236-244` before
this change), which neither `{:messages, _, _}` nor a single-id
`{:failed, id, _, _}` can express. A fifth, `{:settled, id, handle}`, is
what a notification's completion is: a request that finished with nothing
to deliver, whose in-flight slot must still be released. `:ignore`
preserves the catch-all clause that was at `client.ex:246`.

## Why `respond/3` exists

Every other callback carries work the CLIENT started. `respond/3` carries the
one message the server starts: a JSON-RPC request (`id` + `method`), which
the spec obliges the receiver to answer. The client answered none of them --
`handle_message/2` dropped anything with a `method` -- so a server that sent
`elicitation/create` waited on a response that was never coming while the
session looked healthy from both ends.

`method` is passed alongside the response because the modern era routes every
POST on `Mcp-Method`, and the routing key for a response is the method it
answers. A stdio transport ignores it, exactly as it ignores the id in
`send/3`.

# `concurrency`

```elixir
@type concurrency() :: :stateless | :pooled | :serialized
```

Concurrency policy for one origin (ADR-0037 decision 6).

# `handle`

```elixir
@type handle() :: term()
```

Opaque per-connection transport state, threaded back through every call.

# `request`

```elixir
@type request() :: %{
  :method =&gt; String.t(),
  :params =&gt; map(),
  optional(:reservation) =&gt; term()
}
```

One outbound JSON-RPC message.

`:reservation` is the spend-gate handle for a priced tool call. Its ABSENCE
on a priced tool is what `Transport.Http` refuses; its value is opaque here.

# `session`

```elixir
@type session() ::
  {:handshake | :ready, %{version: String.t(), concurrency: concurrency()}}
```

The negotiated session profile, answered once after `connect/1`.

# `cancel`

```elixir
@callback cancel(handle(), pos_integer() | {:notify, pos_integer()}) :: handle()
```

# `close`

```elixir
@callback close(handle()) :: :ok
```

# `connect`

```elixir
@callback connect(config :: map()) :: {:ok, handle()} | {:error, term()}
```

# `decode_info`

```elixir
@callback decode_info(handle(), message :: term()) ::
  {:messages, [binary()], handle()}
  | {:failed, pos_integer(), term(), handle()}
  | {:settled, pos_integer(), handle()}
  | {:closed, term(), handle()}
  | :ignore
```

# `respond`

```elixir
@callback respond(handle(), method :: String.t(), response :: map()) ::
  {:ok, handle()} | {:error, term()}
```

# `send`

```elixir
@callback send(handle(), pos_integer() | nil | {:notify, pos_integer()}, request()) ::
  {:ok, handle()} | {:error, term()}
```

# `session`

```elixir
@callback session(handle()) :: session()
```

# `permit`

```elixir
@spec permit(map(), term()) :: :ok | {:error, {:blocked, :source}}
```

Whether a spec of this provenance may reach `target` -- the URL a remote
spec dials, or the command a stdio spec spawns.

A spec carries `:source` (`:user` when absent: a spec assembled in code is
the operator's own program) and, for anything else, a `:permit` function of
arity one that answers `:ok` or an error for that target. The rule the
function enforces is not this package's to know: `Raxol.Agent.McpHosts`
owns it, because the allowlist it reads is an operator file in `~/.raxol`
and `.mcp.json` is parsed one layer up.

This is asked INSIDE each `connect/1`, not only by the caller that built
the spec. `Raxol.Agent.McpBundle` asks first, for the operator-facing
refusal; a future caller that maps a workspace spec straight to
`Raxol.MCP.Client.start_link/1` gets the check anyway, because the socket
and the subprocess are here.

A refusal is `{:blocked, :source}` whatever the gate said. The gate's own
reason names the host or the command, which this package's closed error
taxonomy does not carry, and the caller that supplied the gate has already
logged it.

# `redact`

```elixir
@spec redact(map()) :: map()
```

A spec with every credential-bearing value replaced by `"[redacted]"`.

Public because the client reports refused specs too, and one redaction is
better than two.

Four keys, not one. `:headers` was the whole of it, and a STDIO spec carries
none -- so the fall-through clause handed back `:env`, which is where
`.mcp.json` puts `GITHUB_TOKEN`, and `:args`, which is where a CLI server
takes `--token=`. `:url` was never redacted on either leg, so `?api_key=`
and `user:pass@host` rode out with it.

Names survive, values do not: an operator reading `{:invalid_spec, _}` needs
to see WHICH variable and how many arguments, and neither is the secret. The
URL keeps scheme, host and path for the same reason -- that is the target,
and the userinfo and query are the credential.

# `select`

```elixir
@spec select(map() | keyword()) :: {:ok, module(), map()} | {:error, term()}
```

Pick the transport a spec asks for: `:command` XOR `:url`.

Both keys, or neither, is `{:error, {:invalid_spec, spec}}` rather than a
silent preference for one. A remote spec in a build without `mint` is
`{:error, :no_http_client}`, which `Raxol.Agent.McpBundle.load/2` already
handles as a per-server fail-open skip.

The refused spec is echoed back through `redact/1`, because a spec carries
resolved credentials and an error term is one of the four places ADR-0033
section 7 names as where they leak.

---

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