Snodo.Client (snodo v0.4.1)

Copy Markdown View Source

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)

Summary

Types

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.

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

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.

t()

Functions

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

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

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

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

Requests server/discover.

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

Requests one page of a list operation.

Lists every prompt, following nextCursor until the last page.

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

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

Lists every tool, following nextCursor to the last page.

Opens a subscriptions/listen stream and returns its handle.

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.

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

Sends any request method with the given params.

Types

input_handler()

@type input_handler() :: (map() -> {: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()

@type input_handlers() :: %{optional(input_kind()) => input_handler()}

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

input_kind()

@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()

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

response()

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

t()

@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()

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

Functions

call_tool(client, tool, arguments \\ %{}, opts \\ [])

@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(client)

@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(target, opts \\ [])

@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(runtime, opts \\ [])

@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(client, opts \\ [])

@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(client, name, arguments \\ %{}, opts \\ [])

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

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

list_page(client, kind, cursor \\ nil, opts \\ [])

@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(client, opts \\ [])

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

Lists every prompt, following nextCursor until the last page.

list_resource_templates(client, opts \\ [])

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

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

list_resources(client, opts \\ [])

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

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

list_tools(client, opts \\ [])

@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(client, notifications, opts \\ [])

@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(client)

@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(client, uri, opts \\ [])

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

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

request(client, method, params \\ %{}, opts \\ [])

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