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
@spec call_tool(Sake.Client.Session.t(), String.t(), map()) :: {:ok, Sake.Client.ToolResult.t(), Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
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}.
@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.
@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.
@spec close(Sake.Client.Session.t()) :: :ok
@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()}
@spec discover(Sake.Client.Session.t()) :: {:ok, map(), Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@spec get_prompt(Sake.Client.Session.t(), String.t(), map()) :: {:ok, map(), Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@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.
@spec list_prompts(Sake.Client.Session.t()) :: {:ok, [map()], Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@spec list_resource_templates(Sake.Client.Session.t()) :: {:ok, [map()], Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@spec list_resources(Sake.Client.Session.t()) :: {:ok, [map()], Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@spec list_tools(Sake.Client.Session.t()) :: {:ok, [Sake.Tool.Spec.t()], Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@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.
@spec ping(Sake.Client.Session.t()) :: {:ok, Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@spec read_resource(Sake.Client.Session.t(), String.t()) :: {:ok, [Sake.Content.t()], Sake.Client.Session.t()} | {:error, term(), Sake.Client.Session.t()}
@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).
@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.
@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.