Sake.Tasks (Sake v0.4.3)

Copy Markdown View Source

Tools that run as tasks: the io.modelcontextprotocol/tasks extension of the modern protocol.

defmodule MyApp.MCP.Export do
  use Sake.Tool, name: "export", task: :optional
  arg :project, :string, required: true

  @impl true
  def execute(%{project: project}, ctx) do
    files = Projects.files(project)

    exported =
      Enum.reduce_while(files, 0, fn file, n ->
        if Sake.Tasks.cancelled?(ctx) do
          {:halt, n}
        else
          Export.file(file)
          Sake.Progress.report(ctx, n + 1, length(files))
          {:cont, n + 1}
        end
      end)

    {:ok, "Exported #{exported} of #{length(files)} files."}
  end
end

When a client declares the extension in a request's capabilities, a call to a tool with task: :optional (or true) or task: :required answers at once with a task, and execute/2 runs in the background; the client polls tasks/get until the task ends. Without the extension, an optional task tool runs like any other tool, and a required one is refused with -32021. Arguments and scopes are checked before a task exists, and a tool's prepare/2 runs first, so it can still ask the client for input on the original request (see Sake.Tool).

Inside a task, Sake.Elicit, Sake.Sampling and Sake.Roots move the task to input_required and wait for the client's tasks/update (keys must be unique over the task's life: asking a key again returns the first answer); Sake.Progress.report/4 sets the task's statusMessage; cancelled?/1 tells whether the client cancelled. Cancelling marks the task cancelled at once and stops its process when it runs on the node that received tasks/cancel; elsewhere, the tool sees the cancellation when it next waits for input (a cancelled elicitation, {:error, :cancelled} from sampling and roots) or calls cancelled?/1.

A task belongs to whoever created it, and to what they created it through: the token's subject when the endpoint has authorization (otherwise the host's scope, see Sake.Plug's scope:), together with the server, the token's OAuth client and its resource. Any other caller, the same person through another client, resource or server included, is told the task does not exist. Reading, answering, cancelling and following a task also need the scopes its tool required when it started: a token without them is refused with insufficient_scope, as the call itself would be. Task IDs carry 144 random bits.

Tasks are configured on the Sake child:

{Sake, secret_key_base: ..., tasks: [store: {Sake.Tasks.Store.Ecto, repo: MyApp.Repo}]}

Options: store (default Sake.Tasks.Store.ETS, which keeps tasks on one node; use Sake.Tasks.Store.Ecto when the endpoint runs on several), ttl (how long a task is kept after it was created, in ms; default one hour), poll_interval (the pollIntervalMs suggested to clients, and how often a waiting task checks the store; default one second) and max_per_principal (how many live tasks one owner may have at once, see below; default 100, or :infinity). tasks: false turns tasks off: task tools then run like any other tool, and the extension is not advertised.

An owner with max_per_principal tasks neither ended nor expired gets no new one until one ends: a task: :optional tool then runs like any other tool, in the request, and a task: :required tool (or a proxied call the upstream made a task of, which is cancelled upstream) is refused with -32600.

Telemetry: [:sake, :task, :start | :stop | :exception] around the tool's run, with task_id, tool and server in the metadata, and the final status on :stop. With OpenTelemetry, the run is an execute_tool span, the child of the span of the tools/call that started it (see guides/observability.md).

Summary

Functions

Whether the client asked to cancel the task the component runs in; always false outside a task.

Functions

cancelled?(arg1)

@spec cancelled?(Sake.Context.t()) :: boolean()

Whether the client asked to cancel the task the component runs in; always false outside a task.