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-RPCresultobject as the server sent it, with string keys. Atools/callresult with"isError" => trueis a successful response and arrives here.{:input_required, result}: a multi round-trip request. Call again withinput_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, anddataare the server's, andkindis 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 havekind: :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.
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
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.
@type input_handlers() :: %{optional(input_kind()) => input_handler()}
The :input_handlers option: at most one handler per 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.
@type list_kind() :: :tools | :resources | :resource_templates | :prompts
@type response() :: {:ok, map()} | {:input_required, map()} | {:error, Snodo.Error.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()} }
Functions
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.
@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.
@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}- runscommandand speaks newline-delimited JSON-RPC over its stdin and stdout. SeeSnodo.Client.Stdiofor: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. SeeSnodo.Client.HTTPfor:headers,:token_provider(aSnodo.Client.TokenProviderthat supplies and refreshes the bearer token),:ssl,:connect_timeout,:max_response_bytes, and connection pool limits.{module, init_arg}- anySnodo.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 theserver/discoverprobe, 10,000 unless set.:client_capabilities,:client_info,:max_pages,:input_handlers,:max_input_rounds, and:cache- as fordirect/2.:timeout- the default request timeout in milliseconds, 30,000 unless set. Each request can override it withtimeout:.
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.
@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, and2025-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 withinitializethrough the runtime, and the result is in the client'ssession.:client_capabilities- the capabilities sent with every request, for example%{"elicitation" => %{"form" => %{}}}. Defaults to%{}. The capabilities the installed:input_handlersimply are added to it.:input_handlers- functions that answer the input requests a server embeds in aninput_requiredresult, as a map from request kind to a function of one argument. A handler receives the request'sparamsmap and returns{:ok, response}, whereresponseis the result the kind expects, or{:error, reason}. The kinds::formand:url, the two elicitation modes.paramscarries"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, thesampling/createMessagerequest SEP-2577 deprecates.paramscarries"messages"and"maxTokens"and may carry the otherCreateMessageRequestParamsfields; the response is aCreateMessageResult, checked withSnodo.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, theroots/listrequest SEP-2577 deprecates.paramsis%{}unless the server sent"_meta"; the response is aListRootsResult, checked withSnodo.Roots.valid_response?/1. Adds"roots" => %{"listChanged" => false}. Defaults to%{}, which leavesinput_requiredresults to the caller. Seerequest/4for the retry loop.
:max_input_rounds- the mostinput_requiredresults the client answers for one call before failing it. Defaults to 10.:client_info- theImplementationsent asio.modelcontextprotocol/clientInfowith 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 ascontext.auth, as a transport would supply it after authenticating.:max_pages- the most pageslist_tools/1and 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 theirttlMsandcacheScopehints. Defaults tofalse. The shared cache holds at most 256 results and 16 MiB.
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.
Gets a rendered prompt. Options are those of request/4.
@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.
@spec list_prompts(t(), keyword()) :: {:ok, [map()]} | {:error, Snodo.Error.t()}
Lists every prompt, following nextCursor until the last page.
@spec list_resource_templates(t(), keyword()) :: {:ok, [map()]} | {:error, Snodo.Error.t()}
Lists every resource template, following nextCursor until the last page.
@spec list_resources(t(), keyword()) :: {:ok, [map()]} | {:error, Snodo.Error.t()}
Lists every direct resource, following nextCursor until the last page.
@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.
@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_metaentries, as forrequest/4.:trace_context- W3C trace fields, as forrequest/4.
Raises ArgumentError for a custom transport without listen/3.
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.
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 asinputResponses.:request_state- the"requestState"of a previous{:input_required, result}. Sent asrequestState.:meta- extra_metaentries. These win over the dialect's metadata and overparams["_meta"].:trace_context- a map with a valid W3C"traceparent"and optional"tracestate". These keys are sent in_metaand 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:metaorparams["_meta"]alongside:progressraisesArgumentError. A function is called in the calling process with each notification'sparamsmap ("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- whentrue, each progress notification restarts:timeout. Defaults tofalse. 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" anddata: %{"maxTotalTimeoutMs" => limit}.:timeout- overrides the client's request timeout.:extension- an extension module that owns this request method. Over HTTP itstransport_policy/2supplies routing headers for the exact protocol version. Other transports do not need the policy.:answer_input- whenfalse, aninput_requiredresult is returned as{:input_required, result}even though the client has:input_handlers. Defaults totrue.:max_input_rounds- overrides the client's round limit for this request.:bypass_cache- whentrue, sends an eligible discovery, list, or resource-read request even if a cached result is available, and does not store its response. Defaults tofalse.
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;kindis the method string for a method the client does not know, andnilfor 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}(reasonis{: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 aCreateMessageResult, or a roots result that is not aListRootsResult. 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 whoseparamsis 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 leaveparamsout). 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.