Snodo.Extensions.Tasks.Client (snodo_tasks v0.4.0)

Copy Markdown View Source

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. result is the final CallToolResult map, string keys, as Snodo.Client.call_tool/4 would have returned it for a synchronous call. A result with "isError" => true is a tool that ran and reported its own failure.
  • {:error, %Snodo.Error{}} - the task failed, with the server's JSON-RPC error as code, message, and data and the failed Snodo.Extensions.Tasks.Client.Status in cause as {: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: true waits 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: true wait, which Snodo.Client.Subscription.close/1 bounds at 5,000 ms.
  • Input handlers and the :input function, 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 functions Snodo.Client answers input_required results with. An elicitation/create request goes to the :form or :url handler by its mode (:form when it has none), sampling/createMessage to :sampling, and roots/list to :roots. Each handler receives the request's params and returns {:ok, response}. When a request has no handler for its kind, no handler runs and await/3 returns {:input_required, status}.
  • :return - returns {:input_required, status} at once.
  • a function of one argument - called with the status, its input_requests limited 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

The :input option of await/3.

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

input()

@type input() ::
  :handlers
  | :return
  | (Snodo.Extensions.Tasks.Client.Status.t() ->
       {:ok, %{optional(String.t()) => map()}} | {:error, term()})

The :input option of await/3.

outcome()

@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.

task_ref()

A task, by its taskId or by a status read earlier.

Functions

await(client, task, opts \\ [])

@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 - when true, wait for notifications/tasks on a subscriptions/listen stream instead of polling. Defaults to false.
  • :poll_interval - milliseconds between polls when the task has no pollIntervalMs. Defaults to 1,000.
  • :min_poll_interval - the fewest milliseconds between polls, whatever the task's pollIntervalMs. 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.

call_and_await(client, tool, arguments \\ %{}, call_opts \\ [], await_opts \\ [])

@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.

call_tool(client, tool, arguments \\ %{}, opts \\ [])

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.

cancel(client, task, opts \\ [])

@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.

capabilities(capabilities \\ %{})

@spec capabilities(map()) :: map()

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 %{}.

get(client, task, opts \\ [])

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.

outcome(status)

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.

server_supports?(client)

@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.

update(client, task, responses, opts \\ [])

@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.