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

Copy Markdown View Source

The pinned dial and the bounded read: one request, one bounded response.

ADR-0037 decision 5, which says explicitly that this transport takes the shape ADR-0038 decision 3 and 4 already implement for the REST side (packages/raxol_web3/lib/raxol/web3/dial.ex, packages/raxol_web3/lib/raxol/web3/exchange.ex). One design in two packages rather than two: copying is forced by the package graph, since raxol_web3 depends on raxol_mcp and not the other way round.

The checked address is the address dialled

run/3 takes the :inet address tuples Raxol.Core.Outbound.vet/2 checked, never a name, so there is no code path here that could consult a resolver and no window for a record that changes between the check and the connect. A binary where an address belongs is refused as {:not_an_address, value} rather than resolved.

Mint derives everything that must follow the NAME from one :hostname option: SNI, the certificate match function, and the HTTP/1 Host header. So :server_name_indication and :customize_hostname_check are never set by hand -- setting either replaces a value Mint already derived correctly -- and :verify and :verify_fun are refused outright, because a silently weakened handshake is the failure this module exists to prevent. A caller-supplied :transport_opts never reaches here: the only caller is Raxol.MCP.Client.Transport.Http, which does not accept one.

There is no connection pool. A pool key is {scheme, host, port, tag}, so a vetted IP in the host position makes every hostname that resolves to that address share one pool and inherit whichever identity opened it first.

The bounded read

The send, the four bounds (size ceiling, content-length pre-rejection, wall-clock deadline, per-recv silence) and the close are Raxol.MCP.BoundedExchange, which Raxol.Web3.Exchange reads through too: the two used to be identical copies, down to the read-phase taxonomy. The reasoning for each bound is in that module. What stays here is the dial and the connect-side reasons, which are this transport's alone.

Truncation is never a success

The accumulator is Raxol.Core.Outbound.Response: it starts :incomplete and only the terminating response promotes it, so run/3 cannot return {:ok, _} for a partial response by construction. That matters more here than for a REST body: a truncated SSE stream still contains whole frames, so a reader that returned what it had would hand the session a plausible-looking response to a request that never completed.

Redirects

A 3xx is returned as a response, not followed, and the caller refuses it. Following one would replay this transport's Authorization header at an origin the upstream chose.

Summary

Functions

Connect to the first reachable vetted address, presenting hostname as the identity.

Dial a vetted target and read one bounded response.

Types

reason()

@type reason() ::
  :no_addresses
  | {:not_an_address, term()}
  | {:forbidden_transport_opts, [atom()]}
  | {:too_large, pos_integer()}
  | {:timeout, :connect | :chunk | :deadline}
  | {:transport, term()}

request()

@type request() :: %{
  :method => String.t(),
  :path => String.t(),
  optional(:headers) => Mint.Types.headers(),
  optional(:body) => iodata() | nil
}

response()

@type response() :: Raxol.MCP.BoundedExchange.response()

vetted()

@type vetted() :: %{
  uri: URI.t(),
  addresses: [:inet.ip_address()],
  hostname: String.t()
}

Functions

connect(vetted, opts)

@spec connect(vetted(), keyword()) :: {:ok, Mint.HTTP.t()} | {:error, reason()}

Connect to the first reachable vetted address, presenting hostname as the identity.

Every address is tried in turn, which is the failover :gen_tcp performs for a name and which pinning would otherwise lose. Public so that the dial can be tested against a real local TLS listener, which the reject set refuses to let the guarded path reach.

:deadline_at (a monotonic millisecond stamp, set by run/3) clamps every attempt: N addresses at connect_timeout_ms each would otherwise exceed the whole exchange's budget before a byte was sent.

run(vetted, request, opts \\ [])

@spec run(vetted(), request(), keyword()) :: {:ok, response()} | {:error, reason()}

Dial a vetted target and read one bounded response.

Options: :port (defaults to the vetted URI's port), :connect_timeout_ms, :deadline_ms, :chunk_timeout_ms, :max_bytes, and :transport_opts for a test's own trust store. The connection is closed on every path.

The deadline is computed HERE, before the dial, and what reaches BoundedExchange.run/3 is what is left of it. BoundedExchange's moduledoc says the deadline is taken at entry so the send counts against it; starting that clock after the connect made the real wall bound connect_timeout_ms x addresses + deadline_ms + chunk_timeout_ms, which was stated nowhere and, at the defaults, exceeded the client's 30 s call_timeout. Each connect attempt is clamped to the remaining budget for the same reason, so the bound is deadline_ms plus at most one chunk_timeout_ms of read overshoot.