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:
httpsonly, throughRaxol.Core.Outbound.vet/2's default.- Every resolved address checked against the reject set, both families, refusing the whole request if ANY answer is rejected.
- The checked address is the address dialled
(
Raxol.MCP.Client.Transport.Http.Exchange). - Redirects are not followed. A 3xx is an error. That matters more
here than for a REST client: this transport sends an
Authorizationheader, so following a 3xx would replay a bearer token at an upstream-chosen origin. - Time and response size are bounded by the exchange; concurrency is
bounded by the session profile
session/1reports, 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_eraThe 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.
Summary
Types
@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() }
Functions
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.
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.