The MCP Tasks primitive (experimental in the MCP 2025-11-25 spec) lets a client kick off a long-running operation, receive a handle immediately, and poll for status. Sandbar implements Tasks by composing them with the workflow substrate (sandbar.util.workflow) — every task is a workflow process and every progress / completion / cancellation event maps to a workflow state transition.
This document describes the MCP-side surface. For workflow definitions, transitions, guards, and the underlying lifecycle see doc/workflow.md.
tasks/gettasks/cancelA long-running tool call (export-all, validate-all-instances, bulk-ingest, schema migration, anything that takes longer than a request-response cycle) returns a task handle rather than blocking. The client then:
taskId immediatelytasks/get <taskId> for status, OR subscribes to notifications/progress for push updatestasks/cancel <taskId> if neededThe task-id IS the workflow process's :db/id (stringified for JSON transport). There is no parallel registry — the workflow process is the durable source of truth.
MCP Client Sandbar
│ │
│ tools/call (long-running) │
│ ───────────────────────────────────────►│
│ │ start-task! ─►
│ │ workflow/start-process!
│ │
│ ◄─ { content: [...], taskId: "42" } ───│
│ │
│ notifications/progress (taskId 42) │
│ ◄──────────────────────────────────────│
│ │ ... workflow advances
│ │ through states
│ tasks/get { taskId: "42" } │
│ ───────────────────────────────────────►│
│ ◄─ { status: "running", state: ... } ──│
│ │
│ tasks/get { taskId: "42" } │
│ ───────────────────────────────────────►│
│ ◄─ { status: "completed", content: ...}│
| Workflow concept | MCP Task concept |
|---|---|
Workflow process :db/id (as string) | taskId |
| Current state ident | state field on status |
process-completed? true | status: "completed" |
process-in-terminal-state? true (non-completed terminal) | status: "completed" (refined in C.7.2) |
| Non-terminal state | status: "running" |
| Process not found | status: "missing" |
cancel-process! invoked | Terminal cancelled state |
| Method | Direction | Purpose |
|---|---|---|
tasks/get | Client → Server | Fetch current status + (if terminal) result content |
tasks/cancel | Client → Server | Request cancellation of an in-flight task |
notifications/progress | Server → Client | Push progress signal (fired by start-task! + workflow transitions) |
All methods use the JSON-RPC 2.0 envelope shape from doc/mcp-server.md.
sandbar.mcp.tasks/process->task-status projects a workflow process to an MCP task status object:
;; Running task
{:status "running"
:state :validation/in-progress}
;; Successful completion
{:status "completed"
:state :validation/passed}
;; Terminal but non-success (e.g., :validation/failed)
{:status "completed"
:state :validation/failed}
;; Process not found
{:status "missing"}
Note: The current Stage C.7 implementation maps both successful and non-successful terminal states to "completed". Stage C.7.2 will refine this to distinguish "completed" (success) from "failed" (error) based on the workflow's terminal-state classification.
When a tool wants to start a long-running operation, it calls start-task! and returns the task handle to the client:
(require '[sandbar.mcp.tasks :as tasks])
(defn handle-bulk-validate
"MCP tool handler that validates all instances of a class.
Long-running for large class hierarchies."
[id params]
(let [class-ident (:class params)
task-id (tasks/start-task! :validation/all-instances
class-ident
{:requested-by (-> params :_identity :db/id)})]
{:jsonrpc "2.0"
:id id
:result {:content [{:type "text"
:text (str "Validation started. Task: " task-id)}]
:taskId task-id}}))
start-task! signature(start-task! workflow-ident subject data) ;; → task-id (string)
| Argument | Description |
|---|---|
workflow-ident | :db/ident of the workflow definition (e.g., :validation/all-instances) |
subject | Entity (or :db/id) the process operates on |
data | Initial process data map (becomes the workflow's :process/data) |
start-task! doesworkflow/start-process! — creates a workflow process in the initial state:db/id as the task-idnotifications/progress so subscribed clients learn of the new task immediatelyPer the layer-targeting discipline (see doc/mcp-server.md), start-task! never touches datomic.api — only the workflow/* abstraction.
tasks/getFetch the current status (and terminal result when complete) for a task.
POST /mcp
Authorization: Bearer <service>:<key>
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 42,
"method": "tasks/get",
"params": {
"taskId": "17592186045700"
}
}
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"taskId": "17592186045700",
"status": "running",
"state": ":validation/in-progress"
}
}
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"taskId": "17592186045700",
"status": "completed",
"state": ":validation/passed",
"content": [
{"type": "text",
"text": "Task in state: :validation/passed\nProcess data: {:instances-validated 142, :failures 0}"}
]
}
}
The content field is populated only when the task reaches a terminal state. It carries:
:process/data (whatever the workflow's terminal step recorded){
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32602,
"message": "Task not found: 17592186045700",
"data": {"received-task-id": "17592186045700"}
}
}
taskId parameter{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32602,
"message": "tasks/get requires :taskId parameter"
}
}
If the taskId isn't a parseable long, tasks/get treats it as not-found (returns the same -32602 shape).
tasks/cancelRequest termination of an in-flight task.
POST /mcp
{
"jsonrpc": "2.0",
"id": 43,
"method": "tasks/cancel",
"params": {
"taskId": "17592186045700"
}
}
{
"jsonrpc": "2.0",
"id": 43,
"result": {
"taskId": "17592186045700",
"status": "completed",
"state": ":validation/cancelled",
"content": [
{"type": "text",
"text": "Task cancelled. Workflow state: :validation/cancelled"}
]
}
}
workflow/cancel-process! checks workflow/can-cancel? before transitioning. If the process can't be cancelled (already terminal, or the workflow declares it non-cancellable), the response carries isError: true:
{
"jsonrpc": "2.0",
"id": 43,
"result": {
"content": [
{"type": "text",
"text": "Cannot cancel task: process already in terminal state (reason: :already-completed)"}
],
"isError": true
}
}
Possible :reason values:
| Reason | Meaning |
|---|---|
:already-completed | Process is already in a terminal state |
:not-cancellable | The workflow definition disallows cancellation from the current state |
:no-cancel-transition | No transition exists to a :cancelled-type terminal state |
Same shapes as tasks/get's Task not found and missing-parameter errors.
notifications/progressFired automatically when:
start-task! creates a new workflow process (initial broadcast){
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"taskId": "17592186045700",
"status": "running",
"workflow": ":validation/all-instances"
}
}
Clients subscribe by connecting to GET /mcp/sse (see doc/mcp-server.md). All notification methods route through the same SSE channel; clients filter client-side by method and params.taskId.
The Tasks primitive doesn't replace tools/call — it composes with it. Tools have two response shapes:
| Shape | When to use |
|---|---|
Inline content array | Sub-second operations; fits within a single request |
taskId handle + start-task! | Operations expected to exceed ~5 seconds |
Stage C.7.3 (future) will auto-dispatch tools/call to a Task when the tool declares (or the runtime measures) expected duration over a threshold. For now, tools opt in explicitly by calling start-task!.
(defn handle-export-all
"MCP tool: export every memory entity to markdown. Long-running."
[id params]
(let [scope (:scope params)
task-id (tasks/start-task! :export/all-memories
scope
{:format :markdown
:requested-at (java.util.Date.)})]
{:jsonrpc "2.0"
:id id
:result {:content [{:type "text"
:text (str "Exporting " scope " memories to markdown.\n"
"Track progress: tasks/get { taskId: \"" task-id "\" }")}]
:taskId task-id}}))
The MCP client UI typically renders taskId-bearing responses with a progress indicator and an explicit "cancel" button.
| Failure | Symptom | Recovery |
|---|---|---|
taskId from older session (process garbage-collected) | Task not found | Tasks aren't durable beyond workflow-process lifetime; start a new task |
Workflow process exists but :db/ident-less state | tasks/get returns state: nil | Verify workflow definition has named states |
cancel-process! fails because workflow has no :cancelled terminal | isError: true with :reason :no-cancel-transition | Add a :cancelled terminal + :cancel transition to the workflow definition |
| Cancellation race (cancel arrives while process completes) | One of :already-completed or successful cancel wins | Clients should treat both terminal outcomes as "task ended" |
| SSE channel dropped mid-progress | Subscriber removed from registry; notifications/progress no longer reaches client | Reconnect to /mcp/sse; poll tasks/get for the missed state |
taskId not a valid long | Treated as not-found | Use task-ids as opaque strings; don't synthesize them |
Sandbar already has a workflow substrate with:
workflow/cancel-process! extension)validation-as-workflow pattern proving the substrate scales to interesting use casesBuilding a parallel "task" registry would have duplicated this. Instead, Tasks delegates: the workflow IS the task, the process-id IS the task-id, the state IS the status.
The Sandbar MCP Server Design ADR (decision B.1.10) names this composition explicitly:
The Tasks primitive composes naturally with Sandbar's workflow substrate. A long-running tool-call returns a TASK HANDLE instead of immediate content; the client polls
tasks/get <handle>for status; receives the terminal result when the workflow process reaches a terminal state.
sandbar.mcp.tasks follows the same discipline as the rest of sandbar.mcp.* — only workflow/* abstractions; never raw datomic.api. When handle-cancel first needed cancellation semantics, the abstraction didn't expose them. Rather than bypass with raw d/transact, Stage C.7.4 landed workflow/cancel-process! and workflow/can-cancel? upstream. The MCP layer composes with the (now-richer) workflow abstraction.
| Capability | Status | Stage |
|---|---|---|
| Progress notifications on state transitions | Initial broadcast only | C.7.1 (planned) |
| Terminal-success vs terminal-failure status refinement | Both map to "completed" | C.7.2 (planned) |
Auto-dispatch tools/call → Tasks based on expected duration | Manual opt-in via start-task! | C.7.3 (planned) |
| Task pause/resume | Not modeled | Future |
| Task chaining (one task triggers another) | Manual via workflow design | Future |
cancel-process!workflow/* rather than touching Datomic directlyCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |