PtcRunner.Kernel.MCPHTTPAdapter (PtcRunner v0.14.0)

Copy Markdown View Source

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

Types

A bounded response with downcased header names.

Functions

Returns all values for a response header using an ASCII-insensitive name.

Types

dispatch_provenance()

@type dispatch_provenance() :: :not_dispatched | :possibly_dispatched

reason()

@type reason() ::
  :invalid_request
  | :timeout
  | :transport_error
  | :response_exceeded
  | :connection_refused
  | :name_not_resolved
  | :tls_handshake_failed

response()

@type response() :: %{
  :status => non_neg_integer(),
  :headers => [{binary(), binary()}],
  :body => term(),
  optional(:authorization_result) => term()
}

A bounded response with downcased header names.

Functions

get_header(arg1, name)

@spec get_header(response(), binary()) :: [binary()]

Returns all values for a response header using an ASCII-insensitive name.

request(opts)

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