# `ConduitMcp.Ext.Tasks`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/ext/tasks.ex#L1)

The MCP Tasks extension (`io.modelcontextprotocol/tasks`) for the modern
(`2026-07-28`) protocol era.

> #### Experimental and unstable {: .warning}
>
> The extension's own repository describes it as experimental and still
> tracking SEP-2663, and it already disagrees with the core specification
> (it documents `-32003` where `2026-07-28` uses `-32021`; this library
> follows the core specification). This API, its configuration and its
> wire output may change in any release. It is off unless enabled.

Specification: <https://modelcontextprotocol.io/extensions/tasks> and
<https://github.com/modelcontextprotocol/ext-tasks> (schema
`2026-07-28`). For legacy-era (`2025-11-25`) tasks see the frozen
`ConduitMcp.Tasks`.

## Enabling

    config :conduit_mcp, :ext_tasks, true

    # or, with options:
    config :conduit_mcp, :ext_tasks,
      store: MyApp.TaskStore,        # default ConduitMcp.Ext.Tasks.EtsStore
      janitor: [interval: 30_000],   # Session.Janitor options, or false
      max_rows: 10_000,              # row cap passed to create/2, or :infinity
      notifier: MyApp.TaskNotifier   # default ConduitMcp.Ext.Tasks.ListenNotifier; nil for none

The value is validated when `:conduit_mcp` starts. When enabled, the
application starts `ConduitMcp.Ext.Tasks.EtsStore.Owner` (default store
only) and a `ConduitMcp.Session.Janitor` named
`ConduitMcp.Ext.Tasks.Janitor` that calls the store's `cleanup/1` (TTL
expiry; `janitor: [ttl: ...]` is the age at which terminal tasks without
a TTL are evicted, default one hour), and `server/discover` advertises
`capabilities.extensions["io.modelcontextprotocol/tasks"]`.

## Returning a task

A `tools/call` callback (`c:ConduitMcp.Server.handle_call_tool/3`, a DSL
`handle`, a `c:ConduitMcp.Component.execute/2`) returns `{:task, attrs}`:

    def handle_call_tool(_conn, "build", %{"ref" => ref}) do
      {:task,
       ttl_ms: :timer.minutes(30),
       poll_interval_ms: 2_000,
       status_message: "Queued",
       start: fn task_id -> MyApp.Builds.enqueue(task_id, ref) end}
    end

`attrs` (keyword list or atom-keyed map), all optional:

  * `:ttl_ms` — lifetime from creation (default one hour); `nil` means
    unlimited.
  * `:poll_interval_ms` — suggested polling interval (positive integer).
  * `:status_message` — initial status message.
  * `:start` — `fn task_id -> ... end`, called once the task is stored.
    **This is where the work starts**: spawn it (a `Task` under your
    supervisor, an Oban job, a message to a GenServer) and return; the
    request is waiting. If it raises, the task is failed with `-32603`.

The library then requires that the client declared the extension in this
request's `io.modelcontextprotocol/clientCapabilities.extensions`
(otherwise `-32021` with `data.requiredCapabilities`, HTTP 400), stores
the task **before** answering, calls `:start`, and answers with a
`CreateTaskResult` (`resultType: "task"`). The task is owned by the
request's principal (`ConduitMcp.Principal.id/1`): `tasks/get`,
`tasks/update` and `tasks/cancel` from any other principal answer
"Task not found". Unauthenticated tasks are owned by `nil`; their id is
then their only protection (32 random bytes).

Only `tools/call` may return a task. From any other callback, on the
legacy era, or with the extension disabled, `{:task, _}` is logged and
answered with `-32603`.

## Driving a task from the worker

The worker reports through `complete/2`, `fail/2`, `require_input/3` and
`progress/2`, and reads the task with `status/1` (or `cancelled?/1`).
These address the task by id alone: the id is the capability, so keep it
out of logs.

    def perform(%{args: %{"task_id" => id, "ref" => ref}}) do
      :ok = ConduitMcp.Ext.Tasks.progress(id, "Building")
      result = MyApp.Builds.run(ref)
      ConduitMcp.Ext.Tasks.complete(id, %{"content" => [%{"type" => "text", "text" => result}]})
    end

`complete/2` takes what the tool would have returned synchronously (a
`CallToolResult` map, `isError: true` included). `fail/2` is for JSON-RPC
errors only.

Input mid-flight: `require_input/3` moves the task to `input_required`
with MRTR input requests (built with `ConduitMcp.MRTR.elicit/2`); the
client reads them with `tasks/get` and answers with `tasks/update`. Once
every outstanding key is answered the task returns to `working` and the
answers are in `status/1`'s `input_responses`. Each key must be unique
over the task's lifetime. Poll `status/1`, or configure a notifier to be
told.

Cancellation is cooperative: `tasks/cancel` moves a non-terminal task to
`cancelled` (after which `complete/2`/`fail/2` return
`{:error, :terminal}`); the worker should check `cancelled?/1` and stop.

Terminal states — `completed`, `failed`, `cancelled` — are final.

## Status notifications

Every change is handed to the configured `ConduitMcp.Ext.Tasks.Notifier`.
The default, `ConduitMcp.Ext.Tasks.ListenNotifier`, sends it as
`notifications/tasks` (the full task state, `to_wire/1`) to the
`subscriptions/listen` streams that named the task in their `taskIds`
filter, and only to those whose principal owns the task (SV-1; see
`ConduitMcp.Subscriptions.Filter`). A custom notifier replaces it;
`notifier: nil` turns notifications off.

# `rpc_error`

```elixir
@type rpc_error() :: %{required(String.t()) =&gt; term()}
```

A JSON-RPC error object: `"code"`, `"message"`, optional `"data"`.

# `cancelled?`

```elixir
@spec cancelled?(ConduitMcp.Ext.Tasks.Store.task_id()) :: boolean()
```

Whether the task was cancelled (`false` for an unknown id).

# `complete`

```elixir
@spec complete(ConduitMcp.Ext.Tasks.Store.task_id(), map(), keyword()) ::
  :ok | {:error, :not_found | :terminal}
```

Completes the task with `result` — what the request would have returned
synchronously, e.g. a `CallToolResult` map. Options: `:status_message`.

# `enabled?`

```elixir
@spec enabled?() :: boolean()
```

Whether `config :conduit_mcp, :ext_tasks` enables the extension.

# `extension`

```elixir
@spec extension() :: String.t()
```

The extension identifier, `"io.modelcontextprotocol/tasks"`.

# `fail`

```elixir
@spec fail(
  ConduitMcp.Ext.Tasks.Store.task_id(),
  rpc_error() | {integer(), String.t()},
  keyword()
) ::
  :ok | {:error, :not_found | :terminal}
```

Fails the task with a JSON-RPC error: `%{"code" => integer, "message" =>
string}` (optional `"data"`) or `{code, message}`. The status message
defaults to the error message. Options: `:status_message`.

# `new_id`

```elixir
@spec new_id() :: ConduitMcp.Ext.Tasks.Store.task_id()
```

A fresh task id: 32 bytes from `:crypto.strong_rand_bytes/1`, url-safe
Base64 without padding (task ids are bearer tokens).

# `progress`

```elixir
@spec progress(ConduitMcp.Ext.Tasks.Store.task_id(), String.t()) ::
  :ok | {:error, :not_found | :terminal}
```

Updates a non-terminal task's status message (and `lastUpdatedAt`)
without changing its status.

# `require_input`

```elixir
@spec require_input(
  ConduitMcp.Ext.Tasks.Store.task_id(),
  %{optional(String.t()) =&gt; map()},
  keyword()
) ::
  :ok
  | {:error,
     :not_found
     | :terminal
     | :invalid_input_requests
     | {:duplicate_keys, list()}}
```

Moves the task to `input_required` with `input_requests` (string keys to
MRTR input requests built with `ConduitMcp.MRTR`), added to any still
outstanding. A key already used by this task is rejected:
`{:error, {:duplicate_keys, keys}}`. Options: `:status_message`.

# `status`

```elixir
@spec status(ConduitMcp.Ext.Tasks.Store.task_id()) ::
  {:ok, ConduitMcp.Ext.Tasks.Store.task()} | {:error, :not_found}
```

Reads the task (worker side; not owner-scoped).

# `statuses`

```elixir
@spec statuses() :: [ConduitMcp.Ext.Tasks.Store.status()]
```

Every task status; see `terminal?/1`.

# `store`

```elixir
@spec store() :: module()
```

The configured store (default `ConduitMcp.Ext.Tasks.EtsStore`).

# `terminal?`

```elixir
@spec terminal?(term()) :: boolean()
```

Whether `status` is terminal (`completed`, `failed`, `cancelled`).

# `to_task`

```elixir
@spec to_task(ConduitMcp.Ext.Tasks.Store.task()) :: map()
```

The bare `Task` for `record` (no status-specific payload): the body of a
`CreateTaskResult`.

# `to_wire`

```elixir
@spec to_wire(ConduitMcp.Ext.Tasks.Store.task()) :: map()
```

The extension's `DetailedTask` for `record` — the `tasks/get` result
(before `resultType`) and the `notifications/tasks` params: `taskId`,
`status`, ISO 8601 `createdAt`/`lastUpdatedAt`, `ttlMs` (`null` when
unlimited), optional `statusMessage`/`pollIntervalMs`, plus
`inputRequests` (`input_required`), `result` (`completed`) or `error`
(`failed`).

# `validate_config!`

```elixir
@spec validate_config!(term()) :: false | keyword()
```

Validates a `:ext_tasks` config value; returns it normalized to `false`
or a keyword list. Raises `ArgumentError` naming the key otherwise.

---

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