Sake.Client (Sake v0.4.3)

Copy Markdown View Source

A client for MCP servers over Streamable HTTP or stdio, on both protocol eras.

{:ok, session} = Sake.Client.connect("https://mcp.example.com/mcp", headers: [{"authorization", "Bearer ..."}])
{:ok, tools, session} = Sake.Client.list_tools(session)
{:ok, result, session} = Sake.Client.call_tool(session, "search", %{"query" => "x"})

The client is functional: every call returns the updated session. Pass on_input: to answer elicitation requests, sampling: to answer sampling requests (fn params, session -> {:ok, result} | {:error, error} end), roots: to share roots (a list of %{"uri" => ..., "name" => ...} maps, or a function of the session that returns one), on_notification: (a function of a method and its params) to see the progress and log notifications of a call, log_level: (a level name such as "info") to have the server send log messages at or above it (see set_log_level/2), and auth: to have the client run the MCP authorization flow itself on a 401 (see Sake.Client.Auth); without it, a 401 comes back as {:error, {:unauthorized, %Sake.Auth.Challenge{}}}, or, when its challenge is RFC 9470's insufficient_user_authentication (the person must sign in more strongly or more recently), as {:error, {:insufficient_user_authentication, %Sake.Auth.Challenge{}}}.

On the modern protocol the client declares the tasks extension, so a server may answer a tool call with a task; call_tool/3 follows it to the end (task_timeout:, in ms, default five minutes), and call_tool_async/3 hands it back instead. tasks: false leaves the extension out.

max_response_size: (bytes, default 16 MiB) caps what the client reads of one HTTP response: a JSON body, or one Server-Sent Event still being received. A server that sends more ends the request with {:error, :response_too_large}.

ui: true declares MCP Apps (see Sake.UI), on both eras, for a client that renders them: the server then lists its app-only tools and UI resources, and each Sake.Tool.Spec from list_tools/1 carries the ui:// URI of its app in ui and who may call it in visibility.

{:stdio, command, args} in place of a URL launches the server as a subprocess and talks to it over its standard input and output:

{:ok, session} = Sake.Client.connect({:stdio, "npx", ["-y", "@modelcontextprotocol/server-everything"]})

Stdio options: env: (a map or list of name-value pairs; a nil value unsets the variable), cd:, stderr: (:inherit, the default, shares this VM's standard error; :ignore discards it), probe_timeout: (ms to wait for an answer to the modern probe before falling back to initialize, default 10 000), close_timeout: (ms close/1 waits for the server to exit before killing it, default 5 000).

Sake.Test connects the client to a server in the same VM, with the server itself as the transport, for the server's tests (see guides/testing.md).

With opentelemetry_api in the host, each request is a CLIENT span and carries the current trace context in its _meta, so the server's spans join the caller's trace; the span of call_tool/3 covers the whole call, its retries with the client's input and a task's polls included. Each request is also the telemetry span [:sake, :client, :request], with method, era, protocol_version and transport in the metadata and error_type (nil on success) on :stop. See guides/observability.md.

Summary

Functions

Calls a tool and returns its result. When the server answers with a task (see Sake.Tasks), the call polls it to the end, answering its input requests as a retry would, and returns the final result: a failed task is {:error, {:rpc, error}, session}, a cancelled one {:error, :cancelled, session}, and one still running after task_timeout: is cancelled and returned as {:error, :task_timeout, session}.

Calls a tool and returns at once: the result, or {:task, task} when the server made a task of the call. Follow the task with get_task/2, answer its inputRequests with update_task/3, and stop it with cancel_task/2.

Asks the server to cancel a task. Cancellation is cooperative: the task may still end otherwise.

The task as the server has it now: its status, and inputRequests, result or error.

Follows the server's own notifications until it ends the stream: fun gets the method and params of each list change ("notifications/tools/list_changed" and the like) and resource update ("notifications/resources/updated"). filter names what to follow, as a subscriptions/listen request's notifications does: "toolsListChanged", "promptsListChanged" and "resourcesListChanged" (true), and "resourceSubscriptions" (a list of URIs).

Sends one request and returns its result as the server sent it. Unlike the calls above, an input-required result ("resultType" => "input_required") or a task ("task") comes back as it is, to answer or follow yourself: Sake.Proxy forwards both this way. A question a legacy server asks during the call is still answered with the session's on_input:, sampling: and roots:. Options: tool: (the Sake.Tool.Spec of a tools/call, for its Mcp-Param-* headers) and timeout: (ms).

Asks the server for log messages at level or above ("debug" to "emergency"), which then reach on_notification: during a call. On the modern protocol the level travels in every request's _meta; on a legacy session the client sends logging/setLevel.

Answers some or all of a task's inputRequests, by key.

Functions

call_tool(session, name, arguments)

Calls a tool and returns its result. When the server answers with a task (see Sake.Tasks), the call polls it to the end, answering its input requests as a retry would, and returns the final result: a failed task is {:error, {:rpc, error}, session}, a cancelled one {:error, :cancelled, session}, and one still running after task_timeout: is cancelled and returned as {:error, :task_timeout, session}.

call_tool_async(session, name, arguments)

@spec call_tool_async(Sake.Client.Session.t(), String.t(), map()) ::
  {:ok, Sake.Client.ToolResult.t() | {:task, map()}, Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

Calls a tool and returns at once: the result, or {:task, task} when the server made a task of the call. Follow the task with get_task/2, answer its inputRequests with update_task/3, and stop it with cancel_task/2.

cancel_task(session, task_id)

@spec cancel_task(Sake.Client.Session.t(), String.t()) ::
  {:ok, Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}

Asks the server to cancel a task. Cancellation is cooperative: the task may still end otherwise.

close(session)

@spec close(Sake.Client.Session.t()) :: :ok

complete(session, ref, argument, value)

@spec complete(
  Sake.Client.Session.t(),
  {:prompt, String.t()} | {:resource, String.t()},
  String.t(),
  String.t()
) ::
  {:ok, map(), Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

connect(target, opts \\ [])

@spec connect(
  String.t() | {:stdio, String.t(), [String.t()]} | {:local, struct()},
  keyword()
) ::
  {:ok, Sake.Client.Session.t()} | {:error, term()}

discover(session)

@spec discover(Sake.Client.Session.t()) ::
  {:ok, map(), Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

get_prompt(session, name, arguments)

@spec get_prompt(Sake.Client.Session.t(), String.t(), map()) ::
  {:ok, map(), Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

get_task(session, task_id)

@spec get_task(Sake.Client.Session.t(), String.t()) ::
  {:ok, map(), Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

The task as the server has it now: its status, and inputRequests, result or error.

list_prompts(session)

@spec list_prompts(Sake.Client.Session.t()) ::
  {:ok, [map()], Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

list_resource_templates(session)

@spec list_resource_templates(Sake.Client.Session.t()) ::
  {:ok, [map()], Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

list_resources(session)

@spec list_resources(Sake.Client.Session.t()) ::
  {:ok, [map()], Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

list_tools(session)

@spec list_tools(Sake.Client.Session.t()) ::
  {:ok, [Sake.Tool.Spec.t()], Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

listen(session, filter, fun)

@spec listen(Sake.Client.Session.t(), map(), (String.t(), map() -> any())) ::
  {:ok, Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}

Follows the server's own notifications until it ends the stream: fun gets the method and params of each list change ("notifications/tools/list_changed" and the like) and resource update ("notifications/resources/updated"). filter names what to follow, as a subscriptions/listen request's notifications does: "toolsListChanged", "promptsListChanged" and "resourcesListChanged" (true), and "resourceSubscriptions" (a list of URIs).

On the modern protocol this is a subscriptions/listen request; on a legacy session, the session's GET stream (over stdio, the notifications the server sends between calls), with a resources/subscribe for each URI, sent before the stream opens and again once it is open (a server may keep the session's subscriptions on its stream, as Sake does). It blocks until the stream ends, so call it from a process of its own, and stop that process to stop listening. Returns {:ok, session} when the server ended the stream.

ping(session)

@spec ping(Sake.Client.Session.t()) ::
  {:ok, Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}

read_resource(session, uri)

@spec read_resource(Sake.Client.Session.t(), String.t()) ::
  {:ok, [Sake.Content.t()], Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

request(session, method, params, opts \\ [])

@spec request(Sake.Client.Session.t(), String.t(), map(), keyword()) ::
  {:ok, map(), Sake.Client.Session.t()}
  | {:error, term(), Sake.Client.Session.t()}

Sends one request and returns its result as the server sent it. Unlike the calls above, an input-required result ("resultType" => "input_required") or a task ("task") comes back as it is, to answer or follow yourself: Sake.Proxy forwards both this way. A question a legacy server asks during the call is still answered with the session's on_input:, sampling: and roots:. Options: tool: (the Sake.Tool.Spec of a tools/call, for its Mcp-Param-* headers) and timeout: (ms).

set_log_level(session, level)

@spec set_log_level(Sake.Client.Session.t(), String.t() | atom()) ::
  {:ok, Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}

Asks the server for log messages at level or above ("debug" to "emergency"), which then reach on_notification: during a call. On the modern protocol the level travels in every request's _meta; on a legacy session the client sends logging/setLevel.

update_task(session, task_id, responses)

@spec update_task(Sake.Client.Session.t(), String.t(), map()) ::
  {:ok, Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}

Answers some or all of a task's inputRequests, by key.