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
endWhen 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
@spec cancelled?(Sake.Context.t()) :: boolean()
Whether the client asked to cancel the task the component runs in; always false outside a task.