ConduitMcp.Tasks (ConduitMCP v0.11.0)

Copy Markdown View Source

Task management for long-running MCP operations (experimental).

Frozen, legacy era only

This module serves the 2025-11-25 tasks methods (tasks/get, tasks/cancel, tasks/result, tasks/list) to legacy-era clients only. It is frozen: no new features, and it goes away with the legacy era. For the 2026-07-28 tasks extension (io.modelcontextprotocol/tasks) see ConduitMcp.Ext.Tasks.

Tasks provide a durable state machine for operations that may take time to complete. Clients can poll for status, cancel in-flight operations, and receive results asynchronously.

Task Lifecycle

working → completed
working → failed
working → cancelled
working → input_required → working (after elicitation)

Storage

Storage is delegated to a pluggable store module implementing ConduitMcp.Tasks.Store. The default store is ConduitMcp.Tasks.EtsStore (in-memory, single-node). For durable, multi-node deployments — or to back tasks with a job queue like Oban — implement the behaviour and configure it as the application's task store:

config :conduit_mcp, :tasks_store, MyApp.MyTasksStore

The standard tasks/get, tasks/cancel, tasks/result, and tasks/list JSON-RPC routes dispatch through this module, so swapping the store requires no handler changes. See examples/oban_tasks_server/ for an Oban + SQLite implementation and examples/oban_task_store.ex for a Postgres-flavored reference.

Configuration

Enable tasks in your transport config:

{ConduitMcp.Transport.StreamableHTTP,
  server_module: MyServer,
  tasks: [enabled: true]}

Summary

Functions

Cancels a task. Dispatches to the configured store's cancel/1 callback; falls back to update(task_id, %{"status" => "cancelled"}) for stores that don't implement it.

Cancels a task, scoped to owner.

Prunes terminal-state tasks older than ttl_ms. Dispatches to the configured store's cleanup/1 callback; returns 0 for stores that don't implement it (e.g., stores backed by native TTL like Redis).

Deletes a task by ID. See ConduitMcp.Tasks.Store.

Generates a unique task ID.

Gets a task by ID. See ConduitMcp.Tasks.Store.

Gets a task by ID, scoped to owner.

Lists tasks, optionally filtered by :status and :limit. See ConduitMcp.Tasks.Store.

Lists tasks scoped to owner, optionally filtered by :status and :limit.

Extracts the owner principal from a Plug.Conn (or conn-like map).

Returns the configured task store module. Reads from Application.get_env(:conduit_mcp, :tasks_store), defaulting to ConduitMcp.Tasks.EtsStore.

Updates a task's status and/or metadata. See ConduitMcp.Tasks.Store.

Returns the list of valid task statuses.

Validates that a status transition is allowed.

Types

status()

@type status() :: :working | :input_required | :completed | :failed | :cancelled

task_id()

@type task_id() :: String.t()

Functions

cancel(task_id)

Cancels a task. Dispatches to the configured store's cancel/1 callback; falls back to update(task_id, %{"status" => "cancelled"}) for stores that don't implement it.

cancel(task_id, owner)

Cancels a task, scoped to owner.

Returns {:error, :not_found} (without cancelling) when the task is owned by a different principal. See get/2 for the scoping rules.

cleanup(ttl_ms)

Prunes terminal-state tasks older than ttl_ms. Dispatches to the configured store's cleanup/1 callback; returns 0 for stores that don't implement it (e.g., stores backed by native TTL like Redis).

create(task_id, metadata \\ %{}, owner \\ nil)

Creates a new task. See ConduitMcp.Tasks.Store.

Pass owner (typically owner(conn)) to stamp the task with a principal so it can be owner-scoped via get/2, cancel/2, and list/2. When owner is nil (the default) the task is left unowned: every caller may see it, unless :tasks_require_owner is set, in which case no caller may (see get/2).

The owner is stored under the top-level "owner" key of the task metadata — see the "Owner scoping" section of ConduitMcp.Tasks.Store.

delete(task_id)

Deletes a task by ID. See ConduitMcp.Tasks.Store.

generate_id()

Generates a unique task ID.

get(task_id)

Gets a task by ID. See ConduitMcp.Tasks.Store.

get(task_id, owner)

Gets a task by ID, scoped to owner.

Returns {:error, :not_found} when the caller may not see the task, so a task's existence is never leaked to a non-owner.

task "owner"caller ownerdefault:tasks_require_owner
unownednilallowdeny
unownedXallowdeny
Ynildenydeny
YYallowallow
YXdenydeny

The Y/nil row keeps authorization default-closed: an unauthenticated request never reads a principal's task. Set config :conduit_mcp, :tasks_require_owner, true to also refuse unowned tasks, which is the right posture once every creation site stamps an owner.

See the "Owner scoping" section of ConduitMcp.Tasks.Store.

list(opts \\ [])

Lists tasks, optionally filtered by :status and :limit. See ConduitMcp.Tasks.Store.

list(opts, owner)

Lists tasks scoped to owner, optionally filtered by :status and :limit.

The owner is pushed into the store query rather than applied afterwards, so a store can express it as a predicate (the default EtsStore compiles all three options into one :ets.select/3 match spec). The facade re-checks the returned rows and then applies :limit itself: a custom store that ignores :owner must not turn into a silent authorization bypass, nor return more than :limit rows. Such a store must ignore :limit too, or it truncates the caller's own rows before the re-check (see ConduitMcp.Tasks.Store.list/1).

Returns the caller's own tasks plus unowned ones. nil = no principal: sees only unowned tasks; nothing under :tasks_require_owner (see get/2).

owner(conn)

Extracts the owner principal from a Plug.Conn (or conn-like map).

Applies the configured :task_owner_fun (config :conduit_mcp, :task_owner_fun), which defaults to ConduitMcp.Principal.id/1 — the stable scalar identity assigned by both auth plugs. Returns nil for nil/non-conn input or when no principal is present. nil = no principal: sees only unowned tasks; nothing under :tasks_require_owner (see get/2).

Return a stable scalar

Ownership is checked by exact match (===), so a custom extractor must return a stable, comparable identity — the user's sub/id scalar, never a struct or claims map. A term carrying any per-request volatile field (exp, iat, jti) fails to match its own tasks on the next request, so the owner's own task 404s; an extractor mapping distinct users to equal terms leaks tasks between them. ConduitMcp.Principal.id/1 is already such a scalar — prefer it.

store()

Returns the configured task store module. Reads from Application.get_env(:conduit_mcp, :tasks_store), defaulting to ConduitMcp.Tasks.EtsStore.

update(task_id, updates)

Updates a task's status and/or metadata. See ConduitMcp.Tasks.Store.

valid_statuses()

Returns the list of valid task statuses.

valid_transition?(from, to)

Validates that a status transition is allowed.