Client helpers for the Tasks extension, built on Snodo.Client.
Every function here takes a Snodo.Client and sends its requests with
Snodo.Client.request/4, Snodo.Client.call_tool/4, and
Snodo.Client.listen/3, so it works with any client those functions work
with: Snodo.Client.direct/2, stdio, and Streamable HTTP. See "Transports"
below for what Streamable HTTP does not carry yet.
{:ok, client} = Snodo.Client.connect({:stdio, "my_server", []})
case Snodo.Extensions.Tasks.Client.call_tool(client, "export", %{"id" => 7}) do
{:task, status} -> Snodo.Extensions.Tasks.Client.await(client, status)
{:ok, result} -> {:ok, result}
{:error, error} -> {:error, error}
end
# or, in one call
Snodo.Extensions.Tasks.Client.call_and_await(client, "export", %{"id" => 7})Negotiation
The extension is negotiated per request: the client declares
extensions["io.modelcontextprotocol/tasks"] in the request's client
capabilities, and a server that serves Tasks may then answer a tools/call
with a task. Each function here adds that declaration to the requests it
sends, through the :meta option of Snodo.Client.request/4, on top of the
client's own :client_capabilities. The client does not need to be built
with it, and its other requests are unaffected: Snodo.Client.call_tool/4
on the same client still runs a tool the server marks :optional
synchronously.
To declare the extension on every request instead, pass capabilities/1 as
the client's :client_capabilities. server_supports?/1 reads whether the
server advertises the extension.
Outcomes
await/3 and call_and_await/5 return the task's outcome:
{:ok, result}- the task completed.resultis the finalCallToolResultmap, string keys, asSnodo.Client.call_tool/4would have returned it for a synchronous call. A result with"isError" => trueis a tool that ran and reported its own failure.{:error, %Snodo.Error{}}- the task failed, with the server's JSON-RPC error ascode,message, anddataand the failedSnodo.Extensions.Tasks.Client.Statusincauseas{:task_failed, status}; or a request failed, or the wait timed out.{:cancelled, status}- the task was cancelled.{:input_required, status}- the task is waiting for input this call did not answer. See "Input" below.
Waiting
await/3 polls tasks/get by default. Between polls it waits the task's
pollIntervalMs, or :poll_interval when the server sent none, and never
less than :min_poll_interval. With listen: true it opens a
subscriptions/listen stream for the task instead and waits for
notifications/tasks events, reading the status once with tasks/get
after the stream is acknowledged so a change made before the stream opened
is not missed. A stream that ends before the task does, as one from a source
that sends only the current status, is followed by polling. A server
without a subscription source refuses subscriptions/listen with -32601,
which await/3 returns.
Either way the wait is bounded by :timeout, 60,000 ms unless set. Each
request the wait sends gets the time left as its timeout, or
:request_timeout (the client's timeout unless set) when that is shorter.
A direct client has no request timeout, so there each tasks/get and
tasks/update runs in a task that is stopped when the time is up. At the
limit await/3 returns -32001 (kind: :transport) with "taskId" and
"timeoutMs" in data and {:timeout, status} in cause, where status
is the last status read, or nil when none was read. The task keeps
running on the server; call await/3 again to keep waiting, or
cancel/3.
Three parts of a wait are not cut short at the limit:
- On a direct client,
listen: truewaits for the stream's acknowledgement without a bound. The stream belongs to the calling process, and the direct transport opens it in that process with no timeout, so it cannot be moved to a task that is stopped at the deadline. Over stdio and Streamable HTTP the acknowledgement is bounded like any other request. - Closing the stream at the end of a
listen: truewait, whichSnodo.Client.Subscription.close/1bounds at 5,000 ms. - Input handlers and the
:inputfunction, which run in the calling process until they return.
Input
A task that needs input reports :input_required with its outstanding
requests in input_requests, keyed by the key tasks/update answers them
under. await/3 answers them according to :input:
:handlers(the default) - the client's:input_handlers, the same functionsSnodo.Clientanswersinput_requiredresults with. Anelicitation/createrequest goes to the:formor:urlhandler by itsmode(:formwhen it has none),sampling/createMessageto:sampling, androots/listto:roots. Each handler receives the request'sparamsand returns{:ok, response}. When a request has no handler for its kind, no handler runs andawait/3returns{:input_required, status}.:return- returns{:input_required, status}at once.- a function of one argument - called with the status, its
input_requestslimited to the requests not answered yet during this wait, and returns{:ok, responses}, a map from key to response, or{:error, reason}.
The responses are sent with tasks/update and the wait goes on. A handler
response is checked as Snodo.Client checks it: an elicitation result needs
an "action" of "accept", "decline", or "cancel", a sampling result
must pass Snodo.Sampling.valid_response?/1, and a roots result
Snodo.Roots.valid_response?/1. A handler or function that returns
{:error, reason}, anything other than {:ok, map}, or a handler
response that fails its check (reason is {:invalid_response, response})
stops the wait with -32603 (kind: :execution) and
cause: {:input_handler, key, reason, status}; key is nil for the
function form. An exception raised by a handler or the function
propagates to the caller. To answer by hand, call update/4 with the
responses and then await/3 again.
Transports
Over Streamable HTTP the server requires an Mcp-Name header that mirrors
params.taskId on tasks/get, tasks/update, and tasks/cancel.
Snodo.Client's HTTP transport derives the headers it sends from the core
protocol dialect, which does not know the extension's methods, so it does
not send that header and the server answers -32020. A task-augmented
tools/call and subscriptions/listen work over HTTP; the task methods,
and therefore await/3, work over Snodo.Client.direct/2 and stdio.
Summary
Types
How a task ended, or why a wait stopped. See "Outcomes" in the module doc.
A task, by its taskId or by a status read earlier.
Functions
Waits for a task to finish and returns its outcome.
Calls a tool and, when the server answers with a task, waits for it.
Calls a tool, declaring the Tasks extension for the call.
Requests tasks/cancel.
Adds the Tasks extension to a client capabilities map.
Requests tasks/get and decodes the task.
Returns the outcome of a finished task: {:ok, result} for :completed,
{:error, %Snodo.Error{}} for :failed, and {:cancelled, status} for
:cancelled.
Reads whether the server advertises the Tasks extension.
Requests tasks/update with input responses.
Types
@type input() :: :handlers | :return | (Snodo.Extensions.Tasks.Client.Status.t() -> {:ok, %{optional(String.t()) => map()}} | {:error, term()})
The :input option of await/3.
@type outcome() :: {:ok, map()} | {:error, Snodo.Error.t()} | {:cancelled, Snodo.Extensions.Tasks.Client.Status.t()} | {:input_required, Snodo.Extensions.Tasks.Client.Status.t()}
How a task ended, or why a wait stopped. See "Outcomes" in the module doc.
@type task_ref() :: String.t() | Snodo.Extensions.Tasks.Client.Status.t()
A task, by its taskId or by a status read earlier.
Functions
@spec await(Snodo.Client.t(), task_ref(), keyword()) :: outcome()
Waits for a task to finish and returns its outcome.
The wait starts by reading the task with tasks/get, unless it is given a
finished task's status with its result or error, whose outcome it returns
at once. See the module
doc for the outcomes, the two ways of waiting, and input.
Options:
:timeout- the most milliseconds to wait, or:infinity. Defaults to 60,000.:listen- whentrue, wait fornotifications/taskson asubscriptions/listenstream instead of polling. Defaults tofalse.:poll_interval- milliseconds between polls when the task has nopollIntervalMs. Defaults to 1,000.:min_poll_interval- the fewest milliseconds between polls, whatever the task'spollIntervalMs. Defaults to 50.:input-:handlers,:return, or a function. Defaults to:handlers.:request_timeout- the timeout of each request the wait sends. Defaults to the client's.
@spec call_and_await( Snodo.Client.t(), String.t() | map(), map(), keyword(), keyword() ) :: outcome() | Snodo.Client.response()
Calls a tool and, when the server answers with a task, waits for it.
call_opts are the options of call_tool/4; await_opts those of
await/3. A tool the server ran at once returns what call_tool/4
returned, so {:input_required, result} here may be an ordinary
multi round-trip result (a map) rather than a task status.
@spec call_tool(Snodo.Client.t(), String.t() | map(), map(), keyword()) :: {:task, Snodo.Extensions.Tasks.Client.Status.t()} | Snodo.Client.response()
Calls a tool, declaring the Tasks extension for the call.
Returns {:task, status} when the server created a task for the call;
status is usually :working. Otherwise the server ran the tool at once
and the response is returned as Snodo.Client.call_tool/4 returns it:
{:ok, result}, {:input_required, result}, or {:error, error}. A tool
the server runs only as a task (:required) answers -32021 to a request
that does not declare the extension; this function always declares it.
Options are those of Snodo.Client.call_tool/4. Entries in :meta win
over the extension declaration.
@spec cancel(Snodo.Client.t(), task_ref(), keyword()) :: :ok | {:error, Snodo.Error.t()}
Requests tasks/cancel.
Cancelling a task that has already finished succeeds and changes nothing;
read the status to see how it ended. Options are those of
Snodo.Client.request/4.
Adds the Tasks extension to a client capabilities map.
Pass the result as :client_capabilities to Snodo.Client.direct/2 or
Snodo.Client.connect/2 to declare the extension on every request:
Snodo.Client.direct(runtime,
client_capabilities: Snodo.Extensions.Tasks.Client.capabilities()
)The extension defines no settings, so its entry is %{}.
@spec get(Snodo.Client.t(), task_ref(), keyword()) :: {:ok, Snodo.Extensions.Tasks.Client.Status.t()} | {:error, Snodo.Error.t()}
Requests tasks/get and decodes the task.
An unknown task, or one in another authorization scope, is -32602. A
result that is not a task is a -32000 transport error with the result in
cause. Options are those of Snodo.Client.request/4.
@spec outcome(Snodo.Extensions.Tasks.Client.Status.t()) :: outcome()
Returns the outcome of a finished task: {:ok, result} for :completed,
{:error, %Snodo.Error{}} for :failed, and {:cancelled, status} for
:cancelled.
Raises ArgumentError for a status that is not finished, and for a
completed or failed status without its result or error, as in a
creation result; read such a task with get/3 first.
@spec server_supports?(Snodo.Client.t()) :: {:ok, boolean()} | {:error, Snodo.Error.t()}
Reads whether the server advertises the Tasks extension.
On a 2026-07-28 connection this requests server/discover and looks for
the extension under capabilities.extensions. On an initialize-era
connection it reads the capabilities initialize returned, which never
carry the extension.
@spec update( Snodo.Client.t(), task_ref(), %{optional(String.t()) => map()}, keyword() ) :: :ok | {:error, Snodo.Error.t()}
Requests tasks/update with input responses.
responses maps each key of the status's input_requests to its
response, for example an elicitation result
%{"action" => "accept", "content" => %{...}}. The server ignores keys it
is not waiting for. Options are those of Snodo.Client.request/4.