# `Snodo.Extensions.Tasks.Client`
[🔗](https://github.com/joshrotenberg/snodo/blob/v0.4.0/extensions/tasks/lib/snodo/extensions/tasks/client.ex#L1)

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.

# `input`

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

The `:input` option of `await/3`.

# `outcome`

```elixir
@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`

```elixir
@type task_ref() :: String.t() | Snodo.Extensions.Tasks.Client.Status.t()
```

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

# `await`

```elixir
@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`

```elixir
@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`

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

# `cancel`

```elixir
@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`

```elixir
@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`

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

# `outcome`

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

# `server_supports?`

```elixir
@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`

```elixir
@spec update(
  Snodo.Client.t(),
  task_ref(),
  %{optional(String.t()) =&gt; 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`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
