Raxol.MCP.BoundedExchange (Raxol MCP v2.8.0)

Copy Markdown View Source

One request and one bounded response on a dialled connection.

ADR-0038 decision 4 and ADR-0037 decision 5 describe the same read loop, and this is it, once. Nothing below this module bounds anything: Mint caps a header section at 256 KiB and nothing else, and there is no response-size ceiling, no overall deadline and no truncation signal anywhere in the stack. So the read loop is ours, and it owns four bounds.

The four bounds

  • Size. A byte counter over the accumulated body, refusing at the ceiling (:max_bytes, default 2 MiB). Exactly the ceiling is allowed; one byte past it is {:too_large, limit}.
  • Content-length. When the header is present and exceeds the ceiling, the response is refused before a single body byte is read, so an upstream that announces 3 MB costs one round trip rather than the ceiling.
  • A wall-clock deadline (:deadline_ms, default 20 s), computed once at entry so the request send counts against it, and re-checked before every recv. A caller that dials first passes the REMAINING budget: Raxol.MCP.Client.Transport.Http.Exchange computes the deadline before its connect, so the dial counts against it too.
  • Per-recv silence (:chunk_timeout_ms, default 10 s), which bounds a peer that stops talking mid-response.

Each recv is given min(remaining_deadline, chunk_timeout), which is what makes the two timeouts distinguishable rather than merged: a timeout on a clamped wait is {:timeout, :deadline}, a timeout on a full chunk wait is {:timeout, :chunk}. Total overshoot past the deadline is bounded by one chunk timeout, so the worst case is deadline_ms + chunk_timeout_ms.

Every reason is an atom

A reason returned from here is {:too_large, limit}, {:timeout, :chunk | :deadline} or {:transport, atom()} -- never a raw Mint struct. Mint's {:invalid_header_value, name, value} carries the offending header VALUE, and this module's callers log their reasons.

Why the loop, and not a stream callback

This is the shape Finch implements for :request_timeout, a budget decremented by each recv and re-checked before the next (deps/finch/lib/finch/http1/conn.ex:298-301, :313-323). Owning the loop is what makes the deadline cover the HEADER phase. A deadline checked inside a stream callback cannot: with Mint's default stream_headers: false no callback runs until the whole header section is parsed (deps/mint/lib/mint/http1.ex:159-162), so a peer dribbling header bytes just inside the chunk timeout is bounded only by the 256 KiB :max_header_list_size (:154-157), which at one byte per nine seconds is not a bound. ADR-0038's earlier draft chose the callback; this is the correction.

Truncation is never a success

The accumulator is Raxol.Core.Outbound.Response: it starts :incomplete and is promoted to :complete only by the terminating response, so run/3 cannot return {:ok, _} for anything but a complete read, by construction. The reasoning for the tagged state is in that module.

The connection

Always closed, on every path. There is no pool (ADR-0038 decision 3), so a connection outlives nothing and leaving one open would leak a socket per request rather than save a handshake.

Why this module lives in raxol_mcp

Both HTTP clients in this repository need exactly this loop: Raxol.Web3.Exchange for the REST backends and Raxol.MCP.Client.Transport.Http.Exchange for the remote MCP transport. Two copies is what the ratchet catches, and the two copies were identical down to the taxonomy, so there was nothing to average.

It cannot live in raxol_core beside Raxol.Core.Outbound.vet/2 and the accumulator, because it calls Mint.HTTP and raxol_core carries no dependencies at all. It can live here: mint is already an OPTIONAL dependency of this package, raxol_web3 already depends on raxol_mcp outright for Raxol.MCP.CircuitBreaker (ADR-0033 decision 2), and the dependency runs that way and not the other. The dial stays with each caller, since one pins vetted addresses for a remote MCP server and the other is Raxol.Web3.Dial; so does each caller's connect-side taxonomy.

Like every other module here that touches Mint, it is compiled only when mint is available, which is why Raxol.MCP.Client.Transport.select/1 checks for it before handing out the HTTP transport.

Summary

Functions

The default wall-clock deadline, in milliseconds.

Send request on conn and read the response under the four bounds.

Types

reason()

@type reason() ::
  {:too_large, pos_integer()}
  | {:timeout, :chunk | :deadline}
  | {:transport, atom()}

request()

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

response()

Functions

default_deadline_ms()

@spec default_deadline_ms() :: pos_integer()

The default wall-clock deadline, in milliseconds.

Public because a caller that dials before calling run/3 starts this clock itself, and it has to be the same number.

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

@spec run(Mint.HTTP.t(), request(), keyword()) ::
  {:ok, response()} | {:error, reason()}

Send request on conn and read the response under the four bounds.

The connection is closed before returning, whatever the outcome.

Options: :max_bytes, :deadline_ms, :chunk_timeout_ms.