TAI BUI
Back to curriculum
BuildPython

Async Tasks (SEP-1686) — Call-Now, Fetch-Later for Long-Running Work

Agent work that takes minutes to hours, like CI runs, deep-research synthesis, and batch exports, breaks synchronous tool calls: connections drop, clients time out, UIs block. SEP-1686, merged in 2025-11-25, adds a Tasks primitive. A request gets augmented into a task, and you fetch the result later or follow it through state notifications. Tasks are experimental through H1 2026, and the SDK surface is still being designed around the spec.

TIME: ~75 minutesPREREQUISITES: Phase 13 · 07 (MCP server), Phase 13 · 09 (transports)

Learning Objectives

  • Identify when to promote a tool from synchronous to task-augmented (>30 seconds of server-side work).
  • Walk the task lifecycle: `working` → `input_required` → `completed` / `failed` / `cancelled`.
  • Persist task state so crashes do not lose in-flight work.
  • Poll `tasks/status` and fetch `tasks/result` correctly.

The Problem

A generate_report tool runs a multi-minute extraction pipeline. Under the synchronous model you have three options, and each is bad:

  1. Hold the connection open for three minutes. Remote transports drop it, clients time out, and UIs freeze.
  2. Return immediately with a placeholder and make the client poll a custom endpoint. This breaks the uniformity MCP is supposed to give you.
  3. Fire and forget, with no result.

SEP-1686 adds a fourth: task augmentation. A request (typically tools/call) gets tagged as a task. The server returns a task id immediately, the client polls tasks/status, and it fetches tasks/result when the work is done. Server-side state survives restarts.

Warm-up0/3
1. A synchronous tools/call over a remote transport keeps running for three minutes. Given how a request is tied to its connection, what is the most likely failure?Warm-up
A client and a server joined by a remote transport. The client sends a synchronous tools/call, and a bar on a timeline from 0 to 3 minutes shows the server working. A dashed box asks for the most likely failure.
Setup from the stem: a three-minute synchronous call. The dashed box is the unknown.
2. A job on a server takes ten minutes, and the caller should not sit on one open connection for that whole time. Which general pattern fits?Warm-up
A caller lane and a server lane over a timeline from 0 to 10 minutes. The server lane has a bar for a ten minute job, and the caller lane holds a dashed box marked with a question mark.
Setup from the stem. The dashed box is the unknown.
3. An operation is idempotent. What does that tell you about calling it more than once?Warm-up
Two panels. The left shows set_lamp(ON) called once, the right shows the same call repeated three times. Under each is a dashed question mark for the state of the lamp afterward.
A made-up everyday operation; the stem names none. The dashed boxes are the unknown.
Check: The Problem0/2
1. What is SEP-1686?Define
A browser-style card showing the address of issue 1686 in the modelcontextprotocol repository on GitHub with the label SEP-1686, and a dashed box asking what SEP-1686 is.
Link target taken from the lesson's reading list. The dashed box is the unknown.
2. Why promote a three-minute tool to a task instead of holding the connection open?Explain
Two lanes over a timeline from 0 to 3 minutes for a three-minute tool. The first lane, hold the connection open, is a bar where the client waits. The second lane, promote to a task, is a dashed box with a question mark.
Setup from the stem. The dashed lane is the unknown.

The Concept

Task augmentation

A request becomes a task by setting params._meta.task.required: true (or optional: true, server decides). The server responds immediately with:

json
{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "_meta": {
      "task": {
        "id": "tsk_9f7b...",
        "state": "working",
        "ttl": 900000
      }
    }
  }
}

ttl is the server's promise to retain state. Once it elapses, the task result is discarded.

Per-tool opt-in

Tool annotations can declare task support:

  • taskSupport: "forbidden": the tool runs synchronously. Safe for fast tools.
  • taskSupport: "optional": the client may request task augmentation.
  • taskSupport: "required": the client has to use task augmentation.

A generate_report tool would be required. A notes_search tool would be forbidden.

States

working  -> input_required -> working  (loop via elicitation)
working  -> completed
working  -> failed
working  -> cancelled

State machine is append-only: once completed, failed, or cancelled, the task is terminal.

Methods

  • tasks/status {taskId} returns the current state and a progress hint.
  • tasks/result {taskId} blocks or returns 404 if the task isn't done yet.
  • tasks/cancel {taskId} is idempotent, and terminal states ignore it.
  • tasks/list is optional and enumerates active and recently completed tasks.

Streaming state changes

When the server supports it, the client can subscribe to state notifications:

server -> notifications/tasks/updated {taskId, state, progress?}

Clients that stream instead of polling get a better UX. Polling is always supported as the minimal surface.

Durable state

The spec requires servers that declare task support to persist state. A crash should not lose completed results within the ttl. A store can be SQLite, Redis, or the filesystem, and the harness for this lesson uses the filesystem.

Cancellation semantics

tasks/cancel is idempotent. If the task is mid-execution, the server attempts to stop it, which depends on the executor cooperating. If the task is already terminal, the request is a no-op.

Crash recovery

When the server process restarts:

  1. Load all persisted task states.
  2. Mark any working tasks whose process died as failed with error CRASH_RECOVERY.
  3. Preserve completed / failed / cancelled for their ttl.

Async tasks plus sampling

A task can itself call sampling/createMessage. This is how long-running research tasks work. The server's task thread samples the client's model as needed, and the client's UI shows the task as working with periodic progress updates.

Why this is experimental

SEP-1686 shipped in 2025-11-25, but the broader roadmap calls out three open issues: durable subscription primitives, subtasks (parent-child task relationships), and result-TTL standardization. Expect the spec to change through 2026. In production code, treat Tasks as stable only for the common case, and isolate anything touching subtasks so an SDK change stays cheap.

Check: The Concept0/24
1. In MCP, what is a task?Define
A tools/call request for generate_report with size large, shown as JSON, next to a dashed card asking what a task is in MCP.
Setup from the stem and the lesson's demo. The dashed card is the unknown.
2. How does a client mark a request as task-augmented?Define
A client on the left sends a tools/call request for generate_report to a server on the right. A dashed box under the arrow asks how it is marked as task-augmented.
Setup from the stem. The dashed box is the unknown.
3. What does the taskSupport tool annotation control?Define
A tool definition card for generate_report with an annotations object that has a taskSupport field whose value is a question mark, next to a dashed box asking what the annotation controls.
Setup from the stem and the lesson. The question mark is the unknown.
4. What does a client get back from tasks/status?Define
A tasks/status request in JSON for task tsk_9f7b..., an arrow, and a dashed box for the response marked with a question mark.
Request shape from the lesson. The dashed box is the unknown.
5. A client calls tasks/result on a task that is still in the working state. What does the spec text say can happen?Define
A flow of three boxes: a task tsk_9f7b... in state working, a tasks/result request with the taskId, and a dashed box for the response marked with a question mark.
Setup from the stem. The dashed box is the unknown.
6. Why is tasks/cancel described as idempotent?Define
A client sends the same tasks/cancel request with a taskId twice to one task, and a dashed box asks why tasks/cancel is described as idempotent.
Setup from the stem. The dashed box is the unknown.
7. What does the ttl field in a task response represent?Define
A task response in JSON with the id, state working and ttl 900000 highlighted, next to a dashed box asking what ttl means.
Quoted from the lesson. The dashed box is the unknown.
8. What does notifications/tasks/updated do?Define
A JSON message with the method notifications/tasks/updated and elided params, next to a dashed box asking what it does.
Method name from the stem. The dashed box is the unknown.
9. What does the optional tasks/list method do?Define
A tasks/list request in JSON, an arrow, and a dashed box for the response marked with a question mark.
Request name from the stem. The dashed box is the unknown.
10. What is a durable store in the task design?Define
A terminal listing the directory /tmp/lesson-13-tasks, which holds one task file, then the contents of that JSON file: id, state working, progress 0.3, total_ms 3000, result null, error null, ttl_ms 900000, created_at and cancel_requested false. A dashed box asks about a durable store.
Real output from running the lesson's main.py; the task id and timestamp differ on every run. The dashed box is the unknown.
11. A generate_report tool runs for minutes and a notes_search tool answers in milliseconds. Which taskSupport values fit?Explain
Two tool cards. generate_report runs for minutes and notes_search answers in milliseconds. Each has a taskSupport field with a dashed question mark for the taskSupport value.
Setup from the stem. The dashed boxes are the unknown.
12. Which sequence of transitions is valid for a task?Explain
Five task state names in boxes with no arrows between them: working, input_required, completed, failed and cancelled. A dashed box asks which sequence of transitions is valid.
State names from the lesson, drawn without transitions. The dashed box is the unknown.
13. What does the input_required state do in the task lifecycle?Explain
A tasks/status style response in JSON with the task id and state input_required, and a dashed box asking what this state does in the lifecycle.
The state name comes from the stem. The dashed box is the unknown.
14. After a simulated crash and restart, what happens to a task that was working when the process died?Explain
A box before the crash holds a task file with a shortened id, state working and error null, and a worker thread running. A cross marks a crash and restart. A second box, after the restart, has a dashed question mark for the task's state.
Fields from a real task file written by main.py. The dashed box is the unknown.
15. What does the spec require of a server that declares task support?Explain
A box labelled a server that declares task support, next to a dashed box asking what the spec requires of it.
Setup from the stem. The dashed box is the unknown.
16. How do tasks/status and tasks/result differ?Explain
Two requests for the same task tsk_9f7b...: tasks/status and tasks/result. Each has an arrow to a dashed response box with a question mark.
Setup from the stem. The dashed boxes are the unknown.
17. After tasks/cancel is sent for a working task, why might the task not stop at once?Explain
A client lane with a tasks/cancel arrow reaching a task lane. The task bar reads state working up to the arrow, and a dashed box after it reads after the cancel arrives, with a question mark.
Setup from the stem. The dashed box is the unknown.
18. Subtasks are one of the open issues that keep Tasks experimental. What does that imply for production code?Explain
A card for Tasks in MCP, marked experimental, listing three open issues from the MCP roadmap: durable subscription primitives, subtasks with the subtasks row highlighted, and result-TTL standardization. A dashed box asks what follows for production code.
Issue list quoted from the lesson. The dashed box is the unknown.
19. A client asks for tasks/result on a completed task three hours later, and the server no longer has it. The task was created with ttl 900000. What is the most likely cause?Analyze
A time axis in hours since the task was created, from 0 to 3 h. At 0 the task is created with ttl 900000. At 3 h a tasks/result request arrives and the server no longer has the task. A dashed box asks for the most likely cause.
Setup from the stem. The dashed box is the unknown.
20. After a server restart, clients get not found for every task they started. The server keeps tasks only in an in-memory dict. Which ingredient is missing?Analyze
Two server processes side by side. Before the restart an in-memory dict holds tasks A and B. After it a fresh process has an empty dict. Below, a client asks tasks/status for task A and the server answers not found. A dashed box asks which ingredient is missing.
Setup from the stem. The dashed box is the unknown.
21. A team marks a 50 ms notes_search tool as taskSupport required. What is the consequence?Analyze
A card for a notes_search tool that takes 50 ms, marked taskSupport required, and a dashed box asking for the consequence for each call.
Setup from the stem. The dashed box is the unknown.
22. A nightly export tool takes about 20 minutes, and the host redeploys the server at unpredictable times. Which design fits best?Design
A bar for a nightly export of about 20 minutes with three dashed red lines across and after it marking possible redeploy moments, and a dashed box asking which design fits best.
Setup from the stem; the dashed lines are example moments only. The dashed box is the unknown.
23. A research tool takes ten minutes, needs a clarifying answer from the user midway, and the UI should show progress without constant polling. Which design fits?Design
Three requirement cards: runs for ten minutes, needs a clarifying answer from the user midway, and the UI should show progress without constant polling. Below, a ten minute bar for the research tool with a marker for the question to the user, and a dashed box asking which design fits.
Setup from the stem. The dashed box is the unknown.
24. You must keep tasks across restarts and also list every task started in a given session. Which store change fits?Design
A directory listing of /tmp/lesson-13-tasks holding several id.json files, the field names of the Task dataclass in main.py, and a dashed box asking how to list every task started in session X.
Setup from the stem and main.py. The dashed box is the unknown.

Use It

code/main.py implements a filesystem-backed task store and a generate_report tool that runs in a background thread. You call the tool and get a task id back immediately, poll tasks/status while the worker updates progress, and fetch tasks/result when it finishes. Cancellation works. Crash recovery is simulated by dropping a working task from the in-memory store and reloading from disk.

What to look at:

  • Task state JSON persisted to /tmp/lesson-13-tasks/<id>.json.
  • The worker thread updates the progress field, and polling shows it advancing.
  • Cancelling from the client sets a flag on the task, and the worker checks it each step and exits early.
  • Reloading state marks the in-flight task as failed with CRASH_RECOVERY.
Check: Use It0/7
1. The store rewrites a task's JSON file on every update. Why does that make crash recovery need so little extra machinery?Explain
A code excerpt from main.py showing Task.persist writing the task as a JSON file, and TaskStore.update setting each changed field and then calling persist. A dashed box asks why crash recovery then needs so little extra machinery.
Quoted from main.py; one line is wrapped. The dashed box is the unknown.
2. In main.py, tools_call is invoked for generate_report with meta=None. What happens?Apply
A Python prompt line calling tools_call with generate_report, a size argument of large and meta=None, and a dashed box asking what the call returns and when.
Call from the stem. The dashed box is the unknown.
3. The worker runs 30 steps and sleeps 0.1 seconds per step. After 15 steps have finished, what does tasks_status report?Apply
The worker loop from main.py with the progress formula masked, a grid of 30 numbered steps 0 to 29 with the first 15 marked finished, and a dashed box asking what tasks_status reports for state and progress.
Setup from the stem and main.py's loop. The dashed box is the unknown.
4. tasks_result is called on a task whose state is still working. What does main.py return?Apply
A task in state working, an arrow to a Python call tasks_result(tid), and a dashed box asking what main.py returns.
Setup from the stem. The dashed box is the unknown.
5. tasks_cancel is called on a task that is mid-run. What does the call do?Apply
A task in state working, mid-run, an arrow to a Python call tasks_cancel(tid), and a dashed box asking what the call does.
Setup from the stem. The dashed box is the unknown.
6. tasks_cancel is called on a task that is already completed. What does main.py return?Apply
A task in state completed, an arrow to a Python call tasks_cancel(tid), and a dashed box asking what main.py returns.
Setup from the stem. The dashed box is the unknown.
7. A client sends tasks/cancel, the call is accepted, yet the task still ends as completed. What is the likeliest cause?Analyze
A client sends tasks/cancel and it is accepted. The task lane shows a working bar that ends in a completed box. A dashed box asks for the likeliest cause.
Setup from the stem. The dashed box is the unknown.

Ship It

This lesson produces outputs/skill-task-store-designer.md. Give it a long-running tool (research, build, export) and it designs the task store (state shape, ttl, durability), picks the taskSupport flag, and sketches progress notifications.

Author's Note

I'd run main.py first and watch the status lines before reading the spec text again. It runs a three-second generate_report as a task, polls until it is terminal, then cancels a second one 0.4 seconds in, so you see completed and cancelled back to back in under ten seconds.

The part I'd look at longest is persist(). Every update rewrites the task's JSON file, which is why the crash demo needs no special machinery: TaskStore.crash_recover globs the directory and flips any working task to failed with CRASH_RECOVERY. It also runs when the module imports, so a stale file left in /tmp/lesson-13-tasks from an earlier run gets marked failed the next time you start the script.

I'd keep the toy in proportion, though. Cancellation is only a flag the worker checks every 0.1 seconds, which works because the worker is a loop of short sleeps. A real tool blocked inside a long call would need a way to interrupt it. tasks_result also returns an error dict where the spec text says 404, and nothing in the code exercises input_required, tasks/list, or notifications. I'd treat those as the next things to add.

Exercises

  1. Run code/main.py. Kick off a generate_report task, poll status, then fetch the result.

  2. Add a tasks/cancel call mid-run. Verify the worker honors it and the state becomes cancelled.

  3. Simulate crash recovery: create a task, drop it from the in-memory STORE.tasks while it is still working, reload the store from disk, and observe the CRASH_RECOVERY failure mode.

  4. Extend the store to SQLite. Durability wins are the same; query options open up (list all tasks from session X).

  5. Read the MCP roadmap post for 2026. Identify the one Tasks-related open issue most likely to affect SDK API design in the next year.

Key Terms

TermWhat people sayWhat it actually means
Task"Long-running tool call"Request augmented with _meta.task for async execution
SEP-1686"Tasks spec"Spec Evolution Proposal that added Tasks in 2025-11-25
_meta.task"Task envelope"Per-request metadata containing id, state, ttl
taskSupport"Tool flag"forbidden / optional / required per tool
tasks/status"Poll method"Fetch current state and optional progress hint
tasks/result"Fetch result"Returns the completed payload or 404 if not yet done
tasks/cancel"Stop it"Idempotent cancellation request
ttl"Retention budget"Milliseconds the server promises to keep the task state
notifications/tasks/updated"State push"Server-initiated state-change event
Durable store"Crash-safe state"Filesystem / SQLite / Redis persistence layer

Further Reading

Code

Ships With