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

The hosted-endpoint transport: dual era, target-policed, and metered.

ADR-0037 decisions 2, 3, 5, 6 and 7. `Raxol.MCP.Client` could only spawn a
local subprocess, which left every hosted MCP server unreachable by the
client that ADR-0033 plans to reuse for Tron and Canton.

## Dual era

The current specification (2026-07-28) removed the handshake and the
session; the servers measured on 2026-08-31 still require both. So the era
is probed with `server/discover` and cached per `{origin, path}`
(`Raxol.MCP.Client.Era`), and the probe's demotion rule is deliberately
narrow: a refusal is not an era. The probe runs in the client's process at
connect time, once, because nothing can be sent until its answer is known;
a LATER probe, the one a rejected session triggers, runs in a monitored
task like every other exchange here.

A spec that knows its server's era may pin it with `era: :modern` or
`era: :legacy`; nothing is probed then, at connect or after a rejected
session. That exists for servers that refuse `server/discover` with
something other than the demoting answers -- Robinhood's trading endpoint
answers it with a plain-text 400 -- where the probe would otherwise wedge
the connection on a status that is correctly not era evidence.

## Every request is a monitored task

`send/3` returns as soon as the request is on its way, so the client's
GenServer never blocks its own mailbox for a round trip -- which would make
the `:pooled` concurrency policy meaningless -- and a task that dies fails
exactly ONE pending entry instead of orphaning it. The task carries its own
monitor reference, so a completed request is demonitored precisely rather
than by scanning.

## The target rules, in one place

ADR-0033 section 7, enforced here rather than per backend:

  1. `https` only, through `Raxol.Core.Outbound.vet/2`'s default.
  2. Every resolved address checked against the reject set, both families,
     refusing the whole request if ANY answer is rejected.
  3. The checked address is the address dialled
     (`Raxol.MCP.Client.Transport.Http.Exchange`).
  4. **Redirects are not followed.** A 3xx is an error. That matters more
     here than for a REST client: this transport sends an `Authorization`
     header, so following a 3xx would replay a bearer token at an
     upstream-chosen origin.
  5. Time and response size are bounded by the exchange; concurrency is
     bounded by the session profile `session/1` reports, which the client
     turns into an in-flight cap and a bounded queue.

`:transport_opts` is deliberately not an option. TLS options belong to the
dial, which refuses the four that would weaken a handshake, and accepting
one here would be a second place to look for a `verify: :verify_none`.

## Credentials

Header values arrive as resolved literals and never reach a log line, a
telemetry measurement, a cache key or an error term. The error taxonomy is
closed and carries no upstream text, no query string and no
caller-influenced host:

    {:blocked, :invalid_url | :address | :source} | :dns_failed | :breaker_open
    | {:http, status} | {:redirect_refused, status} | {:too_large, limit}
    | {:timeout, :connect | :chunk | :deadline} | {:transport, atom()}
    | :session_rejected | {:task_down, atom()}
    | :unmetered_call | {:unknown_price, tool} | :invalid_era

The `Inspect` implementation below redacts header values, because a
GenServer crash report prints its state and the handle lives in it.

## Metering

A `tools/call` for a tool the spec declares as priced is refused unless the
call carries a reservation handle, with no request issued. This is not
redundant with the spend-gate hook: a backend
whose `handles_tools_internally?/0` returns true drives its own tool loop
and bypasses that seam entirely, so it reaches this transport
with no hook in the path. An unknown price on a metered origin is denied by
default and logged with the tool and the origin, so an operator sees a
reason rather than an absence.

# `t`

```elixir
@type t() :: %Raxol.MCP.Client.Transport.Http{
  bounds: term(),
  era: term(),
  era_ttl: term(),
  exchange: term(),
  headers: term(),
  key: term(),
  metered: term(),
  name: term(),
  pinned_era: term(),
  prices: term(),
  reprobed?: term(),
  session_id: term(),
  tables: term(),
  tasks: term(),
  version: term(),
  vetted: term()
}
```

# `respond`

Answer a request the SERVER sent us.

A POST carrying only a JSON-RPC response is specified to be answered `202`
with no body, so it is issued exactly like a notification: a monitored
task, tracked under a `nil` id, with nothing for the client to correlate.
It is deliberately NOT metered -- a response is not a tool call -- and
deliberately not admitted through the client's in-flight window, because a
response the server is already blocked on must not queue behind the
requests that are blocked on the server.

# `session`

The negotiated session profile.

A modern origin needs no handshake and tolerates a fan-out. A legacy one
handshakes and defaults to `:serialized`, because one of the three measured
upstreams answers 500 to BOTH of two parallel calls on one session,
reproducibly, and the failure is invisible to a single-call test. A spec
that declares `:concurrency` overrides this.

---

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