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

A client for MCP servers.

`direct/2` dispatches to a runtime in the calling process. `connect/2` opens
a stdio subprocess or a Streamable HTTP endpoint:

    {:ok, client} = Snodo.Client.direct(EchoServer.runtime())
    {:ok, client} = Snodo.Client.connect({:stdio, "elixir", ["echo_server.exs"]})
    {:ok, client} = Snodo.Client.connect({:http, "http://127.0.0.1:4000/mcp"})

    {:ok, [%{"name" => "echo"}]} = Snodo.Client.list_tools(client)

    {:ok, result} = Snodo.Client.call_tool(client, "echo", %{"text" => "hello"})
    result["content"]
    #=> [%{"type" => "text", "text" => "hello"}]

The client builds each request, including the metadata the selected protocol
dialect requires, and decodes the response into one of:

  * `{:ok, result}`: the JSON-RPC `result` object as the server sent it, with
    string keys. A `tools/call` result with `"isError" => true` is a
    successful response and arrives here.
  * `{:input_required, result}`: a multi round-trip request. Call again with
    `input_responses:` and, when the result carries a `"requestState"`,
    `request_state:`. See the interactive operations guide.
  * `{:error, %Snodo.Error{}}`: a JSON-RPC error or a transport failure. For a
    JSON-RPC error, `code`, `message`, and `data` are the server's, and
    `kind` is derived from the code: -32700 and -32600 are `:json_rpc`,
    -32601 and -32602 are `:protocol`, -32603 is `:execution`, and any other
    code is `:protocol`. Transport failures have `kind: :transport`: -32000
    when the connection is closed, unreachable, returns something that is
    not a JSON-RPC response, or returns a response over the transport's size
    limit, and -32001 when a request times out.

Each request takes a fresh integer ID, so one client can be used from many
processes at once.

The client speaks the protocol versions the server serves: the stateless
`2026-07-28`, and the initialize-era `2025-11-25` and `2025-06-18`.
`connect/2` settles the version before it returns: it probes with
`server/discover`, and a server that does not answer as a 2026-07-28 server
gets `initialize` with the highest initialize-era version instead. The
`:protocol` option pins one version or narrows the list. On an
initialize-era connection `session` holds what `initialize` returned, and
the requests the server sends to the client (`elicitation/create`,
`sampling/createMessage`, `roots/list`) are answered by the same
`:input_handlers` that answer 2026-07-28 input requests.

Pass `progress:` to a request to receive the server's progress
notifications for it while it runs:

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

Install `input_handlers:` to answer a server's input requests inside the
call instead of receiving `{:input_required, result}`: form and URL
elicitation, and the deprecated sampling and roots requests:

    {:ok, client} =
      Snodo.Client.connect({:http, url},
        input_handlers: %{form: &MyUI.form/1, url: &MyUI.url/1, sampling: &MyModel.sample/1}
      )

Open a `subscriptions/listen` stream with `listen/3` to receive change
notifications as messages, or as a stream:

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

    subscription
    |> Snodo.Client.Subscription.stream()
    |> Enum.each(fn
      {:notification, "notifications/resources/updated", %{"uri" => uri}} -> IO.puts(uri)
      other -> IO.inspect(other)
    end)

# `input_handler`

```elixir
@type input_handler() :: (map() -&gt; {:ok, map()} | {:error, term()})
```

Answers one input request. Receives the request's `params` map and returns
the response the server expects, or the reason the request was not answered.

# `input_handlers`

```elixir
@type input_handlers() :: %{optional(input_kind()) =&gt; input_handler()}
```

The `:input_handlers` option: at most one handler per kind.

# `input_kind`

```elixir
@type input_kind() :: :form | :url | :sampling | :roots
```

A kind of input request a handler answers: one of the two elicitation modes,
or one of the sampling and roots requests SEP-2577 deprecates.

# `list_kind`

```elixir
@type list_kind() :: :tools | :resources | :resource_templates | :prompts
```

# `response`

```elixir
@type response() ::
  {:ok, map()} | {:input_required, map()} | {:error, Snodo.Error.t()}
```

# `t`

```elixir
@type t() :: %Snodo.Client{
  cache: boolean(),
  cache_namespace: term(),
  cache_variant: term(),
  client_capabilities: map(),
  client_info: map(),
  dialect: module(),
  input_handlers: input_handlers(),
  max_input_rounds: pos_integer(),
  max_pages: pos_integer(),
  probe_timeout: timeout(),
  protocol: String.t(),
  session: Snodo.Client.Session.t() | nil,
  timeout: timeout(),
  transport: {module(), Snodo.Client.Transport.state()}
}
```

# `target`

```elixir
@type target() ::
  {:stdio, String.t(), [String.t()]} | {:http, String.t()} | {module(), term()}
```

# `call_tool`

```elixir
@spec call_tool(t(), String.t() | map(), map(), keyword()) :: response()
```

Calls a tool, by name or with its definition from `list_tools/1`.

Options are those of `request/4`. A result with `"isError" => true` is
returned as `{:ok, result}`: the tool ran and reported its own failure.

Over HTTP, arguments whose input schema property carries `x-mcp-header`
are also sent as `Mcp-Param-*` headers, which needs the tool's
`inputSchema`. Pass the definition map to send them on the first request.
Called by name, a tool that requires them is refused with -32020; the
client then lists the tools and retries once with the definition.

# `close`

```elixir
@spec close(t()) :: :ok
```

Closes the client's connection. Closing an in-process client does nothing.

When the server issued a session id, the transport is told to end the
session first: over HTTP that is a `DELETE` with `Mcp-Session-Id`. Its
outcome is not reported, so `close/1` waits for it for at most 5,000 ms,
or the client's `:timeout` when that is shorter.

# `connect`

```elixir
@spec connect(target(), keyword()) :: {:ok, t()} | {:error, Snodo.Error.t()}
```

Connects to a server in another process or on the network.

Targets:

  * `{:stdio, command, args}` - runs `command` and speaks newline-delimited
    JSON-RPC over its stdin and stdout. See `Snodo.Client.Stdio` for `:env`,
    `:cd`, `:max_line_bytes`, and `:max_server_requests`. The connection
    closes when the calling process exits.
  * `{:http, url}` - posts each request to a Streamable HTTP endpoint. See
    `Snodo.Client.HTTP` for `:headers`, `:token_provider` (a
    `Snodo.Client.TokenProvider` that supplies and refreshes the bearer
    token), `:ssl`, `:connect_timeout`, `:max_response_bytes`, and connection
    pool limits.
  * `{module, init_arg}` - any `Snodo.Client.Transport`.

Options for every target:

  * `:protocol` - a protocol version, or a list of versions to allow.
    Defaults to every version the client speaks. See below.
  * `:probe_timeout` - milliseconds to wait for the answer to the
    `server/discover` probe, 10,000 unless set.
  * `:client_capabilities`, `:client_info`, `:max_pages`,
    `:input_handlers`, `:max_input_rounds`, and `:cache` - as for `direct/2`.
  * `:timeout` - the default request timeout in milliseconds, 30,000 unless
    set. Each request can override it with `timeout:`.

The version is settled before `connect/2` returns. When the allowed list
holds both a stateless-era and an initialize-era version, as it does by
default, the client sends `server/discover` under `:probe_timeout`. A result
with `supportedVersions` is a 2026-07-28 server's answer, and the highest
allowed version the server lists is used. Any other answer, whatever the
error code or the shape, means the server does not speak a stateless
version: the client closes and reopens the transport, which on stdio starts
the server again because the probe may already have been processed under
the older lifecycle rules, and sends `initialize` with the highest allowed
initialize-era version. 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`. The server's
`protocolVersion` must be one the client allows, or the client closes the
connection and returns -32602 with `negotiated` and `requested` in `data`.
After `notifications/initialized` the client's `session` is a
`Snodo.Client.Session`; a transport without `notify/3` cannot open such a
connection. Over HTTP every later request carries `MCP-Protocol-Version`
and, when the server issued one, `Mcp-Session-Id`, and `close/1` sends a
`DELETE` for the session. A server that supports none of the allowed
versions is a -32602 error with `requested` and `supported` in `data`.

# `direct`

```elixir
@spec direct(Snodo.Server.Runtime.t(), keyword()) ::
  {:ok, t()} | {:error, Snodo.Error.t()}
```

Builds a client that dispatches to `runtime` in the calling process.

Options:

  * `:protocol` - a protocol version to speak, or a list of versions to
    allow. Defaults to every version the client speaks: `2026-07-28`,
    `2025-11-25`, and `2025-06-18`. The highest allowed version the runtime
    enables is used; a version the client does not speak, or a choice the
    runtime does not enable, is a -32602 error. An initialize-era version is
    negotiated with `initialize` through the runtime, and the result is in
    the client's `session`.
  * `:client_capabilities` - the capabilities sent with every request, for
    example `%{"elicitation" => %{"form" => %{}}}`. Defaults to `%{}`. The
    capabilities the installed `:input_handlers` imply are added to it.
  * `:input_handlers` - functions that answer the input requests a server
    embeds in an `input_required` result, as a map from request kind to a
    function of one argument. A handler receives the request's `params`
    map and returns `{:ok, response}`, where `response` is the result the
    kind expects, or `{:error, reason}`. The kinds:
      * `:form` and `:url`, the two elicitation modes. `params` carries
        `"mode"`, `"message"`, and `"requestedSchema"` or `"url"`; the
        response is the elicitation result (`"action"` of `"accept"`,
        `"decline"`, or `"cancel"`, with `"content"` for an accepted
        form). Each adds its mode under the `"elicitation"` capability.
      * `:sampling`, the `sampling/createMessage` request SEP-2577
        deprecates. `params` carries `"messages"` and `"maxTokens"` and
        may carry the other `CreateMessageRequestParams` fields; the
        response is a `CreateMessageResult`, checked with
        `Snodo.Sampling.valid_response?/1`. Adds `"sampling" => %{}`; a
        handler that accepts `"tools"` or an `"includeContext"` other
        than `"none"` declares `"tools"` or `"context"` under it in
        `:client_capabilities`.
      * `:roots`, the `roots/list` request SEP-2577 deprecates. `params`
        is `%{}` unless the server sent `"_meta"`; the response is a
        `ListRootsResult`, checked with `Snodo.Roots.valid_response?/1`.
        Adds `"roots" => %{"listChanged" => false}`.
    Defaults to `%{}`, which leaves `input_required` results to the
    caller. See `request/4` for the retry loop.
  * `:max_input_rounds` - the most `input_required` results the client
    answers for one call before failing it. Defaults to 10.
  * `:client_info` - the `Implementation` sent as
    `io.modelcontextprotocol/clientInfo` with every request: a map with
    string `"name"` and `"version"` and, optionally, `"title"`,
    `"description"`, `"websiteUrl"`, and `"icons"`. Defaults to
    `%{"name" => "snodo", "version" => <this library's version>}`.
  * `:auth` - the value handlers and authorization policies read as
    `context.auth`, as a transport would supply it after authenticating.
  * `:max_pages` - the most pages `list_tools/1` and the other list
    functions request before returning an error. Defaults to 1,000.
  * `:cache` - opt in to caching complete discovery, list, and resource-read
    results according to their `ttlMs` and `cacheScope` hints. Defaults to
    `false`. The shared cache holds at most 256 results and 16 MiB.

# `discover`

```elixir
@spec discover(t(), keyword()) :: response()
```

Requests `server/discover`.

The initialize-era versions do not define it, so on such a connection the
request is refused with -32601; the server's capabilities and instructions
are in the client's `session`.

# `get_prompt`

```elixir
@spec get_prompt(t(), String.t(), map(), keyword()) :: response()
```

Gets a rendered prompt. Options are those of `request/4`.

# `list_page`

```elixir
@spec list_page(t(), list_kind(), String.t() | nil, keyword()) ::
  {:ok, Snodo.Client.Page.t()} | {:error, Snodo.Error.t()}
```

Requests one page of a list operation.

`kind` is one of `:tools`, `:resources`, `:resource_templates`, or
`:prompts`. Pass the previous page's `next_cursor` to continue.

# `list_prompts`

```elixir
@spec list_prompts(t(), keyword()) :: {:ok, [map()]} | {:error, Snodo.Error.t()}
```

Lists every prompt, following `nextCursor` until the last page.

# `list_resource_templates`

```elixir
@spec list_resource_templates(t(), keyword()) ::
  {:ok, [map()]} | {:error, Snodo.Error.t()}
```

Lists every resource template, following `nextCursor` until the last page.

# `list_resources`

```elixir
@spec list_resources(t(), keyword()) :: {:ok, [map()]} | {:error, Snodo.Error.t()}
```

Lists every direct resource, following `nextCursor` until the last page.

# `list_tools`

```elixir
@spec list_tools(t(), keyword()) :: {:ok, [map()]} | {:error, Snodo.Error.t()}
```

Lists every tool, following `nextCursor` to the last page.

The list functions return a -32000 transport error when the server repeats a
cursor, or when the last of the client's `:max_pages` pages still has a
`nextCursor`.

Over HTTP, a tool whose input schema has an invalid `x-mcp-header`
annotation is left out and a warning is logged, as 2026-07-28 requires.

# `listen`

```elixir
@spec listen(t(), map(), keyword()) ::
  {:ok, Snodo.Client.Subscription.t()} | {:error, Snodo.Error.t()}
```

Opens a `subscriptions/listen` stream and returns its handle.

`notifications` is the requested filter, sent as `params["notifications"]`
as given: the core keys `"toolsListChanged"`, `"promptsListChanged"`,
`"resourcesListChanged"`, and `"resourceSubscriptions"` (a list of URIs),
and any key a negotiated extension defines, such as the Tasks extension's
`"taskIds"`. The server validates it and answers -32602 for an invalid one.

Returns `{:ok, subscription}` once the server's
`notifications/subscriptions/acknowledged` arrives; `subscription.accepted`
is the filter the server agreed to, which may be a subset of the request.
Returns `{:error, %Snodo.Error{}}` for a JSON-RPC error response (for
example -32601 from a server without a subscription source), for a stream
the server ends before acknowledging it, and for a transport failure; -32001
when no acknowledgement arrives within `:timeout`.

The calling process owns the subscription: events reach it as
`{:snodo_subscription, ref, payload}` messages once it asks for them with
`Snodo.Client.Subscription.demand/2`, `next/2`, or `stream/1`, and the
stream is cancelled when it exits. See `Snodo.Client.Subscription` for the
payloads and the buffer.

Options:

  * `:max_buffer` - the most events held for the owner before the overflow
    policy applies. Defaults to 100.
  * `:overflow` - `:drop_oldest` (the default) or `:drop_newest`.
  * `:timeout` - overrides the client's request timeout for the wait for
    the acknowledgement. A direct client has no timeout.
  * `:meta` - extra `_meta` entries, as for `request/4`.
  * `:trace_context` - W3C trace fields, as for `request/4`.

Raises `ArgumentError` for a custom transport without `listen/3`.

# `ping`

```elixir
@spec ping(t()) :: response()
```

Requests `ping`, which the initialize-era versions define; the server
answers with an empty result. 2026-07-28 does not define it, and a server
of that version answers -32601.

# `read_resource`

```elixir
@spec read_resource(t(), String.t(), keyword()) :: response()
```

Reads a resource by exact URI. Options are those of `request/4`.

# `request`

```elixir
@spec request(t(), String.t(), map(), keyword()) :: response()
```

Sends any request method with the given params.

Use it for methods without a dedicated function, such as
`completion/complete` or a negotiated extension's methods. The dialect's
request metadata is merged under `params["_meta"]`; keys already present in
`params["_meta"]` win.

Options:

  * `:input_responses` - answers to a previous `{:input_required, result}`,
    keyed by the IDs in its `"inputRequests"`. Sent as `inputResponses`.
  * `:request_state` - the `"requestState"` of a previous
    `{:input_required, result}`. Sent as `requestState`.
  * `:meta` - extra `_meta` entries. These win over the dialect's metadata
    and over `params["_meta"]`.
  * `:trace_context` - a map with a valid W3C `"traceparent"` and optional
    `"tracestate"`. These keys are sent in `_meta` and win over `:meta`.
  * `:progress` - a function of one argument, or a pid, to receive the
    server's progress notifications for this request. The client sends the
    request ID as `_meta.progressToken`; a `"progressToken"` in `:meta` or
    `params["_meta"]` alongside `:progress` raises `ArgumentError`. A
    function is called in the calling process with each notification's
    `params` map (`"progressToken"`, `"progress"`, and, when the server sent
    them, `"total"` and `"message"`) before the request returns. A pid is
    sent `{:snodo_progress, params}`.
  * `:reset_timeout_on_progress` - when `true`, each progress notification
    restarts `:timeout`. Defaults to `false`. Has no effect without
    `:progress`, or on a direct client, which has no timeout.
  * `:max_total_timeout` - with `:reset_timeout_on_progress`, the most
    milliseconds a request may run, counted from when it was sent. Defaults
    to 600,000. A request stopped by this limit returns -32001 with the
    message "Maximum total timeout exceeded" and
    `data: %{"maxTotalTimeoutMs" => limit}`.
  * `:timeout` - overrides the client's request timeout.
  * `:extension` - an extension module that owns this request method. Over
    HTTP its `transport_policy/2` supplies routing headers for the exact
    protocol version. Other transports do not need the policy.
  * `:answer_input` - when `false`, an `input_required` result is returned
    as `{:input_required, result}` even though the client has
    `:input_handlers`. Defaults to `true`.
  * `:max_input_rounds` - overrides the client's round limit for this
    request.
  * `:bypass_cache` - when `true`, sends an eligible discovery, list, or
    resource-read request even if a cached result is available, and does not
    store its response. Defaults to `false`.

With `:input_handlers` installed, an `input_required` result is answered
in the calling process. Every entry of `"inputRequests"` is first matched
to the handler for its kind and checked for the `params` that kind
documents; only when all of them pass do the handlers run, one request at
a time in the sort order of the request IDs (strings, so `"10"` sorts
before `"2"`). The request is then sent again, on a fresh 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 have been answered. A result that carries only
a `"requestState"` is sent again with it after 250 milliseconds and counts
as a round. Each round has its own `:timeout`, and `:progress` is delivered
for every round. The loop stops with an error whose `cause` ends with the
last `input_required` result, so the caller can finish the flow by hand:

  * -32602 (`kind: :protocol`, `cause: {:no_input_handler, kind, result}`)
    for a request of a kind with no handler; `kind` is the method string
    for a method the client does not know, and `nil` for an entry without
    one. No handler has run.
  * -32603 (`kind: :execution`, `cause: {:input_handler, id, reason, result}`)
    when a handler returned `{:error, reason}`, a value other than
    `{:ok, response}` (`reason` is `{:invalid_return, value}`), or a
    response that is not valid for its kind (`{: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`. An exception raised by a
    handler propagates to the caller.
  * -32000 (`kind: :transport`, `data: %{"maxInputRounds" => limit}`,
    `cause: {:max_input_rounds, result}`) at the round limit.
  * -32000 (`kind: :transport`, `cause: result`) for a malformed result: one
    with nothing to answer and no `"requestState"`, or an input request
    whose `params` is not an object or lacks the keys of its kind
    (`"message"` and `"requestedSchema"` for a form, `"message"` and
    `"url"` for a URL, `"messages"` and `"maxTokens"` for sampling; a
    roots request may leave `params` out). No handler has run.

On an initialize-era connection a method the negotiated dialect's catalog
does not define as a client request is refused with -32601 before anything
is sent. On 2026-07-28 a method the catalog lists only as a server request
or a notification is refused the same way, and a method the catalog does
not list is sent as it is, because negotiated extensions add methods the
core catalog does not carry.

`subscriptions/listen` raises `ArgumentError`: its response is a stream,
which `listen/3` opens.

---

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