Async Work & Advanced UX: Progress, Cancellation, Tasks
Module 7 — Async Work & Advanced UX: Progress, Cancellation, Tasks
Phase 2 · The Primitives & Building (Phase 2 capstone). Real tools aren’t instant — they run reports, call slow APIs, kick off jobs that take minutes or hours. A request/response that blocks for ten minutes is a broken experience and a fragile connection. This module covers the three mechanisms MCP gives you for work that takes time: progress notifications, cancellation, and the headline 2025-11-25 feature — the Tasks primitive (“call now, fetch later”). Tasks are central to robust agentic systems, so this is also a forward-looking module: expect it to mature in 2026-07-28.
By the end you can:
- Add progress reporting and cancellation to a long-running tool.
- Explain the Tasks primitive: the state machine, the handle, and the polling model.
- Decide when a capability should be synchronous, progress-streamed, or a Task.
- Reason about the durability/scaling implications of long-running work (a bridge to Phase 4).
1. The problem with long operations
A naïve tool just does its work and returns. Fine for word_count. Disastrous for generate_quarterly_report that takes 8 minutes:
- the user sees a frozen spinner with no signal of life,
- there’s no way to cancel a job started by mistake,
- on a remote (Streamable HTTP) connection, a single very long-held response is fragile — networks, proxies, and load balancers time out, and a dropped connection loses the whole result,
- nothing survives a client disconnect/reconnect.
MCP addresses these at three escalating levels: keep me informed (progress), let me stop it (cancellation), and let it outlive this request (Tasks).
2. Progress notifications — “still working”
For operations that take a while but still return within one logical request, the server streams progress notifications so the host can show a live indicator.
- The client opts in by attaching a
progressTokento the request (in the request’s_meta). - The server then emits
notifications/progress(a Module-2 notification — noid, no reply) referencing that token, withprogress, optionaltotal, and an optional human-readablemessage.
// server → client, repeatedly, during a long tools/call
{ "jsonrpc": "2.0", "method": "notifications/progress",
"params": { "progressToken": "rep-42", "progress": 3, "total": 8,
"message": "Aggregating region 3 of 8…" } }
Rules that matter: progress is advisory (don’t drive correctness off it), tokens are per-request, and the progress value should move forward. On Streamable HTTP these notifications ride the SSE stream (Module 3) — this is exactly the “stream only when there’s something to stream” case that transport was designed for.
3. Cancellation — “stop that”
Either side can cancel an in-flight request with a notifications/cancelled notification naming the requestId to abort (and an optional reason).
{ "jsonrpc": "2.0", "method": "notifications/cancelled",
"params": { "requestId": 3, "reason": "user aborted" } }
Semantics to internalize:
- It’s best-effort and racy — the operation may already have completed when the cancel arrives; both sides must tolerate the race (e.g. a result and a cancel crossing on the wire).
- A well-behaved server should stop work and free resources on cancellation.
- The
initializerequest itself must not be cancelled this way.
Cancellation is what makes destructive or expensive tools tolerable — combined with the consent gate (Module 1) and entitlements (Phase 3), the user is never trapped in a runaway operation.
4. The Tasks primitive — “call now, fetch later” (2025-11-25, experimental)
Progress and cancellation still assume one outstanding request that eventually returns. Tasks break that assumption: a request can return immediately with a task handle, and the actual result is fetched later. This turns any suitable request into an asynchronous job.
The model
- A client makes a request augmented to allow task execution; instead of blocking, the server returns a task handle (an id) right away.
- The client uses task methods to poll status and, when ready, fetch the result — “call now, fetch later.”
- The task moves through a state machine:
┌─────────┐ needs user input ┌───────────────┐
create ──▶│ working │◀──────────────────────│ input_required │
└────┬────┘──────────────────────▶└───────────────┘
│ done │ error │ aborted
▼ ▼ ▼
┌──────────┐ ┌─────────┐ ┌───────────┐
│ completed│ │ failed │ │ cancelled │
└──────────┘ └─────────┘ └───────────┘
States: working → input_required / completed / failed / cancelled (terminal states are completed/failed/cancelled). input_required is notable — it lets a long task pause to gather input (it composes naturally with elicitation from Module 5) and resume.
Why this is a big deal
- Durability across disconnects. Because the result isn’t tied to one held-open response, a client can disconnect and reconnect and still retrieve the outcome by handle. This is what makes MCP viable for genuinely long jobs (data pipelines, deep research, multi-step agent runs).
- Scalability. It decouples “request received” from “work running,” which fits horizontally-scaled, stateless-friendly deployments (recall why Streamable HTTP replaced sticky HTTP+SSE in Module 3). A different server instance can serve the status/result lookup.
- Agentic foundation. Combined with
2025-11-25sampling-with-tools (Module 5), Tasks give servers a way to run multi-step background work that reports in and asks for input — the substrate of robust agents.
“Experimental” caveat: in
2025-11-25Tasks are experimental — surface area and exact method names may shift, and the2026-07-28release candidate is expected to evolve them. Build against your SDK’s current Tasks API and treat the concepts (handle, state machine, poll/fetch, durability) as the stable knowledge; treat method signatures as version-specific.
5. Choosing the right level
Decide per capability:
| Operation profile | Mechanism |
|---|---|
Fast, deterministic (word_count, get_record) | Plain request/response |
| Seconds-to-minutes, returns in one request, user wants a progress bar | Progress notifications (+ cancellation) |
| Minutes-to-hours, must survive disconnects, may pause for input, runs in background | Tasks (+ progress/elicitation as needed) |
| Anything expensive or destructive | always wire cancellation |
A useful default: design read tools as synchronous; design heavy report/generation/agentic tools to be Task-capable from the start, because retrofitting durability is painful.
6. Milestone exercise
- Add a long-running tool to your Module 6 server — e.g.
crunch_numbers(n)that loops with asleep. Emitnotifications/progressagainst the client’sprogressToken, and honornotifications/cancelledby stopping early. Watch both in the Inspector. - Model a Task. For a hypothetical
generate_reportoperation, write out: the immediate response (a task handle), three example status responses walkingworking → input_required → completed, and how you’d fetch the final result. Describe what happens if the client disconnects afterworkingand reconnects. - Compose with Module 5. Explain how the
input_requiredstate would use elicitation to collect a missing parameter mid-task. - Decision drill. For each of these, pick request/response, progress, or Task and justify:
lookup_user,export_10GB_dataset,summarize_paragraph,run_overnight_migration.
Self-check:
- Your long tool emits forward-moving progress and actually stops on cancel (and tolerates the cancel-vs-complete race).
- Your Task walkthrough uses a handle and shows status moving through valid states to a terminal state.
- You correctly explained durability across disconnect as the core reason Tasks exist.
- Your decision drill puts
export_10GB_datasetandrun_overnight_migrationas Tasks, the two trivial ones as request/response, and you wired cancellation on the expensive ones.
That closes Phase 2 — you can design and build capable, well-behaved servers. Next is Phase 3 · Module 8 — Threat model & trust boundaries, where we map everything that can go wrong before learning the controls (auth, entitlements) that fix it.
Quick reference — Module 7 glossary
- Progress —
notifications/progresskeyed to a client-suppliedprogressToken; advisory; rides SSE on Streamable HTTP. - Cancellation —
notifications/cancelledwithrequestId; best-effort, racy; free resources; never cancelinitialize. - Tasks (2025-11-25, experimental) — “call now, fetch later”: request returns a handle; client polls status and fetches result.
- Task states —
working → input_required / completed / failed / cancelled;input_requiredcomposes with elicitation. - Why Tasks matter — durability across disconnects + scalability (stateless-friendly) + agentic background work; method surface is version-specific, concepts are stable.