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

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

> #### Frozen, legacy era only {: .info}
>
> 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]}

# `status`

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

# `task_id`

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

# `cancel`

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`

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`

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`

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`

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

# `generate_id`

Generates a unique task ID.

# `get`

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

# `get`

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 `owner` | default | `:tasks_require_owner` |
|---|---|---|---|
| unowned | `nil` | allow | deny |
| unowned | `X` | allow | deny |
| `Y` | `nil` | **deny** | deny |
| `Y` | `Y` | allow | allow |
| `Y` | `X` | deny | deny |

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`

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

# `list`

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 `c: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`

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 {: .warning}
>
> 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`

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

# `valid_statuses`

Returns the list of valid task statuses.

# `valid_transition?`

Validates that a status transition is allowed.

---

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