How Does MCP Handle Work You Can’t Wait For?
Part 5 of 6: MCP's three-layer answer to work that won't finish fast — progress notifications, subscriptions, and the experimental Tasks extension
This is part 5 of a six-part series called “Building an MCP Client in React.” Part 1 laid out the three-party map — host, client, server — and the split between the legacy era and the modern era. Part 2 walked through a single Streamable HTTP exchange on the wire. Part 4 covered what actually happens once the browser calls a tool: how progress notifications arrive, and what happens when the connection gets cut mid-flight.
The running example throughout this series is an app that turns a natural-language question into SQL, queries a database, and renders the result as a table. This part is about the queries that don’t come back right away — something like “sum up the last year of orders by product,” a query that can take anywhere from a few seconds to tens of seconds. What does the user see while that runs, and what can they actually do if they want to give up partway through?
MCP’s spec answers “how do you handle waiting” with three separate layers: progress notifications, subscribing to change notifications, and the Tasks extension. This part works through all three in order, spending most of its time on the third.
Three Layers for Work You Can’t Wait For
Before going deep on any one of them, here’s the map: what each of the three layers actually does.
| Layer | Mechanism | Good fit for | Where this article stands |
|---|---|---|---|
| Layer 1: progress notifications | notifications/progress. Rides along on the response stream of the request that’s already running | Reporting “how far along am I” in a word or two | Already measured in part 4; recapped briefly here |
| Layer 2: subscribing to change notifications | subscriptions/listen. Opens a separate, long-lived stream | Continuously receiving asynchronous changes — a tool list changing, say | Not measured. Spec text only |
| Layer 3: the Tasks extension | Hands back a handle (taskId) that the client polls with tasks/get | Work too long-lived to keep a connection open for, or that you’d rather not keep one open for | The center of this article. Measured |
All three of these share one thing: they’re opt-in. Progress notifications only run once the client attaches a progressToken; subscribing only starts once the client calls subscriptions/listen; Tasks only work once both the client and the server declare support for it. None of these run silently on their own by default.
Layer 1: Progress Notifications — Riding Along on the Same Conversation
Part 4 already measured how progress notifications behave, so here’s just the recap. When a client attaches a progressToken to a request’s _meta, the server may send notifications/progress — carrying that token, the current progress value, an optional total, and an optional message — on the response stream of that very request, ahead of the final result.
The progress value MUST increase with each notification, even if the total is unknown.
progress has to increase with every notification, the rule says, even when the total is unknown. Part 4’s measurement showed five progress notifications arriving roughly 0.7 seconds apart, with the final result arriving last. A progress notification is commentary on the conversation happening right now — end the request, and the commentary ends with it.
Progress notifications MUST stop after completion.
Once the server has sent its response, no more progress notifications come. What this layer can do stops at narrating a request that’s currently in flight — it can’t carry state across separate requests, and it can’t pick back up after a connection closes. That’s what the next layer is for.
Layer 2: Subscribing to Change Notifications — Opening a Long-Lived Stream
subscriptions/listen is a different mechanism from progress notifications entirely. Rather than riding along on one request, it’s itself a request whose whole job is to open a long-lived stream.
subscriptions/listenopens a long-lived notification stream from the server to the client. Unlike one-off requests, the stream stays open and delivers notifications until the client cancels it. It replaces the formerresources/subscribeRPC and the HTTP GET endpoint.
As part 1 touched on, the 2026-07-28 revision removed the always-open GET stream from the spec. subscriptions/listen is what took its place.
The client specifies filters like toolsListChanged or resourceSubscriptions in the request body, subscribing only to the categories of notification it actually wants. The server replies first with a notifications/subscriptions/acknowledged message stating which categories it will actually deliver, then leaves the stream open. And that stream is kept strictly separate from progress notifications.
Request-scoped notifications like notifications/progress and notifications/message are not delivered on the listen stream — they flow only on the response stream of the request they relate to.
Notifications tied to a single request — things like notifications/progress and notifications/message — never show up on the listen stream; they only flow on the response stream of the request they belong to. So layers 1 and 2 both use the word “notification,” but they ride on completely different streams.
As established in parts 1 and 2, the SDK we’re testing (@modelcontextprotocol/sdk 1.30.0) only implements protocol versions up to 2025-11-25, and subscriptions/listen isn’t in it at all. Everything in this section is a description of the spec text, not something we confirmed by running it ourselves.
Layer 3: The Tasks Extension — Turning Work Into a Handle You Can Carry Away
This is where the rest of this article lives. Here’s how the official docs describe the problem Tasks is solving.
Not every tool call returns instantly. Some operations — CI pipelines, batch processing, human approvals — take seconds, minutes, or longer. MCP Tasks let servers return a durable handle instead of blocking, so clients can poll for progress, provide input when needed, and retrieve the final result after reconnecting.
Unlike layers 1 and 2, which stay connected and wait, Tasks is a mechanism for stepping away and coming back later.
Tasks is an opt-in extension, not part of the core spec.
MCP Tasks is an extension to the core MCP specification. Host support varies by client.
That’s stated directly. And in the official TypeScript SDK we tested (1.30.0), Tasks ships as a module literally named experimental/tasks, and that module’s own header comment describes it as an experimental API that may change without notice. Tasks is unfinished twice over — an extension at the spec level, and experimental at the implementation level — and that’s worth keeping in mind through everything that follows.
Everything measured from here on used Node.js v22.12.0, @modelcontextprotocol/sdk 1.30.0, run on 2026-08-06. We stood up a Streamable HTTP server (stateless) with two tools: quick, which returns immediately, and long_job, which declares execution: { taskSupport: 'required' }. It doesn’t depend on any particular product or service.
The Signal to Turn a Call Into a Task Has Two Different Shapes
tools/list already shows which tools are task-aware.
"name":"quick" "taskSupport":"forbidden"
"name":"long_job" "taskSupport":"required"
A tool with taskSupport: 'required' can’t run any other way. Sure enough, calling long_job without any task signal at all comes back as a tool failure.
event: message
data: {"result":{"content":[{"type":"text","text":"MCP error -32601: Tool long_job requires task augmentation (taskSupport: 'required')"}],"isError":true},"jsonrpc":"2.0","id":2}
So the question becomes: how do you actually signal “run this as a task”? And this is exactly where the spec’s shape and the implementation’s shape diverge. The spec (2026-07-28, the Tasks extension’s implementation guide) defines this as per-request negotiation inside _meta.
{
"jsonrpc": "2.0",
"id": 1,
"method": "...",
"params": {
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}
What the official TypeScript SDK we tested (1.30.0) actually accepts is a different shape. Attach a dedicated task field directly to params, and the call runs as a task.
{
jsonrpc: '2.0', id: 3, method: 'tools/call',
params: { name: 'long_job', arguments: { steps: 5 }, task: { ttl: 300000 } }
}
The spec’s shape — declaring support as an extension under _meta — and this SDK’s shape — a dedicated params.task field — are two different things. As established in part 1, this SDK doesn’t yet implement the _meta-based extension negotiation mechanism the spec defined at 2026-07-28. What actually worked was params.task, an implementation-specific shape that could change out from under you later. If you’re writing code today that calls Tasks against this SDK, that distinction is worth keeping straight.
The Server Needs Its Own Declaration, Too
Getting the client-side signal right isn’t enough on its own. If the server side isn’t set up for it, you get a real JSON-RPC error instead.
event: message
data: {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Server does not support task creation (required for tools/call)"}}
Clearing that required passing the following at server construction time.
new McpServer(
{ name: 'sandbox-server', version: '0.0.1' },
{
capabilities: { tasks: { requests: { tools: { call: true } }, list: true, cancel: true } },
taskStore,
taskMessageQueue: taskQueue,
},
);
capabilities.tasks declares which operations are allowed to become tasks, and taskStore supplies a place to keep task state. Miss either one, and nothing runs. The spec says a server states its Tasks support through a separate method, server/discover — but as established in part 1, server/discover itself doesn’t exist yet in this SDK. Just like the client’s params.task, the server-side declaration we measured here is an SDK-specific shape, not the spec’s.
The States a Task Can Move Through
The spec defines five possible task states.
| State | Meaning |
|---|---|
working | Processing is in progress |
input_required | The server needs input from the client before it can continue |
completed | The work finished; result holds the final output |
failed | A JSON-RPC error occurred mid-run; error holds the details |
cancelled | The work was cancelled (not guaranteed to actually take effect) |
completed, failed, and cancelled are terminal — once reached, the task’s state does not change.
completed, failed, and cancelled are terminal states — once a task reaches one, it never moves again. What we measured here was the working → completed transition and the working → cancelled transition. We didn’t build a case that produces input_required (a server asking for additional input) in this test rig.
CreateTaskResult and the Poll Interval
Call a tool as a task, and you get back a handle instead of a result.
event: message
data: {"result":{"task":{"taskId":"eec0548145956f108cd21273beabae13","status":"working","ttl":300000,"createdAt":"2026-08-06T08:41:26.796Z","lastUpdatedAt":"2026-08-06T08:41:26.796Z","pollInterval":500}},"jsonrpc":"2.0","id":3}
It carries taskId, status, ttl (an expiry), createdAt, lastUpdatedAt, and pollInterval — the server’s suggested polling interval. The Tasks extension’s implementation guide describes these fields as ttlMs and pollIntervalMs, but the response we actually measured from this SDK used ttl and pollInterval, with no trailing Ms. The spec’s vocabulary and the implementation’s vocabulary don’t line up here either. Don’t settle on a field’s name just from reading the spec — check the actual JSON that comes back.
pollInterval: 500 is the server’s suggestion: “come check back with tasks/get roughly every 500 milliseconds.” It isn’t a requirement. Nothing at the protocol level breaks if a client polls more often than that, or less. Still, as the next section shows, the interval you actually choose directly determines how fine-grained a picture you get.
Polling with tasks/get — statusMessage Is a Snapshot, Not a History
We polled tasks/get three times, 0.8 seconds apart — a little slower than the server’s own 0.6-second update interval.
data: {"result":{"taskId":"eec…13","status":"working",…,"statusMessage":"step 1/5"},"jsonrpc":"2.0","id":4}
data: {"result":{"taskId":"eec…13","status":"working",…,"statusMessage":"step 2/5"},"jsonrpc":"2.0","id":5}
data: {"result":{"taskId":"eec…13","status":"working",…,"statusMessage":"step 4/5"},"jsonrpc":"2.0","id":6}
The third poll skips right over step 3/5 and lands on step 4/5. The server updates statusMessage at all five stages, but because the polling interval (0.8s) is longer than the update interval (0.6s), one whole intermediate state disappeared. statusMessage only ever returns whatever value the server happens to be holding at the exact moment tasks/get gets called — it isn’t accumulating a history of progress and handing that back. If you want to show progress step by step in a UI, any interval that isn’t comfortably shorter than the server’s own update rate will drop steps on the floor. This is where the difference between layer 1’s progress notifications (an ongoing narration on the same stream) and layer 3’s polling (a series of point-in-time snapshots) really shows up.
statusMessage Survives Completion
We called that same tasks/get again after the task had finished.
data: {"result":{"taskId":"eec…13","status":"completed",…,"statusMessage":"step 4/5"},"jsonrpc":"2.0","id":7}
status has flipped to completed, but statusMessage is still sitting at "step 4/5", the last thing that got written to it. The server doesn’t clear statusMessage or overwrite it with some other text when the task finishes. Show this straight in a UI and it can read as “finished at 4 of 5” — a misleading impression. statusMessage describes progress, not completion itself, so switching what the UI shows once status reaches a terminal state is something the implementer has to add on their own.
Results Come from tasks/result, Not tasks/get
Even once status reads completed, the result itself isn’t in there. It comes from a separate method: tasks/result.
data: {"result":{"content":[{"type":"text","text":"finished after 5 steps"}],"_meta":{"io.modelcontextprotocol/related-task":{"taskId":"eec0548145956f108cd21273beabae13"}}},"jsonrpc":"2.0","id":8}
The payload is exactly the CallToolResult you’d have gotten back from calling the tool synchronously, with a reference to the originating task attached in _meta. The method that tells you the status (tasks/get) and the method that hands you the result (tasks/result) are separate — an easy detail to miss if you write a naive “poll and wait for the result” implementation. Calling tasks/list returns every task created so far.
data: {"result":{"tasks":[{"taskId":"eec…13","status":"completed",…}],"_meta":{}},"jsonrpc":"2.0","id":9}
Pass a taskId that doesn’t exist to tasks/get, and you get a real JSON-RPC error back, not a tool failure.
data: {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"MCP error -32602: Failed to retrieve task: Task not found"}}
tasks/cancel Only Changes the State
A client can send tasks/cancel at any time. The spec is direct about what that does and doesn’t guarantee.
Cancellation is cooperative — the server acknowledges the intent but is not obligated to stop the work.
Cancellation is cooperative, the spec says — the server takes note of the intent, but nothing obligates it to actually stop what it’s doing. We tried this out for real: created a 20-step task, then sent tasks/cancel 1.2 seconds in.
data: {"result":{"_meta":{},"taskId":"76f5…f0","status":"cancelled",…,"statusMessage":"Client cancelled task execution."},"jsonrpc":"2.0","id":11}
status flips cleanly to cancelled, and every tasks/get after that returns cancelled too. So far, this is exactly what you’d expect, and it matches the spec. The problem is what’s actually running underneath. Our test server never stopped its processing loop when it got the cancel — the loop kept going, and the next time it tried to write a new state, it threw.
[server] task 76f50aa2b84a9305a41c7beb08d0a3f0 working 1/20
[unhandledRejection] Error: Cannot update task 76f50aa2b84a9305a41c7beb08d0a3f0 from terminal status 'cancelled' to 'working'. Terminal states (completed, failed, cancelled) cannot transition to other states.
at InMemoryTaskStore.updateTaskStatus (…/experimental/tasks/stores/in-memory.js:112:19)
The task store refuses to move a task out of a terminal state (exactly as the terminal-state rule above says it should), but refusing the transition isn’t the same as stopping the work. Unless the implementer’s own code actually watches for the cancellation and bails out on it, the underlying process keeps running, throwing an exception every time it tries to write a new status. Written this naively, that exception reaches the process as an unhandled rejection.
The Answer to the Hand-Rolled Polling This Series Once Planned
An earlier plan for this series had this slot covering “write your own polling mechanism” — issuing your own task IDs, checking status at a fixed interval, and stopping once it’s done. A good chunk of what that hand-rolled version would have had to do is already answered at the spec level by the Tasks extension. A task identifier (taskId), a suggested polling interval (pollInterval), a defined set of terminal states, and a defined way to fetch the result — none of that needs to be designed from scratch anymore.
That said, as this round of testing showed, there’s still a real chunk you have to write yourself.
- Displaying
statusMessagesafely: depending on the polling interval, intermediate steps get dropped, and the last message sticks around after completion. Whoever’s building the UI needs to checkstatusbefore deciding what to show - Making cancellation actually take effect:
tasks/cancelonly tells the server about an intent — writing the code that actually stops the work is on the implementer. Skip it, and you get a stream of exceptions like the one above - Persisting the task ID: Tasks’s headline benefit — resuming polling after reconnecting — only works if the client stashed
taskIdsomewhere first. The spec defines the shape of the handle, not where you’re supposed to keep it
You no longer have to write your own polling loop from scratch, but the design decision to poll and wait at all is still squarely the implementer’s job.
The Thread Running Through All of This: Cancellation Is Cooperative
Part 4 found that closing the SSE response stream doesn’t stop a tool handler that never checks for extra.signal — it runs all the way to the end regardless. Tasks’s tasks/cancel turned out to have the exact same property here. The state flips to cancelled, but the work behind it doesn’t stop on its own.
These are two very different-looking APIs — one is closing a stream, the other is an explicit tasks/cancel call — but underneath, they come from the same place. What the spec defines is a way to communicate an *intent* to cancel, not a way to force work to *stop*. It comes down to the phrase the spec keeps repeating: cancellation is cooperative. The protocol carries the intent; whether that intent actually halts anything is up to the code behind the handler. Whether it’s extra.signal for a closed stream, or a task’s own status for Tasks, checking it mid-flight and bailing out is a step the implementer always has to take on their own — the protocol won’t do it for you.
Mapping It Back to the Running Example
Let’s put all of this back onto the opening example — the heavy query that sums up a year of orders by product. The host calls the query tool in a way that lets progress notifications through. If the server judges the work will finish in a few seconds, layer 1’s progress notifications alone are probably enough to narrate it and hand back the result.
If the server has marked that tool task-aware (taskSupport: 'required'), the host attaches params.task when it calls it, then polls tasks/get using the taskId it gets back. It uses pollInterval as a rough guide for how often to poll, and it always checks whether status has reached a terminal state before putting statusMessage straight on the screen.
If the user gets tired of waiting and hits cancel, the host sends tasks/cancel. But that’s only a request asking the server to stop — it’s not a guarantee that anything actually stops. The host’s UI has to be designed around the possibility that the server keeps working after the cancel button gets pressed.
What’s Coming Next
Here’s where the threads this part didn’t pull on get picked up.
| Left for later | Where |
|---|---|
Measuring subscriptions/listen, push delivery via notifications/tasks, and the 2026-07-28 extension-negotiation shape | All unmeasured. Out of scope for this series — something to check individually if it ever becomes necessary |
A live round trip through MRTR (InputRequiredResult, and a server’s mid-flight input request via inputRequests / requestState) | Out of scope for this series. Part 6 is about authorization design, legacy/modern compatibility, and whether it’s safe to run generated SQL as-is — not MRTR. See the spec’s input_required section of the Tasks extension and Multi Round-Trip Requests (2026-07-28) |
| Authorization design, dual-era compatibility between legacy and modern, and whether it’s safe to run generated SQL as-is | Part 6 |
Wrapping Up
MCP’s answer to “work you can’t wait for” splits into three layers: progress notifications, subscribing to change notifications, and the Tasks extension. Layer 1 narrates a request that’s in flight right now; layer 2 delivers ongoing changes over a long-lived stream; layer 3, Tasks, is built for work too long to hold a connection open for, handled through a handle and polling.
Tasks is an extension at the spec level, not core, and the SDK we measured against explicitly labels it experimental and subject to change without notice. Even the signal for turning a call into a task diverges: the spec defines negotiation under _meta, while the SDK we tested only accepts a params.task field, and field names (ttl versus ttlMs, for instance) don’t line up either. Running the implementation for real also showed that statusMessage drops intermediate steps depending on the polling interval, and keeps showing a stale message after completion. And tasks/cancel only changes the state — it’s no guarantee that the work behind it actually stops.
That’s the same property part 4 found in a stream that keeps running after you close it: cancellation is cooperative. The protocol carries intent; the responsibility for actually stopping something always sits with whoever wrote the code. Next time, in the final part of this series, we cover authorization design, compatibility between the legacy and modern eras, and whether it’s safe to run generated SQL as-is.
Articles in This Series
Read from Part 1 and the series runs as one line, from mapping the spec to code that runs in a browser. Each part also stands on its own, so jump around from here whenever you want to.
- Part 1: Where Does the MCP Client Actually Live? — host, client, and server, and the legacy-to-modern fault line
- Part 2: What Does an MCP Server Actually Listen For? — required Streamable HTTP headers, SSE, and Origin validation
- Part 3: What Does an MCP Server’s Counter Actually Hand You? — schemas, annotations, and the two ways a call fails
- Part 4: Can React Talk to an MCP Server Directly? — CORS, a BFF, reading SSE, and cooperative cancellation
- Part 5: How Does MCP Handle Work You Can’t Wait For? (this article) — progress notifications, subscriptions, and the Tasks extension
- Part 6: Whose Job Is It to Open That Door? — authorization, legacy/modern detection, and running generated SQL
Primary Sources
- Tasks Extension Overview — what Tasks is for, the task lifecycle,
CreateTaskResult,tasks/get/tasks/update/tasks/cancel, cancellation being cooperative, the extension-negotiation shape, and its status as an extension to the core spec - Progress (2026-07-28) —
progressToken, the rule thatprogressmust increase, the rule that it stops after completion - Subscriptions (2026-07-28) — the long-lived
subscriptions/listenstream, notification filters, and its replacement ofresources/subscribeand the GET endpoint - Cancellation (2026-07-28) — cancellation over Streamable HTTP, and the rule that a stream disconnect counts as intent
- Streamable HTTP (2026-07-28) — the rule that request-scoped notifications don’t flow on the subscription stream, and the removal of the GET stream and protocol-level sessions
- Multi Round-Trip Requests (2026-07-28) — the reference sent along in “What’s Coming Next” for MRTR:
InputRequiredResult, and theinputRequests/requestStatemechanism for a server’s mid-flight input request. Unmeasured in this article








