Module 07

Async Work & Advanced UX: Progress, Cancellation, Tasks

Building Systems Model Context Protocol (MCP)

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:

  1. Add progress reporting and cancellation to a long-running tool.
  2. Explain the Tasks primitive: the state machine, the handle, and the polling model.
  3. Decide when a capability should be synchronous, progress-streamed, or a Task.
  4. 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 progressToken to the request (in the request’s _meta).
  • The server then emits notifications/progress (a Module-2 notification — no id, no reply) referencing that token, with progress, optional total, and an optional human-readable message.
// 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 initialize request 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-25 sampling-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-25 Tasks are experimental — surface area and exact method names may shift, and the 2026-07-28 release 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 profileMechanism
Fast, deterministic (word_count, get_record)Plain request/response
Seconds-to-minutes, returns in one request, user wants a progress barProgress notifications (+ cancellation)
Minutes-to-hours, must survive disconnects, may pause for input, runs in backgroundTasks (+ progress/elicitation as needed)
Anything expensive or destructivealways 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

  1. Add a long-running tool to your Module 6 server — e.g. crunch_numbers(n) that loops with a sleep. Emit notifications/progress against the client’s progressToken, and honor notifications/cancelled by stopping early. Watch both in the Inspector.
  2. Model a Task. For a hypothetical generate_report operation, write out: the immediate response (a task handle), three example status responses walking working → input_required → completed, and how you’d fetch the final result. Describe what happens if the client disconnects after working and reconnects.
  3. Compose with Module 5. Explain how the input_required state would use elicitation to collect a missing parameter mid-task.
  4. 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_dataset and run_overnight_migration as 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

  • Progressnotifications/progress keyed to a client-supplied progressToken; advisory; rides SSE on Streamable HTTP.
  • Cancellationnotifications/cancelled with requestId; best-effort, racy; free resources; never cancel initialize.
  • Tasks (2025-11-25, experimental) — “call now, fetch later”: request returns a handle; client polls status and fetches result.
  • Task statesworking → input_required / completed / failed / cancelled; input_required composes with elicitation.
  • Why Tasks matter — durability across disconnects + scalability (stateless-friendly) + agentic background work; method surface is version-specific, concepts are stable.