Bounded, non-pooling HTTP transport for MCP and MCP OAuth traffic.
The adapter talks to Mint directly so request headers and bodies never enter a pool process or global Telemetry events. Each call owns one socket, rejects redirects at the caller's protocol layer, observes one absolute timeout budget, and closes the connection after the response or on caller exit. Socket ownership and Mint message handling run in a dedicated worker, so synchronous requests cannot consume unrelated messages from the caller's mailbox.
request/1 accepts optional :on_status, :on_headers, and :on_data
callbacks for bounded streaming. The status callback receives the response
as soon as a status line is parsed, the header callback receives it after
each header block, and the data callback receives the current response and
one data chunk. Each returns either {:cont, response} or
{:halt, response}. Halting closes the socket immediately; MCP uses this to
act on a definitive 401 even when the remaining header block is malformed
or stalls, act on a complete 403 challenge without waiting for its body,
stop after one complete SSE response, or stop at a response ceiling.
This module deliberately emits no request-bearing Logger or Telemetry data. Returned errors are closed atoms and never retain Mint exceptions, endpoint strings, headers, or bodies. Connect failures distinguish a refused socket, an unresolved hostname, and an allowlisted TLS configuration or protocol alert; every other transport value remains generic.
Receive ceiling
:max_body_bytes, :max_headers, and :max_header_bytes bound what is
kept. They cannot bound what a peer may deliver, because they are applied
to values Mint has already parsed out of a socket message that reached this
process whole. Two further bounds cover that gap, and both are enforced
before any parsing.
:max_receive_bytes is the per-message ceiling. It is applied as the
socket's buffer option, which is the size of the driver's user-level read
buffer and therefore the largest single {:tcp, _, data} the driver will
deliver. Mint cannot carry it: its private inet-options helper raises buffer
to max(buffer, sndbuf, recbuf) on every initiate/5, so a value passed
through :transport_opts is overwritten with an operating-system-derived
size — 400 KiB on an ordinary loopback socket. The connection is therefore
opened in :passive mode, which is the one mode whose initiate/5 does not
arm active: :once, the ceiling is applied to the unarmed socket, and only
then is the connection switched to :active. Setting the ceiling on an
already-armed socket is too late: the driver has a read outstanding under the
old buffer, and a 160 KiB message can still arrive under a 16 KiB ceiling.
Over TLS the ceiling bounds the ciphertext read, so a record left over from
a previous read may be delivered alongside a full buffer. One TLS record
(16_384 bytes, the protocol's plaintext maximum) is the documented allowance;
:max_receive_bytes is required to be a whole number of records so the two
are expressed in the same unit.
The socket option makes the bound true; it does not enforce it, so the
receive loop also refuses any single message over the declared maximum. One
window needs that: Mint raises buffer at the end of its connect, and over
TLS the ssl connection process accumulates under the raised value if this
worker is descheduled before the ceiling is reapplied. A 30 ms gap delivers a
whole operating-system receive buffer — 400 KiB against a 16 KiB ceiling.
Lowering buffer afterwards does not shrink what has already accumulated,
and Mint's raise cannot be prevented: it is max(buffer, sndbuf, recbuf), and
the operating system clamps a small recbuf back up. The refusal costs a
legitimate peer nothing, because the window closes before the request is sent
and anything accumulating inside it was unsolicited.
The second ceiling is derived rather than passed, and bounds what Mint may buffer unparsed — the one thing none of the other limits can see. A peer that never completes a status line, a chunk-size line, or a chunk extension produces no Mint response at all, so no limit on a parsed value ever runs, while Mint buffers the remainder with no ceiling of its own. Without this count, 64 MiB from such a peer becomes 64 MiB resident in this process.
It is counted over raw socket payloads before they reach Mint, and reset
whenever Mint returns any response, because a response means Mint consumed
what it had rather than kept it. That reset is what keeps the ceiling off the
transfer encoding: chunked framing costs about six bytes per chunk, so a
cumulative wire ceiling would have to guess how finely a peer chunks its body
and would refuse a legitimate 2 MiB text/event-stream sent as 100-byte
events. Only a peer sending bytes that yield nothing accumulates. The ceiling
is max_header_bytes plus one whole delivered message — the largest header
block Mint will accept before emitting anything, plus the largest thing the
transport can hand over in one piece — and a peer that crosses it is refused
with :response_exceeded.
Resident bytes for one response are bounded, but not by a plain sum of the four numbers, and the difference is worth stating because it is easy to get wrong: a reset zeroes the counter while Mint's leftover survives it, so the unparsed remainder is the pending ceiling plus the message that carried the reset. It does not ratchet beyond that — Mint's leftover is always a prefix of the incomplete token at the front, and emitting anything consumes that prefix whole, so leftover at a reset is at most one message. The bound is therefore the accumulated body and headers, plus the pending ceiling, plus two delivered messages: the one that carried the last reset and the one in flight.
What is not bounded here is total bytes read. A peer may send bare 1xx
informational responses indefinitely: Mint emits a response for each, so the
counter resets, and none of them advances a header or body ceiling either.
That costs bandwidth and scheduling but not memory, and the operation deadline
is what ends it. Bounding it would mean a cumulative wire ceiling, which is
the thing the reset exists to avoid.
Summary
Functions
Returns all values for a response header using an ASCII-insensitive name.
Types
@type dispatch_provenance() :: :not_dispatched | :possibly_dispatched
@type reason() ::
:invalid_request
| :timeout
| :transport_error
| :response_exceeded
| :connection_refused
| :name_not_resolved
| :tls_handshake_failed
@type response() :: %{ :status => non_neg_integer(), :headers => [{binary(), binary()}], :body => term(), optional(:authorization_result) => term() }
A bounded response with downcased header names.