Snodo.Client calls MCP servers. The same functions work over three connections:

# A runtime in this VM: no process or transport in between
{:ok, client} = Snodo.Client.direct(MyServer.runtime())

# A subprocess speaking newline-delimited JSON-RPC on stdin and stdout
{:ok, client} = Snodo.Client.connect({:stdio, "elixir", ["my_server.exs"]}, env: [{"LOG_LEVEL", "warn"}])

# A Streamable HTTP endpoint
{:ok, client} = Snodo.Client.connect({:http, "https://example.test/mcp"}, headers: [{"authorization", "Bearer " <> token}])

The client speaks the protocol versions the server serves: 2026-07-28, and the initialize-era 2025-11-25 and 2025-06-18. connect/2 settles the version before it returns; see Protocol versions.

Calls

{:ok, discovery} = Snodo.Client.discover(client)
{:ok, tools} = Snodo.Client.list_tools(client)
{:ok, result} = Snodo.Client.call_tool(client, "search", %{"query" => "json"})
{:ok, result} = Snodo.Client.read_resource(client, "hex://jason/info")
{:ok, result} = Snodo.Client.get_prompt(client, "review", %{"name" => "jason"})
{:ok, result} = Snodo.Client.request(client, "completion/complete", params)
:ok = Snodo.Client.close(client)

Every call returns one of:

ReturnMeaning
{:ok, result}the JSON-RPC result object as sent, with string keys. A tool result with "isError" => true is a successful response and arrives here
{:input_required, result}the server needs more input; retry the same call with answers (below)
{:error, %Snodo.Error{}}a JSON-RPC error, or a failure of the connection

Decoded JSON-RPC errors keep the server's code, message, and data. Connection failures have kind: :transport: -32000 for a closed, unreachable, or unusable connection and -32001 for a timeout, the codes the official TypeScript SDK uses. Each request can set timeout: (the default is 30 seconds, or the client's :timeout option).

Request IDs are fresh integers, so one client can be shared by many processes.

Protocol versions

:protocol names one version, or lists the versions to allow. Without it the client allows every version it speaks, newest first:

{:ok, client} = Snodo.Client.connect({:http, url})                              # negotiate
{:ok, client} = Snodo.Client.connect({:http, url}, protocol: "2025-11-25")      # pin
{:ok, client} = Snodo.Client.connect({:http, url}, protocol: ["2026-07-28", "2025-06-18"])

When the allowed versions span both eras, connect/2 probes with server/discover under :probe_timeout (10 seconds by default). A result with supportedVersions is a 2026-07-28 server's answer, and the highest allowed version the server lists wins. Any other answer, whatever the error code or shape, and a timeout, mean the server does not speak a stateless version: the client closes and reopens the transport, then sends initialize with the highest allowed initialize-era version. On stdio, reopening starts the server again, because the probe may already have been processed under the older lifecycle rules, which some servers apply to the first request they see. A pin, or a list from one era, skips the probe: a stateless pin sends nothing at connect time and an initialize-era pin sends initialize at once.

initialize carries :client_capabilities and :client_info, and the server's protocolVersion must be one the client allows; otherwise the client closes the connection and returns -32602 with negotiated and requested in data. notifications/initialized follows, and the client's session is then a Snodo.Client.Session with the negotiated version, the Mcp-Session-Id the server issued (if any), and the serverInfo, capabilities, and instructions from the result. A server that supports none of the allowed versions is a -32602 error with requested and supported in data, and a version the client does not speak at all is refused before connecting.

direct/2 needs no probe: the runtime's enabled versions are known, so the highest allowed one among them is used, and an initialize-era version is negotiated through the runtime the same way. A runtime that enables only the legacy dialects gets a working direct client.

On an initialize-era connection:

  • Requests carry no _meta metadata and no client info; those belong to 2026-07-28. Over HTTP every later request carries MCP-Protocol-Version and, when issued, Mcp-Session-Id, and close/1 sends a DELETE for the session before closing.
  • ping/1 works; discover/1 is refused, because those versions do not define server/discover. The capabilities are in session.
  • request/4 refuses a method the negotiated dialect's catalog does not define as a client request, with -32601 before anything is sent. The catalogs are the protocol dialect modules' profiles, the same ones the server admits requests against, so the client and the server stay in step. On 2026-07-28 a method the catalog lists only as a server request or a notification is refused the same way, and an unlisted method is sent as it is, because negotiated extensions add methods the core catalog does not carry.
  • The requests the server sends to the client (elicitation/create, sampling/createMessage, and roots/list) are answered by the same :input_handlers that answer 2026-07-28 input requests, keyed by method and mode: a form elicitation goes to :form, a URL elicitation to :url, a sampling request to :sampling, and a roots request to :roots. A server ping is answered without a handler. A request of a kind with no handler is answered -32601, one whose params lack the kind's keys -32602, and a handler that fails -32603. Over HTTP such a request arrives on the event stream of the request in flight, the handler runs in the calling process, and the answer goes back as its own POST. The time the handler takes counts against that request's timeout, which is not extended. Over stdio the connection process runs each handler in a linked process of its own and writes the answer, with at most :max_server_requests (16) handlers running at once; a request over the limit is answered -32603 without running a handler, and handlers still running when the connection closes are killed. A handler that raises is answered -32603 on stdio, and on HTTP the exception propagates to the caller as it does for an embedded request.
  • The catalog check applies to the requests the client sends. The server requests above are answered on any connection, whatever the negotiated version. The initialize-era catalogs do not list them, and 2026-07-28 replaces them with input requests, but a 2026-07-28 connection over stdio, or over HTTP with input handlers installed, answers a server ping with {} and passes an elicitation, sampling, or roots request to the handlers. Such a request is outside the multi round-trip flow, so :max_input_rounds does not count it.
  • Versions before 2025-06-18 are not spoken. The client does not open the standalone GET event stream, so a request a server sends only there (the TypeScript SDK does this for requests made without a related request ID) is not received; and a session the server expires is reported as the server's error rather than re-initialized.

Lists and paging

list_tools/1, list_resources/1, list_resource_templates/1, and list_prompts/1 follow nextCursor to the end and return every item. They stop with a -32000 error if a server repeats a cursor, or if the last of :max_pages pages (1,000 by default) still has a nextCursor. list_page/3 returns one Snodo.Client.Page for manual paging:

{:ok, %Snodo.Client.Page{items: tools, next_cursor: cursor}} = Snodo.Client.list_page(client, :tools)
{:ok, next} = Snodo.Client.list_page(client, :tools, cursor)

Response caching

Pass cache: true to direct/2 or connect/2 to cache complete server/discover, list-page, and resources/read results. Caching is off by default. A result is stored only when it has a positive ttlMs and a cacheScope of "public" or "private". It expires ttlMs milliseconds after the response arrives. The shared cache holds at most 256 results and 16 MiB; the least recently used entry is removed when either limit is exceeded. An individual result larger than 16 MiB is not stored.

Public results can be reused by clients of the same endpoint, protocol, client metadata, and noncredential HTTP headers. Private results are partitioned by the credentials used for the request and the session ID. Stdio clients have separate cache namespaces for their separate server processes. Neither raw bearer tokens nor authorization headers are kept in cache keys. Custom transports can reuse public results, but private results are not stored because the client cannot identify their credentials.

While a client listens for subscriptions, tools, prompts, and resources list changes remove the corresponding cached pages. A resource update removes cached reads for that URI. Invalidation happens when the event reaches the client, even if the subscription owner has not yet demanded it. Without a subscription, entries expire by their TTL.

Pass bypass_cache: true on a call to send it to the server and leave existing entries unchanged. The named discovery and list functions accept an options argument; for manual paging, pass it after the cursor:

{:ok, client} = Snodo.Client.connect({:http, url}, cache: true)
{:ok, tools} = Snodo.Client.list_tools(client)
{:ok, fresh} = Snodo.Client.list_tools(client, bypass_cache: true)
{:ok, page} = Snodo.Client.list_page(client, :tools, nil, bypass_cache: true)

Requests with other per-call options, such as custom metadata or progress, are sent normally and are not cached.

Multi round-trip requests

A server that needs input returns input_required with inputRequests and, sometimes, a requestState. A client without input handlers does not answer; call again with the answers:

{:ok, client} = Snodo.Client.direct(runtime, client_capabilities: %{"elicitation" => %{"form" => %{}}})

{:input_required, %{"inputRequests" => requests} = pending} =
  Snodo.Client.call_tool(client, "deploy", %{})

answers = %{"confirm" => %{"action" => "accept", "content" => %{"approved" => true}}}

Snodo.Client.call_tool(client, "deploy", %{},
  input_responses: answers,
  request_state: pending["requestState"]
)

See Interactive operations for the server side.

Input handlers

Install input_handlers: to answer input requests inside the call. The map is keyed by request kind. A handler is a function of one argument: it receives the request's params and returns {:ok, response}, where response is the result the kind expects, or {:error, reason}.

KindEmbedded requestHandler receivesHandler returnsDeclares
:formelicitation/create, mode form"mode", "message", "requestedSchema"an elicitation result: "action" of "accept", "decline", or "cancel", with "content" when accepted"elicitation" => %{"form" => %{}}
:urlelicitation/create, mode url"mode", "message", "url"an elicitation result"elicitation" => %{"url" => %{}}
:samplingsampling/createMessage"messages", "maxTokens", and whichever other CreateMessageRequestParams fields the server senta CreateMessageResult: "role", "content", "model", optional "stopReason""sampling" => %{}
:rootsroots/list%{}, or "_meta" when the server sent ita ListRootsResult: "roots" with file:// "uri" entries and optional names"roots" => %{"listChanged" => false}

SEP-2577 deprecates the server-initiated sampling and roots requests in 2026-07-28; the protocol still defines them, and servers built on this library can send them (see Interactive operations). A :sampling handler that accepts "tools" and "toolChoice", or an "includeContext" other than "none", declares "sampling" => %{"tools" => %{}} or %{"context" => %{}} in :client_capabilities; the client does not infer those settings from the handler, so a server that needs them is refused by the dialect with -32021 until they are declared. What a sampling handler returns is model output the server will treat as input; what a roots handler returns is a claim about this client's file system, and the handler chooses which roots to reveal.

{:ok, client} =
  Snodo.Client.connect({:http, url},
    input_handlers: %{
      form: fn %{"message" => message, "requestedSchema" => schema} ->
        {:ok, %{"action" => "accept", "content" => MyUI.ask(message, schema)}}
      end,
      url: fn %{"url" => url} ->
        MyUI.open(url)
        {:ok, %{"action" => "accept"}}
      end,
      sampling: fn %{"messages" => messages, "maxTokens" => max_tokens} ->
        {:ok, text, model} = MyModel.complete(messages, max_tokens: max_tokens)
        {:ok, %{"role" => "assistant", "content" => %{"type" => "text", "text" => text}, "model" => model}}
      end,
      roots: fn _params ->
        {:ok, %{"roots" => [%{"uri" => "file:///home/me/project", "name" => "project"}]}}
      end
    }
  )

{:ok, result} = Snodo.Client.call_tool(client, "deploy", %{})

The declared client capabilities follow the handlers, merged with any :client_capabilities given: :form adds "elicitation" => %{"form" => %{}}, :url adds "url" next to it, :sampling adds "sampling" => %{}, and :roots adds "roots" => %{"listChanged" => false}. A declared value wins where the two meet: "sampling" => %{"tools" => %{}} keeps its setting and a declared "listChanged" replaces the handler's false. An explicit "elicitation" => %{} next to a :url handler is kept as form. A declared entry the handlers need to merge into must be a map; "elicitation" => true with a form handler installed, or "roots" => true with a roots handler, raises ArgumentError. Without a handler, nothing declares sampling or roots. A server therefore does not ask for a kind the client cannot answer.

When a result asks for input, the client first matches every request to a handler and checks that its params carry the keys of its kind; only then does it call the handlers, one at a time in the calling process, in the sort order of the request IDs. It sends the request again, on a fresh request ID, with the responses as inputResponses and the result's requestState unchanged. That repeats until the server returns a complete result or :max_input_rounds results (10 by default) have been answered. A result that carries only a requestState is sent again with it after 250 ms, as the official TypeScript client does, and counts as a round. Each round has its own timeout:, and progress: is delivered for every round.

ConditionReturn
no handlers installed, or answer_input: false on the call{:input_required, result}
a request of a kind with no handler, or with a method the client does not know (no handler has run)-32602, kind: :protocol, cause: {:no_input_handler, kind, result}
a handler returned {:error, reason}, something other than {:ok, response}, or a response that is not valid for its kind (reason is {:invalid_response, response}): an elicitation result without a valid "action", a sampling result that is not a CreateMessageResult, or a roots result that is not a ListRootsResult-32603, kind: :execution, cause: {:input_handler, id, reason, result}
the server still required input after :max_input_rounds rounds-32000, kind: :transport, data: %{"maxInputRounds" => n}, cause: {:max_input_rounds, result}
a result with nothing to answer and no requestState, or an input request whose params is not a map or lacks "message" and "requestedSchema" (form), "message" and "url" (URL), or "messages" and "maxTokens" (sampling); a roots request may leave params out. No handler has run-32000, kind: :transport, cause: result

A handler that raises stops the call with that exception. Each error carries the last input_required result at the end of its cause, so the caller can finish the flow by hand with input_responses: and request_state:. Elicitation responses are not checked against requestedSchema on the client; the server validates them and answers -32602 for an invalid one, as it does for a hand-written response. A sampling or roots response is checked for its result shape with Snodo.Sampling.valid_response?/1 or Snodo.Roots.valid_response?/1 before it is sent, so a handler bug is a -32603 error on the client rather than a -32602 from the server.

OptionApplies toDefaultMeaning
:input_handlersdirect/2, connect/2%{}a map from :form, :url, :sampling, or :roots to a function of one argument
:max_input_roundsdirect/2, connect/2, each call10the most input_required results answered for one call
:answer_inputeach calltruefalse returns {:input_required, result} for this call

Progress

Pass progress: to a request to receive the server's notifications/progress for it while it runs. The client sends the request ID as _meta.progressToken. A function is called in the requesting process with each notification's params before the request returns; a pid is sent {:snodo_progress, params}.

Snodo.Client.call_tool(client, "reindex", %{},
  progress: fn %{"progress" => done} = params ->
    IO.puts("#{done} of #{params["total"] || "?"}")
  end
)

params holds "progressToken", "progress", and, when the server sent them, "total" and "message". Passing a "progressToken" in :meta as well as progress: raises ArgumentError.

A long request that keeps reporting progress can outlast its timeout:. With reset_timeout_on_progress: true, each notification restarts the timeout, and max_total_timeout: (600,000 ms by default) bounds the whole request from when it was sent. A request stopped by that bound returns -32001 with the message "Maximum total timeout exceeded" and data holding "maxTotalTimeoutMs".

OptionDefaultMeaning
:progressnonea function of one argument, or a pid, for this request's progress
:reset_timeout_on_progressfalserestart timeout: at each progress notification
:max_total_timeout600,000the longest a request may run when its timeout is reset

A direct client has no timeout; its handler runs in a linked task while the calling process receives the progress reports.

Subscriptions

listen/3 opens a subscriptions/listen stream. It sends the filter as given (the core keys, and any key a negotiated extension defines, such as the Tasks extension's taskIds) and returns once the server's notifications/subscriptions/acknowledged arrives. The handle carries the filter the server accepted, which may be a subset of the request:

{:ok, subscription} =
  Snodo.Client.listen(client, %{
    "toolsListChanged" => true,
    "resourceSubscriptions" => ["file:///notes.md"]
  })

subscription.accepted
#=> %{"toolsListChanged" => true, "resourceSubscriptions" => ["file:///notes.md"]}

The calling process owns the subscription. Events reach it as {:snodo_subscription, ref, payload} messages, where ref is subscription.ref and the payload is one of:

PayloadMeaning
{:notification, method, params}one event, as sent: method is "notifications/resources/updated", one of the three list-changed methods, or an extension's such as "notifications/tasks"; params keeps "_meta"
{:dropped, n}n events were discarded because the buffer was full; sent just before the next delivered event, in addition to it, and takes no demand of its own
{:closed, :complete}the server ended the stream with its terminal result
{:closed, {:error, %Snodo.Error{}}}the server ended the stream with an error, or the connection failed; nothing follows

Events are sent only while the owner has asked for them. Snodo.Client.Subscription.demand/2 asks for n more; next/2 asks for one and waits for it; stream/1 wraps next/2 as an Enumerable that ends with the {:closed, reason} element. Each unit of demand pays for exactly one event; {:dropped, n} and {:closed, reason} take none. A next/2 call that returns {:dropped, n} leaves the event its demand paid for in the mailbox, and the following call returns it:

subscription
|> Snodo.Client.Subscription.stream()
|> Enum.each(fn
  {:notification, "notifications/resources/updated", %{"uri" => uri}} -> reload(uri)
  {:notification, "notifications/tools/list_changed", _params} -> refresh_tools()
  {:dropped, n} -> Logger.warning("missed #{n} events")
  {:closed, reason} -> Logger.info("stream ended: #{inspect(reason)}")
end)

A GenServer asks for a batch and asks again as it consumes:

def handle_info({:snodo_subscription, ref, {:notification, method, params}}, %{sub: %{ref: ref}} = state) do
  :ok = Snodo.Client.Subscription.demand(state.sub, 1)
  {:noreply, apply_change(state, method, params)}
end

# A drop report takes no demand, so there is nothing to renew.
def handle_info({:snodo_subscription, ref, {:dropped, _n}}, %{sub: %{ref: ref}} = state) do
  {:noreply, resync(state)}
end

Events that arrive without demand wait in the transport process, at most :max_buffer of them (100 by default). When the buffer is full, :overflow decides: :drop_oldest (the default) discards the oldest queued event, :drop_newest discards the arriving one. The count reaches the owner as {:dropped, n}. The terminal {:closed, reason} is delivered after the queued events, so the owner sees every event it asks for before the end.

Snodo.Client.Subscription.close/1 ends the stream, and so does the owner's exit: stdio sends notifications/cancelled, HTTP closes the connection, and the direct client closes the source. No message follows close/1; events already delivered stay in the mailbox. next/2 on a stream that has ended, after its {:closed, reason} or after close/1, returns {:closed, {:error, %Snodo.Error{}}} at once over every transport.

listen/3 returns {:error, %Snodo.Error{}} for a JSON-RPC error response (-32601 from a server without a subscription source, -32602 for an invalid filter), for a stream the server ends before acknowledging it, and for a transport failure; -32001 when the acknowledgement does not arrive within timeout:. request/4 raises for subscriptions/listen.

OptionDefaultMeaning
:max_buffer100the most events held for the owner before the overflow policy applies
:overflow:drop_oldest:drop_oldest or :drop_newest
:timeoutthe client'show long to wait for the acknowledgement

Options

OptionApplies toMeaning
:client_capabilitiesallcapabilities sent with every request, plus those the input handlers imply
:input_handlers, :max_input_roundsallfunctions that answer input requests, and the round limit (see above)
:client_infoallthe Implementation sent as io.modelcontextprotocol/clientInfo; defaults to %{"name" => "snodo", "version" => ...} with this library's version
:timeoutalldefault request timeout in milliseconds
:authdirect/2the value handlers and policies read as context.auth
:protocolalla version to pin, or a list to allow; defaults to every version the client speaks (see above)
:probe_timeoutconnect/2milliseconds to wait for the answer to the server/discover probe (10,000)
:max_pagesallthe most pages a list function requests (1,000)
:cacheallopt in to the bounded response cache (off by default; see above)
:env, :cdstdioenvironment and working directory for the command
:max_line_bytesstdiothe largest response line to accept (16 MiB); the rest of a longer line is discarded and its request times out
:max_server_requestsstdiothe most server-to-client requests whose handlers run at once (16); a request over the limit is answered -32603
:headers, :ssl, :connect_timeoutHTTPextra headers, :ssl options (peers are verified against the OS trust store by default), connect timeout
:token_providerHTTPa Snodo.Client.TokenProvider as {module, state} that supplies the bearer token (see Authorization)
:max_response_bytesHTTPthe largest response to accept (16 MiB), checked as it arrives; a larger response closes the connection and returns -32000
:max_buffer, :overflowlisten/3the subscription's buffer bound (100) and overflow policy (:drop_oldest)

request/4 and listen/3 also accept :meta for extra _meta entries. Pass trace_context: %{"traceparent" => value, "tracestate" => value} on a call to send W3C trace fields through _meta. tracestate is optional; the client checks the values and rejects malformed ones with ArgumentError. The named option wins over duplicate keys in :meta.

Transport behavior

  • Stdio. One process owns the port and correlates responses by ID, and progress notifications by token. A timeout, or a progress function that raises, sends notifications/cancelled for that request. When the server exits, pending and later requests fail with -32000. The connection closes when the process that opened it exits. close/1 closes the server's stdin. A request the server sends to the client is answered through the input handlers in a process linked to the connection, at most :max_server_requests at once, and those processes are killed when the connection closes.

    Subscriptions share the connection. The connection process correlates the acknowledgement, the events, and the terminal response by the subscription ID in each notification's _meta, and monitors each subscription's owner. When the server exits, or close/1 stops the connection, open subscriptions end with {:closed, {:error, ...}} (-32000).

  • HTTP. One HTTP/1.1 POST per request, on a :gen_tcp or :ssl connection that closes when the request returns. The headers the protocol requires (MCP-Protocol-Version, Mcp-Method, Mcp-Name, and Mcp-Param-* for x-mcp-header arguments) come from the dialect's transport policy, the same declaration the server checks. JSON and event-stream responses are both accepted. An event stream is read as it arrives: progress notifications for the request are delivered as they come, and the request returns at the event that carries its response.

    A subscription keeps its event-stream response open in a process of its own (subscription.pid), which exits when the stream ends. Closing the subscription closes that connection, which the server treats as cancellation. close/1 on the client does not affect open subscriptions.

    :max_response_bytes is checked while the response arrives, for every status: a Content-Length over the limit is refused before the body is read, and a chunked or close-delimited body is refused at the read that passes the limit. An event stream that answers a request counts as one body, notifications included; a subscription stream is checked one event at a time. The error has cause: {:max_response_bytes, limit}.

  • Direct. A subscription is dispatched in a process of its own, which serves the server-side subscription as a server transport would and closes the source when the stream ends. close/1 on the client does not affect open subscriptions.

    On an initialize-era session the requests carry MCP-Protocol-Version and Mcp-Session-Id instead of the 2026-07-28 headers, notifications/initialized is a POST answered with 202, a request the server sends on the event stream is answered on its own POST, and close/1 sends DELETE.

    Mcp-Param-* headers need the tool's input schema, so pass the definition from list_tools/1 to call_tool/4 in place of the name. Called by name, a tool that needs them is refused with -32020; the client then lists the tools and retries once with the definition. list_tools/1 over HTTP leaves out tools with an invalid x-mcp-header annotation and logs a warning for each.

    {:ok, tools} = Snodo.Client.list_tools(client)
    search = Enum.find(tools, &(&1["name"] == "search"))
    {:ok, result} = Snodo.Client.call_tool(client, search, %{"region" => "eu", "query" => "json"})

Authorization

A server over HTTP may require an OAuth 2.1 bearer token. A token the application already holds goes in headers:. A token that has to be obtained, refreshed, or extended comes from a Snodo.Client.TokenProvider, given as token_provider: {module, state}:

  • Before each request, including the one that opens a listen/3 stream, the transport asks the provider for a token and sends Authorization: Bearer <token> when it gets one. {:ok, nil} sends the request without one, which is how a client learns the server's challenge. On an initialize-era connection the same applies to initialize, to notifications/initialized and each answer to a server request, and to the DELETE that ends the session.
  • After a 401, or a 403 whose WWW-Authenticate challenge is insufficient_scope, the transport parses the challenge into a Snodo.Client.Challenge (resource_metadata, scope, error), asks the provider to refresh with it, and sends the request once more with the new token.
  • A second 401 or 403 returns -32000 with cause: {:unauthorized, status, challenge}. A provider error is returned as it is. No token appears in an error.
  • The request's timeout: covers each HTTP attempt, not the provider call before or between them. A provider that runs an authorization flow can hold a request for as long as the user takes to authorize, up to the provider's own limit (Snodo.OAuth.Client waits 300 seconds by default).

snodo_oauth supplies Snodo.OAuth.Client, which implements the MCP authorization flows on this behaviour: protected resource and authorization server metadata discovery with issuer validation, client ID metadata documents, dynamic client registration and pre-registered clients, PKCE, resource indicators, scope selection and step-up, refresh tokens, and the client credentials grant with client_secret_basic, client_secret_post, or private_key_jwt.

{:ok, oauth} =
  Snodo.OAuth.Client.start_link(
    resource: "https://mcp.example.com/mcp",
    authorize: fn url -> MyApp.open_browser(url) end
  )

{:ok, client} =
  Snodo.Client.connect({:http, "https://mcp.example.com/mcp"},
    token_provider: {Snodo.OAuth.Client, oauth}
  )

Custom transports

Any module implementing Snodo.Client.Transport (connect/2, request/3, close/1) can be passed as {module, init_arg} to connect/2. A transport that ignores the :on_progress option delivers no progress. listen/3 is optional; Snodo.Client.listen/3 raises ArgumentError for a transport without it. The optional notify/3 sends notifications/initialized, so a transport without it can open 2026-07-28 connections only; the optional delete_session/2 ends a session the server identified. The :headers, :on_response_headers, and :on_server_request options carry the initialize-era session, as the behaviour documents.

Examples

examples/24_client_transports.exs runs the same calls in process, over stdio, and over HTTP.