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 ownshost,content-type,content-length,transfer-encoding, andconnection, and refuses them here; with:token_providerit ownsauthorizationtoo.:token_provider-{module, state}, aSnodo.Client.TokenProviderthat supplies the bearer token. The transport asks it for a token before each request, notification, answer to a server request, and sessionDELETE, and before opening eachsubscriptions/listenstream. After a401, or a403whoseWWW-Authenticatechallenge isinsufficient_scope, it asks the provider to refresh with the parsedSnodo.Client.Challengeand sends the request once more. A401or403on that second attempt is a -32000 transport error withcause: {: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-:sslclient options forhttpsURLs. The default verifies the peer against:public_key.cacerts_get/0and 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.
Summary
Functions
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.
Types
@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 }