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

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

Each request is one HTTP/1.1 `POST` on its own `:gen_tcp` or `:ssl`
connection, closed before the request returns, so no connection or process
outlives it. The request headers come from the protocol dialect's
`transport_policy/1`, the same declaration 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.

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. 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.
An event stream is one body, so the limit applies to the whole stream,
notifications included. 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.

# `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(),
  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*
