The MCP Tasks extension (io.modelcontextprotocol/tasks) for the modern
(2026-07-28) protocol era.
Experimental and unstable
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 noneThe 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 (ConduitMcp.Server.handle_call_tool/3, a DSL
handle, a 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}
endattrs (keyword list or atom-keyed map), all optional:
:ttl_ms— lifetime from creation (default one hour);nilmeans 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 (aTaskunder 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}]})
endcomplete/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.
Summary
Types
A JSON-RPC error object: "code", "message", optional "data".
Functions
Whether the task was cancelled (false for an unknown id).
Completes the task with result — what the request would have returned
synchronously, e.g. a CallToolResult map. Options: :status_message.
Whether config :conduit_mcp, :ext_tasks enables the extension.
The extension identifier, "io.modelcontextprotocol/tasks".
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.
A fresh task id: 32 bytes from :crypto.strong_rand_bytes/1, url-safe
Base64 without padding (task ids are bearer tokens).
Updates a non-terminal task's status message (and lastUpdatedAt)
without changing its status.
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.
Reads the task (worker side; not owner-scoped).
Every task status; see terminal?/1.
The configured store (default ConduitMcp.Ext.Tasks.EtsStore).
Whether status is terminal (completed, failed, cancelled).
The bare Task for record (no status-specific payload): the body of a
CreateTaskResult.
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).
Validates a :ext_tasks config value; returns it normalized to false
or a keyword list. Raises ArgumentError naming the key otherwise.
Types
Functions
@spec cancelled?(ConduitMcp.Ext.Tasks.Store.task_id()) :: boolean()
Whether the task was cancelled (false for an unknown id).
@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.
@spec enabled?() :: boolean()
Whether config :conduit_mcp, :ext_tasks enables the extension.
@spec extension() :: String.t()
The extension identifier, "io.modelcontextprotocol/tasks".
@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.
@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).
@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.
@spec require_input( ConduitMcp.Ext.Tasks.Store.task_id(), %{optional(String.t()) => 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.
@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).
@spec statuses() :: [ConduitMcp.Ext.Tasks.Store.status()]
Every task status; see terminal?/1.
@spec store() :: module()
The configured store (default ConduitMcp.Ext.Tasks.EtsStore).
Whether status is terminal (completed, failed, cancelled).
@spec to_task(ConduitMcp.Ext.Tasks.Store.task()) :: map()
The bare Task for record (no status-specific payload): the body of a
CreateTaskResult.
@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).
Validates a :ext_tasks config value; returns it normalized to false
or a keyword list. Raises ArgumentError naming the key otherwise.