# `Snodo.Client.HTTP`
[🔗](https://github.com/joshrotenberg/snodo/blob/v0.4.1/lib/snodo/client/http.ex#L1)

Streamable HTTP transport for `Snodo.Client.connect({:http, url}, opts)`.

Requests use a bounded pool of HTTP/1.1 `:gen_tcp` or `:ssl` connections.
Fully read, framed responses can return their connection to the pool;
streams and responses that run until socket close do not. The request
headers come from the protocol dialect's
`transport_policy/1`. For a request made with `extension: Module`, the
module's `transport_policy/2` adapts that policy for its exact-versioned
method. These are the same declarations the server admits requests against:
the accepted and request media types, and every mirrored header
(for `2026-07-28`, `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`) read
from the request body. A mirrored value that is not plain printable ASCII is
sent in the `=?base64?...?=` form when the policy allows it. A header value
that contains CR, LF, or NUL is refused with a -32000 transport error, and
so is a request-level `:headers` entry that names a header the transport
owns (see the `:headers` option).

The response may be `application/json` or `text/event-stream`. An event
stream is read as it arrives, and the transport returns the response whose
ID matches the request as soon as that event is complete. A
`notifications/progress` event for a request made with `progress:` is passed
to the progress function when it arrives, and with
`reset_timeout_on_progress: true` it moves the deadline for the rest of the
response. A request the server sends on the stream, such as
`elicitation/create` on an initialize-era connection, is answered by the
`:on_server_request` function in the calling process, and the response is
sent as its own `POST` before the stream is read further; without that
option such a request is dropped. If that `POST` fails, refused by the
server or by the connection, the request in flight fails at once with a
-32000 transport error whose `cause` is that failure. The time the function takes counts
against the request's timeout, which is not extended. Other notifications
are dropped. A JSON-RPC error body is returned whatever the HTTP status, so
`Snodo.Client` decodes it as `{:error, %Snodo.Error{}}`. Anything else is a
-32000 transport error with the status and body in `cause`. A timeout closes
the connection, which the server treats as cancellation.

On an initialize-era connection the client passes `MCP-Protocol-Version` and
`Mcp-Session-Id` in `:headers`, reads `Mcp-Session-Id` from the `initialize`
response through `:on_response_headers`, sends `notifications/initialized`
with `notify/3` (a `POST` answered with a 2xx and no body), and ends the
session with `delete_session/2`, a `DELETE` with the same headers whose
outcome is not reported: a server that does not support client-initiated
termination answers 405.

A `subscriptions/listen` request opened with `Snodo.Client.listen/3` keeps
its event-stream response open in a process of its own, which delivers the
events to the owner (see `Snodo.Client.Subscription`). The request timeout
bounds the wait for the acknowledgement only. Closing the subscription, or
the owner's exit, closes that connection, which the server treats as
cancellation. `:max_response_bytes` applies to each event of the stream
rather than to the stream as a whole.

The response is read by this module rather than `:httpc`, which reads the
body of any status other than 200 and 206 in full before returning it.
`:max_response_bytes` is checked as the response arrives, whatever its
status: a `Content-Length` over the limit is refused before the body is read,
a chunked body is refused at the first chunk that would pass it, and a body
that ends when the connection closes is refused at the read that passes it.
For a request, an event stream is one body, so the limit applies to the
whole stream, notifications included; a subscription stream is limited
per event, as described above. The status line and headers are held to the same
limit. Over the limit the connection is closed and the request returns a
-32000 transport error with `cause: {:max_response_bytes, limit}`.

Options:

  * `:headers` - extra request headers as `{name, value}` string pairs, for
    example `[{"authorization", "Bearer " <> token}]`. The transport owns
    `host`, `content-type`, `content-length`, `transfer-encoding`, and
    `connection`, and refuses them here; with `:token_provider` it owns
    `authorization` too.
  * `:token_provider` - `{module, state}`, a `Snodo.Client.TokenProvider`
    that supplies the bearer token. The transport asks it for a token
    before each request, notification, answer to a server request, and
    session `DELETE`, and before opening each `subscriptions/listen` stream.
    After a `401`, or a `403` whose `WWW-Authenticate` challenge is
    `insufficient_scope`, it asks the provider to refresh with the parsed
    `Snodo.Client.Challenge` and sends the request once more. A `401` or
    `403` on that second attempt is a -32000 transport error with
    `cause: {:unauthorized, status, challenge}`. Without a provider, those
    statuses are returned as any other unexpected status. The request
    timeout covers each HTTP attempt, not the provider calls before and
    between them.
  * `:ssl` - `:ssl` client options for `https` URLs. The default verifies the
    peer against `:public_key.cacerts_get/0` and checks the host name.
  * `:connect_timeout` - milliseconds to establish the connection. Defaults
    to the request timeout.
  * `:max_response_bytes` - the largest response to accept, default 16 MiB,
    the stdio client's line limit.
  * `:pool_size` - the most simultaneous pooled requests for this client
    origin, default 4. Additional requests wait up to their request timeout.
    Event streams detach from the pool and use a separate socket.
  * `:pool_idle_timeout` - milliseconds an idle connection may wait for
    reuse, default 30,000.
  * `:pool_max_requests` - the most requests sent on one connection before
    it is retired, default 100.

# `state`

```elixir
@type state() :: %{
  url: String.t(),
  scheme: String.t(),
  host: charlist(),
  port: :inet.port_number(),
  authority: String.t(),
  target: String.t(),
  socket_options: [:gen_tcp.connect_option()],
  headers: [{String.t(), String.t()}],
  ssl: keyword(),
  connect_timeout: timeout() | nil,
  max_response_bytes: pos_integer(),
  pool_key: term(),
  pool_size: pos_integer(),
  pool_idle_timeout: pos_integer(),
  pool_max_requests: pos_integer(),
  token_provider: {module(), term()} | nil
}
```

# `encode_sentinel`

```elixir
@spec encode_sentinel(String.t()) :: String.t()
```

Encodes a mirrored header value with the base64 sentinel when it is not
plain printable ASCII, or when the plain value could be mistaken for one.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
