ConduitMcp.Ext.Tasks (ConduitMCP v0.11.0)

Copy Markdown View Source

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 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 (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}
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.

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

rpc_error()

@type rpc_error() :: %{required(String.t()) => term()}

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

Functions

cancelled?(task_id)

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

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

complete(task_id, result, opts \\ [])

@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?()

@spec enabled?() :: boolean()

Whether config :conduit_mcp, :ext_tasks enables the extension.

extension()

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

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

fail(task_id, error, opts \\ [])

@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()

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

progress(task_id, status_message)

@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(task_id, input_requests, opts \\ [])

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

status(task_id)

@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()

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

Every task status; see terminal?/1.

store()

@spec store() :: module()

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

terminal?(status)

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

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

to_task(record)

@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(record)

@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!(value)

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