Snodo.Client.HTTP (snodo v0.4.1)

Copy Markdown View Source

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.

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

state()

@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
}

Functions

encode_sentinel(value)

@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.