Snodo.Client (snodo v0.2.0)

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, or returns something that is not a JSON-RPC response, 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 stateless 2026-07-28 protocol; initialize-era servers, which need a session handshake, are not supported. subscriptions/listen streams and progress notifications are not delivered.

Summary

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.

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

Sends any request method with the given params.

Types

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{
  client_capabilities: map(),
  client_info: map(),
  dialect: module(),
  protocol: String.t(),
  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.

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 and :cd. The connection closes when the calling process exits.
  • {:http, url} - posts each request to a Streamable HTTP endpoint. See Snodo.Client.HTTP for :headers, :ssl, and :connect_timeout.
  • {module, init_arg} - any Snodo.Client.Transport.

Options for every target:

  • :protocol - defaults to "2026-07-28", the only supported version.
  • :client_capabilities and :client_info - as for direct/2.
  • :timeout - the default request timeout in milliseconds, 30,000 unless set. Each request can override it with timeout:.

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 - the protocol version to speak. Defaults to the first stateless-era version the runtime enables. Initialize-era versions need a session, which a direct client does not hold, so they are refused.
  • :client_capabilities - the capabilities sent with every request, for example %{"elicitation" => %{"form" => %{}}}. Defaults to %{}.
  • :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.

discover(client)

@spec discover(t()) :: response()

Requests server/discover.

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)

@spec list_page(t(), list_kind(), String.t() | nil) ::
  {: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)

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

Lists every prompt, following nextCursor until the last page.

list_resource_templates(client)

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

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

list_resources(client)

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

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

list_tools(client)

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

Lists every tool, following nextCursor to the last page.

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.

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, such as a "progressToken". These win over the dialect's metadata and over params["_meta"].

subscriptions/listen raises ArgumentError: it needs a stream to deliver events on, and dispatching it would open the application's source.