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.
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:
- Hold the connection open for three minutes. Remote transports drop it, clients time out, and UIs freeze.
- Return immediately with a placeholder and make the client poll a custom endpoint. This breaks the uniformity MCP is supposed to give you.
- 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.
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:
{
"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/listis 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:
- Load all persisted task states.
- Mark any
workingtasks whose process died asfailedwith errorCRASH_RECOVERY. - Preserve
completed/failed/cancelledfor 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.
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
progressfield, 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
failedwithCRASH_RECOVERY.
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.
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
-
Run
code/main.py. Kick off agenerate_reporttask, poll status, then fetch the result. -
Add a
tasks/cancelcall mid-run. Verify the worker honors it and the state becomescancelled. -
Simulate crash recovery: create a task, drop it from the in-memory
STORE.taskswhile it is stillworking, reload the store from disk, and observe theCRASH_RECOVERYfailure mode. -
Extend the store to SQLite. Durability wins are the same; query options open up (list all tasks from session X).
-
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
| Term | What people say | What 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
- MCP GitHub SEP-1686 issue: the originating proposal and full discussion
- WorkOS: MCP async tasks for AI agent workflows: design walkthrough with rationale
- DeepWiki: MCP task system and async operations: mechanics and state machine
- FastMCP: Tasks: SDK-level task implementation patterns
- MCP blog: 2026 roadmap: open issues and 2026 priorities including subtasks
Code
- main.py (python, 195 lines)