Raxol.MCP.Client.Transport.Http (Raxol MCP v2.8.0)

Copy Markdown View Source

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.

Summary

Functions

Answer a request the SERVER sent us.

The negotiated session profile.

Types

t()

@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

respond(handle, method, response)

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(http)

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.