Run long work as tasks

About 26 minutes to read, with 4 examples in 8 sections.

Edition 2026.11.5 Examples passed Nov 4, 2026 70 of 70 examples pass What changed

Written with AI. Verified by people. How we make books

Anyone can check this book or correct it. Report an error · How to fix one

Labs

Learn this chapter by doing it

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.

Open in Lab, in a new tab

Checking your hours…

Checking for your Lab…

Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.

Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.

Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.

By the end of this chapter you can negotiate a Model Context Protocol extension, run the shop's slow restock report as a task that the client polls, let tasks expire and cancel them, and say what MCP Apps add to a tool. Everything holds for the specification at revision 2026-07-28 and the Python SDK, mcp 2.3.0.

This book follows one case study throughout: a small online shop for kitchen and garden goods, which we call the shop, and whose support team answers customer tickets through a host. Meet the shop and its API introduces it. The shop's API has a slow endpoint, POST /stock/{sku}/recount, which recounts one product in both warehouses and by default takes 3 seconds to do it. In Serve over Streamable HTTP, the shop's recount_stock tool walks all 64 products through that endpoint and streams a progress report after each one, while the client keeps the HTTP response open.

The support team wants more than a count. Before the weekly order to suppliers, a lead asks the host for a restock report: every product recounted, and the ones with fewer than 30 units across both warehouses listed for reordering. At the API's default speed, 64 products take more than three minutes. A call that long must survive the host's timeout, every proxy between the host and the server, and the lead closing a laptop. Chapter 6 showed, in Cancel a call the client no longer needs, that a Streamable HTTP client cancels a call by closing its stream, so a call whose connection drops takes its work with it.

The Model Context Protocol (MCP) offers a different shape for such work. With the Tasks extension, the server answers the call at once with a handle to a task, the client asks for the task's status as often as the server suggests, and the result waits on the server until the client fetches it. Tasks is an extension: an optional addition to the protocol that both sides must agree to use. This chapter negotiates one, runs the restock report as a task, and ends with when not to.

The chapter's examples serve the shop's MCP server over Streamable HTTP on loopback, as chapter 6's do, with the shop's API beside it. Setup that the examples share lives in examples/lib/, and two modules there are new. lib.tasks is the book's own small Tasks extension, which Negotiate an extension introduces. lib.restock is the restock report's work: it recounts every product through chapter 6's lib.recount, sets the task's status message after each one, and lists the products to reorder.

Negotiate an extension

The core protocol is what every client and server must implement. An extension is what the protocol's maintainers, or anyone else, want to standardize without making it mandatory. Extensions became part of the protocol at revision 2026-07-28, when ClientCapabilities and ServerCapabilities gained an extensions field (changelog). The field maps each extension's identifier to an object of settings, and an empty object means "supported, with no settings". An identifier is a vendor prefix, a slash and a name. Official extensions use the prefix io.modelcontextprotocol, and anyone else uses a reversed domain they own (Extensions overview).

At the pinned revision, a client declares its extensions in every request, under _meta["io.modelcontextprotocol/clientCapabilities"].extensions, the same place it declares every other capability (Carry the version and capabilities on every request). A server lists its own in the result of server/discover (Extensions overview). Extensions are off by default on both sides and need an explicit opt-in. When only one side supports an extension, the specification says the side that supports it "MUST either revert to core protocol behavior or reject the request with an appropriate error" (versioning). An extension is negotiated, then, not forked: a server that uses one still answers clients that don't.

Extensions also have a lifecycle. A policy adopted in September 2026, after the pinned revision, gives each one a stage, Experimental, Beta or Stable, or a path into the core protocol. Tasks is at "Beta, to Core", and MCP Apps, which Know what MCP Apps add covers, is Stable (Extensions overview; SEP-3392). A Beta extension may still change in ways that break clients.

The Tasks extension's identifier is io.modelcontextprotocol/tasks, and it defines no settings (SEP-2663). The Python SDK, mcp 2.3.0, ships the framework for writing extensions, but not the Tasks extension itself. On the server, an extension subclasses mcp.server.extension.Extension, sets identifier, and can add tools, resources and new request methods, or wrap tools/call. On the client, a ClientExtension declares the identifier and can claim result shapes that the core protocol doesn't have (mcp/server/extension.py, mcp/client/extension.py). The SDK has no tasks/get handler and no types for the extension's handle; the task types it does define belong to the experimental tasks of revision 2025-11-25 (mcp_types). The extension's only official SDK package, @modelcontextprotocol/ext-tasks, is TypeScript, and for the pinned revision it covers only the requester, the client side; its receiver support is for the 2025-11-25 tasks. No official package implements the extension's server side at the pinned revision (ext-tasks).

So this book builds a small one. lib.tasks implements, on the SDK's framework, the part of the extension's specification that this chapter teaches: a Tasks server extension, a TasksClient client extension, and the methods tasks/get and tasks/cancel. It leaves out tasks/update and pushed notifications, and it keeps tasks in one process's memory. Use it to learn the protocol, and replace it with the SDK's own when a release ships one.

The shop's server passes tasks.Tasks({"restock_report"}) to MCPServer(extensions=[...]), which tells it to answer every call to restock_report with a task. The tool itself calls lib.restock. The example serves it, connects one client with TasksClient and one without, and reads the first client's capabilities from the wire. The client without the extension also sends one tasks/get.

declare-the-tasks-extension.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""The shop's server declares the Tasks extension; a client that declares it too is served, one that doesn't is refused."""import asyncioimport jsonimport osfrom mcp import Clientfrom mcp.server.mcpserver import MCPServerfrom mcp.shared.exceptions import MCPErrorfrom lib import loopback, restock, shop_api, tasks, wireserver = MCPServer("shop-support", version="0.7.0", extensions=[tasks.Tasks({"restock_report"})])@server.tool(title="Restock report")async def restock_report(below: int = 30) -> restock.RestockReport:    """Recount every product's stock and list those with fewer than `below` units. Slow: runs as a task."""    return await restock.run(below)async def main() -> None:    app = wire.record_http(server.streamable_http_app(), log="declare.jsonl")    with loopback.serve(shop_api.create_app()) as api_url, loopback.serve(app) as base_url:        os.environ.update(shop_api.server_env(api_url))        async with Client(base_url + "/mcp", extensions=[tasks.TasksClient()]) as client:            print("protocol revision:", client.protocol_version)            print("server declares:", json.dumps(client.server_capabilities.extensions))            await client.list_tools()        async with Client(base_url + "/mcp") as plain:            try:                await plain.call_tool("restock_report")            except MCPError as error:                print("client without it:", error.error.code, error.error.message)                print("required:", json.dumps(error.error.data["requiredCapabilities"]))            try:                await tasks.get(plain.session, "any-task-id")       # the task methods check the declaration too            except MCPError as error:                print("tasks/get without it:", error.error.code)    sent = next(e["request"] for e in wire.read_http_trace("declare.jsonl") if e["request"]["method"] == "tools/list")    print("client declares:", json.dumps(sent["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]))asyncio.run(main())
Outputpython declare-the-tasks-extension.py
protocol revision: 2026-07-28
server declares: {"io.modelcontextprotocol/tasks": {}}
client without it: -32021 Client did not declare required extension 'io.modelcontextprotocol/tasks'
required: {"extensions": {"io.modelcontextprotocol/tasks": {}}}
tasks/get without it: -32021
client declares: {"extensions": {"io.modelcontextprotocol/tasks": {}}}

The server declared {"io.modelcontextprotocol/tasks": {}}, and the client with the extension sent {"extensions": {"io.modelcontextprotocol/tasks": {}}} in the _meta of its tools/list request. Both are the identifier with an empty settings object. The client without the extension called restock_report and got JSON-RPC error -32021 Client did not declare required extension 'io.modelcontextprotocol/tasks'. The error's data lists what that client lacked: {"extensions": {"io.modelcontextprotocol/tasks": {}}}. This is the specification's Missing Required Client Capability error, and the SDK's require_client_extension() raises it with the requiredCapabilities field filled in (mcp/server/mcpserver/server.py). The task methods check too: the same client's tasks/get got error -32021, because servers "MUST" return it for tasks/get, tasks/update and tasks/cancel from clients that didn't declare the extension (SEP-2663). The SDK doesn't make that check for an extension's methods, so lib.tasks calls require_client_extension() in each of them.

The shop rejects the call rather than falling back, because the fallback, a plain three-minute call, is the problem this chapter started from. A tool whose long work is sometimes short could fall back instead, and answer a client without the extension the ordinary way.

Working rule: Turn an extension on only on a side that implements it, through the SDK's extension classes, never by writing the identifier into capabilities by hand. On the server, decide for each tool what a client without the extension gets: an ordinary result when the work can finish within a normal call, and error -32021 when it can't. Check the client's declaration on every request, because at the pinned revision each request declares its own.

Start a task instead of waiting

The client in Negotiate an extension declared that it can handle a task, and declaring is all it does. At the pinned revision the server alone decides, call by call, whether to answer with a task, and a client that declared the extension "MUST be prepared to handle either CallToolResult or CreateTaskResult" (SEP-2663). The same tool can answer one call at once and the next with a task, and nothing in its definition says which. In the experimental tasks of revision 2025-11-25, the client asked for a task with a task parameter on the request. That design is gone, and it is "not wire-compatible" with the extension.

Three rules bound what the server may do. It "MUST NOT" return a task to a client that didn't declare the extension on that request, "regardless of prior declarations". Only tools/call can be answered with a task in this revision. And the server "MUST NOT" return the handle "until the task is durably created", so that a tasks/get for it would already find it (SEP-2663).

The handle is a CreateTaskResult: a result whose resultType is "task" instead of "complete", carrying the task's taskId, its status, an optional statusMessage, the timestamps createdAt and lastUpdatedAt, its lifetime in ttlMs, and a suggested pollIntervalMs (Tasks schema). The specification lets extensions add resultType values, and a client "MUST" treat a value it doesn't recognize as invalid (overview). That rule is why a server never sends a task to a client that hasn't declared the extension.

For the shop, lib.tasks puts all of this in Tasks.intercept_tool_call, the SDK's one hook around tools/call. For a call to restock_report, it checks the request's declaration, creates a task record with an ID from secrets.token_urlsafe(24), and stores it. Then it starts the tool's ordinary handler in the background and returns the handle. Because the background work runs the same handler a plain call would, the task's result is exactly the CallToolResult a plain call returns. Timestamps come from tasks.Clock, a fake clock that starts on the shop's reference date, 30 June 2026, and moves only when an example moves it, so the output is the same on every run.

The example loads the server from the previous example and calls restock_report through client.session.call_tool(..., allow_claimed=True), which hands back the handle itself instead of finishing it. Then it sends one tasks/get at once.

start-a-restock-report.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Call restock_report and get a task handle back at once; the task is already stored and working."""import asyncioimport osfrom mcp import Clientfrom lib import http_mcp, loopback, shop_api, tasks, wireserver = http_mcp.load_server("declare-the-tasks-extension.py")     # the shop's server with the Tasks extensionasync def main() -> None:    app = wire.record_http(server.streamable_http_app(), log="start.jsonl")    with loopback.serve(shop_api.create_app()) as api_url, loopback.serve(app) as base_url:        os.environ.update(shop_api.server_env(api_url))        async with Client(base_url + "/mcp", extensions=[tasks.TasksClient()]) as client:            handle = await client.session.call_tool("restock_report", {"below": 30}, allow_claimed=True)            task = await tasks.get(client.session, handle.task_id)    print("result type:", handle.result_type)    print("handle fields:", ", ".join(sorted(handle.model_dump(by_alias=True, exclude_none=True))))    print("status:", handle.status)    print("created at:", handle.created_at)    print("ttl ms:", handle.ttl_ms)    print("poll interval ms:", handle.poll_interval_ms)    print("task id length:", len(handle.task_id))    print("tasks/get straight after:", task["status"])    exchanges = wire.read_http_trace("start.jsonl")    get = next(e for e in exchanges if e["request"]["method"] == "tasks/get")    print("Mcp-Name is the task id:", get["request_headers"].get("mcp-name") == handle.task_id)asyncio.run(main())
Outputpython start-a-restock-report.py
result type: task
handle fields: _meta, createdAt, lastUpdatedAt, pollIntervalMs, resultType, status, taskId, ttlMs
status: working
created at: 2026-06-30T09:00:00Z
ttl ms: 3600000
poll interval ms: 100
task id length: 32
tasks/get straight after: working
Mcp-Name is the task id: True

The call returned at once with result type task and status working. The handle's fields were _meta, createdAt, lastUpdatedAt, pollIntervalMs, resultType, status, taskId, ttlMs. The _meta is the SDK's serverInfo stamp, which it adds to every result at the pinned revision (mcp/server/runner.py). The task was created at 2026-06-30T09:00:00Z on the fake clock, with a ttlMs of 3600000, one hour, and a pollIntervalMs of 100. The tasks/get sent straight after found the task working, because the server stored it before the handle left.

The task ID was 32 characters long, which encodes 192 random bits. The specification says task IDs may act as bearer handles, and servers "MUST generate them with sufficient entropy that a third party cannot enumerate or guess them" (SEP-2663). Over Streamable HTTP, the client "MUST" also copy the ID into the Mcp-Name header of tasks/get, tasks/update and tasks/cancel, so that a load balancer can route every request for one task the same way. The check that the header matched the ID printed True: lib.tasks sets name_param = "taskId" on its request classes, and the SDK's client fills the header from it (mcp_types/_types.py).

Working rule: Answer with a task when the work can outlive the connection, and decide per call, not per tool, when some calls are short. Store the task before you return the handle, and generate its ID from a cryptographic random source, with at least 128 bits. Run the same code a plain call runs, so a task's result never differs from a plain result. Never return a task to a request that didn't declare the extension.

Poll a task and fetch its result

The handle from Start a task instead of waiting said the restock report was working. A task has five statuses: working, input_required, completed, failed and cancelled. The last three are terminal: "once reached, the task's state does not change" (Tasks overview). failed means a JSON-RPC error. A tool that ran and returned a result with isError: true ends completed, with that result inside, and the specification says failed "MUST NOT" be used for it (SEP-2663). It is the same split chapter 3 drew between tool errors and protocol errors (Report failures as tool results).

The client finds out where a task stands by polling. It sends tasks/get with the taskId, and "SHOULD" wait the server's pollIntervalMs between polls, a value that may change from one reply to the next. Servers "MAY" rate-limit clients that poll faster. The reply is the task, with resultType "complete". When the task has completed, the reply carries result, whose structure "matches the result type of the original request" (SEP-2663; Tasks schema). For a tool, that is a CallToolResult. Clients "SHOULD" store task IDs durably, so that polling can resume after a crash or a restart.

A server may also push status changes. The client subscribes with subscriptions/listen for the task IDs it holds, and the server sends notifications/tasks, each with the full task. Polling stays the default (Tasks overview), and lib.tasks implements only polling. A task that needs the user's input moves to input_required and carries input requests shaped like a Multi Round-Trip Request's (Return an input request instead of a result), which the client answers with tasks/update. lib.tasks doesn't implement that either, and the restock report never asks.

In the SDK, polling belongs in a client extension. TasksClient claims the result type "task" with a ResultClaim, whose resolver gets the handle and a session for follow-up requests, and must return the call's ordinary CallToolResult (mcp/client/extension.py). The resolver waits pollIntervalMs, sends tasks/get, and repeats until the task is terminal. Client.call_tool then returns the result as if the call had been plain. A client without a claim for "task" can't parse the handle: the SDK validates it as a CallToolResult and raises pydantic's ValidationError. UnexpectedClaimedResult, which carries the handle, is for a client that has the claim but calls session.call_tool without allow_claimed=True (mcp/client/session.py).

For the shop, this is the call the host makes when a lead asks for the restock report. The example connects with TasksClient, passing on_poll= to keep every tasks/get reply, and calls restock_report with a threshold of 30 units.

poll-the-restock-report.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Call restock_report through a client that polls the task, and get the report as an ordinary tool result."""import asyncioimport osfrom mcp import Clientfrom lib import http_mcp, loopback, shop_api, tasksserver = http_mcp.load_server("declare-the-tasks-extension.py")     # the shop's server with the Tasks extensionasync def main() -> None:    polls = []    with loopback.serve(shop_api.create_app()) as api_url, loopback.serve(server.streamable_http_app()) as base_url:        os.environ.update(shop_api.server_env(api_url))        async with Client(base_url + "/mcp", extensions=[tasks.TasksClient(on_poll=polls.append)]) as client:            result = await client.call_tool("restock_report", {"below": 30})    statuses = [p["status"] for p in polls]    print("statuses:", " then ".join(dict.fromkeys(statuses)))    print("more than one working poll:", statuses.count("working") > 1)    counted = [int(p["statusMessage"].split()[1]) for p in polls if "statusMessage" in p]    print("messages count up:", counted == sorted(counted))    print("last message:", polls[-1]["statusMessage"])    print("result type:", type(result).__name__)    report = result.structured_content    print("skus recounted:", report["skus"])    print("to reorder:", len(report["reorder"]))    print("first to reorder:", "{sku}, {units} units".format(**report["reorder"][0]))asyncio.run(main())
Outputpython poll-the-restock-report.py
statuses: working then completed
more than one working poll: True
messages count up: True
last message: recounted 64 of 64 SKUs
result type: CallToolResult
skus recounted: 64
to reorder: 5
first to reorder: KG-1018, 2 units

The polls saw the statuses working then completed. The check that there was more than one working poll printed True, and the check that the status messages only counted up printed True. The last message read "recounted 64 of 64 SKUs". The number of polls changes from run to run with the machine's speed, so the example prints none.

What came back from Client.call_tool was a CallToolResult. The report recounted 64 products and listed 5 to reorder, the first being KG-1018, 2 units. The code that made the call never saw a task. That is the point of the claim: a host written against plain tool calls, such as the one in Build a host that works through tickets, gets tasks by adding one extension to its client.

Working rule: Poll at the server's pollIntervalMs, re-read it on every reply, and stop at the first terminal status. Treat completed as the original call's result, isError and all, and failed as a protocol error. Hide the polling in a client extension so the rest of the host sees plain calls. When a host must survive a restart, store each task ID before the first poll, and resume from it.

Expire and cancel tasks

The restock report in Poll a task and fetch its result finished and was fetched. Not every task is. A client may crash and never come back, or a lead may close a report they no longer need, and a server that keeps every task forever runs out of storage. The extension gives a task two ways to end early: its lifetime runs out, or the client cancels it.

A task's lifetime is its ttlMs, counted from createdAt, or null for no limit, and the server may change it. After it passes, servers "MAY mark a task as failed at any point after the TTL elapses, and subsequently delete it at any time". A server that has purged a task must answer a tasks/get for it with error -32602, Invalid Params, and may say the task can't be found rather than that it expired (SEP-2663). The client can't count on a result outliving the TTL, however the task went.

Cancelling a task uses tasks/cancel with the taskId, and the specification says notifications/cancelled "MUST NOT" be used for it. The reply is an empty acknowledgement, and it promises nothing more: "A server is not obligated to actually stop the work; it is only obligated to acknowledge the request." The task may stay working for a while, and it may still end completed or failed. Once it has sent the cancel, the client may forget the task (SEP-2663). This is cooperative cancellation, unlike chapter 6's, where closing the stream cancelled the call's work in the server (Cancel a call the client no longer needs).

lib.tasks makes both concrete. On a tasks/get after the TTL, it deletes the task, stops its work, and answers -32602 with the message the specification's example uses. On tasks/cancel, it sets a flag and acknowledges. The tool's work reads the flag through tasks.cancel_requested(), and lib.restock checks it after each product, so the restock report stops at the end of the product it is recounting, and the task becomes cancelled then.

For the shop, the example gives the server a ttlMs of one minute. So that its output never depends on the machine's speed, it holds each report before every product with restock.Steps, a gate in lib.restock that lets one more product start each time the example calls allow(). It starts one report, which it never lets start a product, polls it, moves the fake clock on 61 seconds, and polls again. Then it starts a second report, lets it recount one product, waits for its first status message, and cancels it. It polls at once, lets the report start its next product, and polls until the task is terminal.

expire-a-task.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""One task outlives its ttlMs and is purged; another is cancelled, and stops at the tool's next check."""import asyncioimport osfrom mcp import Clientfrom mcp.server.mcpserver import MCPServerfrom mcp.shared.exceptions import MCPErrorfrom lib import loopback, restock, shop_api, tasksclock = tasks.Clock()                                     # fake time: moves only when the example says sofirst_steps, second_steps = restock.Steps(), restock.Steps()   # a report starts a product only when allowedpending = [first_steps, second_steps]                     # one per call, in call orderserver = MCPServer("shop-support", version="0.7.0",                   extensions=[tasks.Tasks({"restock_report"}, ttl_ms=60_000, clock=clock)])@server.tool(title="Restock report")async def restock_report(below: int = 30) -> restock.RestockReport:    """Recount every product's stock and list those with fewer than `below` units. Slow: runs as a task."""    return await restock.run(below, steps=pending.pop(0))async def main() -> None:    with loopback.serve(shop_api.create_app()) as api_url, loopback.serve(server.streamable_http_app()) as base_url:        os.environ.update(shop_api.server_env(api_url))        async with Client(base_url + "/mcp", extensions=[tasks.TasksClient()]) as client:            first = await client.session.call_tool("restock_report", allow_claimed=True)            print("first, ttl ms:", first.ttl_ms)         # first_steps allows no product, so it stays working            print("first, first poll:", (await tasks.get(client.session, first.task_id))["status"])            clock.advance(61)            try:                await tasks.get(client.session, first.task_id)            except MCPError as error:                print("first, 61 s later:", error.error.code, error.error.message)            second = await client.session.call_tool("restock_report", allow_claimed=True)            second_steps.allow()                          # product 1 only: the report then waits before product 2            task = await tasks.poll_until(client.session, second.task_id, lambda t: "statusMessage" in t)            print("second, before cancel:", task["status"] + ",", task["statusMessage"])            ack = await tasks.cancel(client.session, second.task_id)            print("cancel acknowledged, besides _meta:", ", ".join(f"{k} {v}" for k, v in ack.items() if k != "_meta"))            print("second, right after:", (await tasks.get(client.session, second.task_id))["status"])            second_steps.allow()                          # product 2 starts; the check after it sees the cancel            task = await tasks.poll_until(client.session, second.task_id, lambda t: t["status"] in tasks.TERMINAL)            print("second, finally:", task["status"] + ",", task["statusMessage"])asyncio.run(main())
Outputpython expire-a-task.py
first, ttl ms: 60000
first, first poll: working
first, 61 s later: -32602 Failed to retrieve task: Task has expired
second, before cancel: working, recounted 1 of 64 SKUs
cancel acknowledged, besides _meta: resultType complete
second, right after: working
second, finally: cancelled, stopped after 2 of 64 SKUs

The first task had a ttlMs of 60000 and was working on its first poll. After 61 seconds on the fake clock, tasks/get answered -32602 Failed to retrieve task: Task has expired. Its work went with it, and so would its result have, had it finished.

The second task was working, recounted 1 of 64 SKUs when the client cancelled it. Besides the SDK's _meta stamp, the acknowledgement held only resultType complete: an empty result, apart from the field every result carries. A poll straight after found the task still working, because the cancel only set a flag and the report hadn't reached its next check. In the shop, that is the rest of a product's recount; in the example, the report was held before its second product. The last poll found cancelled, stopped after 2 of 64 SKUs. The report recounted one more product after the cancel, then stopped, as the specification allows.

Working rule: Give every task a TTL that covers the longest wait you expect between the work finishing and the client fetching it, and purge after it; for the shop's restock report, that is an hour. Make long work check for cancellation at each natural step, such as each product, and clean up there. In the client, treat a cancel's acknowledgement as "asked", not "stopped", and treat -32602 from tasks/get as a task that is gone for good.

Know what MCP Apps add

Tasks change when a tool's result arrives, as Start a task instead of waiting showed. Another official extension, MCP Apps, changes how a result is shown. Its identifier is io.modelcontextprotocol/ui. A tool names a user interface in _meta.ui.resourceUri: a ui:// resource, served with the MIME type text/html;profile=mcp-app. A host that supports Apps renders that HTML in a sandboxed iframe, and talks to it only through postMessage, in JSON-RPC: MCP's own methods, such as tools/call, plus methods that start with ui/. Through the host, the app can call the server's tools (MCP Apps; Apps specification).

A tool's _meta.ui.visibility says who may call it, ["model", "app"] by default. A tool for the app alone is hidden from the model, and the specification puts both directions on the host: it "MUST NOT" include such a tool in the model's tool list, and it "MUST" reject an app's call to a tool whose visibility leaves out "app" (Apps specification). A UI-enabled tool should still return meaningful text for clients that don't support the extension (Extensions overview).

The Apps specification is dated 2026-01-26 and marked Stable, and its negotiation examples use the 2025 initialize handshake. At the pinned revision a client declares Apps per request, like any extension (Negotiate an extension). Unlike Tasks, Apps ships in the Python SDK, in mcp.server.apps. Apps() is the extension, @apps.tool(resource_uri=...) declares a UI-enabled tool, apps.add_html_resource() serves the HTML, and client_supports_apps(ctx) says whether this request's client declared Apps with the right MIME type (mcp/server/apps.py).

For the shop, the restock report is a candidate. As an app, it could show the products to reorder as a table, with a button beside each that adds the product to the supplier order. The button would call a tool such as add_to_supplier_order with a visibility of ["app"], so that the model never places orders on its own. The report would keep its text and structured content, so a host without Apps would read the same report as in Poll a task and fetch its result. The book doesn't build this view: the book's host loop in chapter 10 has no screen, and an Apps view needs one to render it.

MCP Apps and elicitation do different jobs. Elicitation is the server asking the user one question in the middle of a call, through the client's own form (Ask for structured input with elicitation). An app is a view of a tool's result, which the server writes and the host renders.

Working rule: Reach for MCP Apps when a result needs more than text to act on, such as a table to sort or a choice per row, and the hosts your users run support the extension. Keep every UI-enabled tool's text result complete, so a host without Apps loses only the view. Give app-only tools a visibility of ["app"] when the model shouldn't call them, and remember that the host enforces it, not your server.

Choose a task, progress or a plain call

The shop now runs work three ways: a plain call, such as get_order_status; a call that streams progress, chapter 6's recount_stock; and a task, the restock report from Poll a task and fetch its result. They differ in what the connection must survive and in what the caller sees while it waits.

The difference that settles most choices is progress. The specification says notifications/progress and notifications/message "are not supported on tasks in general in this specification" (SEP-2663). A task reports how far it has got only through its statusMessage, which the client reads when it polls. The restock report's "recounted 12 of 64 SKUs" is that kind of message: a string, seen only as often as the client polls, with no number a progress bar can use. Log messages are no way around it (Replace sampling, roots and logging).

The other difference is who must support what. A plain call and progress are core protocol. A task needs both sides to implement an extension, and host support for extensions varies (Extension support matrix). The Tasks overview gives the cases a task is for: work that outlives a connection, that must survive a client restart, or that already runs as a job in a queue (Tasks overview).

The table that follows is our rule of thumb, not a measurement. It puts the three ways in its columns and what each needs in its rows.

Plain call Progress Task
Typical length seconds up to a few minutes minutes or more
Connection must stay open yes yes no
Caller sees while waiting nothing progress and total a status message
Survives a client restart no no yes, if the client stored the ID
Needs an extension no no yes, on both sides

For the shop, get_order_status stays a plain call, because it answers in milliseconds. recount_stock keeps chapter 6's progress, because a lead who starts it watches a count rise and waits. The restock report becomes a task, because it runs for minutes at the API's real speed, the lead wants it later rather than watched, and a dropped connection shouldn't cost three minutes of recounting.

Working rule: Use a plain call when the result comes back well within every timeout between the host and the server. Use progress when the work is long but the caller waits for it on a connection you trust to stay open, and wants a number to show. Use a task when the work can outlive the connection, or when the caller wants to leave and come back. Accept that a task reports only a status message, and keep a plain-call fallback or a clear -32021 for clients without the extension.

What goes wrong in production

The restock report in this chapter ran in one process, with tasks in memory and a fake clock. Spread across replicas, run for days and called by many users, it can fail in the ways below, each with a sign that shows it happening.

Tasks are stored on one replica. lib.tasks keeps tasks in a dictionary. Behind chapter 6's two replicas (Run replicas without sticky sessions), a tasks/get that reaches the other replica finds nothing and answers -32602. Store tasks where every replica can read them, such as a database or a job queue's own store, or route on the Mcp-Name header, which carries the task ID on every task request. You'll notice it as tasks that "expire" within seconds, at about the rate your load balancer switches replicas.

Task IDs leak another caller's result. A task ID can be the only thing between a caller and a stored result. The specification says servers "MUST perform authentication and authorization checks on each task-related request", and it removed tasks/list, which, scoped badly, could show one caller another's task IDs (SEP-2663). Record the caller with the task, from the token's subject once Authorize clients with OAuth is in place, and refuse every tasks/get and tasks/cancel from anyone else. An input request that arrives through a task gets the same trust as one in a direct call, and no more: "A task is not a higher-trust channel."

The SDK doesn't check the declaration for you. In mcp 2.3.0, the server runner passes an extension's resultType through unchecked, with a note in the source that rejecting it for clients that didn't declare the extension is still to do (mcp/server/runner.py). The extension must call require_client_extension() itself, on the call and on each of its own methods, as lib.tasks does. A client that didn't declare Tasks and gets a task anyway must treat the result as invalid; the SDK's client does, because without a claim for "task" it validates the handle as a CallToolResult and raises a ValidationError.

Polling costs more than the work. Thousands of clients each polling every 100 milliseconds make a load of their own. Raise pollIntervalMs as a task runs longer, rate-limit clients that ignore it, as the specification allows, and offer notifications/tasks once your server can push. The symptom is tasks/get outnumbering every other method in your traces.

Tasks pile up. A TTL of null, or one longer than any client waits, keeps every abandoned task's result. Set a TTL, purge on a schedule as well as on read, and alert on the number of stored tasks. Clients that lose task IDs on restart make the pile grow faster, so store IDs on the client before the first poll.

Cancelled work keeps running. A tool that never checks for cancellation runs to the end after every tasks/cancel, and the shop's API recounts products nobody wants. Check at each step, and log how long tasks run after a cancel.

Clients speak the 2025-11-25 tasks. That revision's experimental tasks used tasks/result, tasks/list and a task parameter on the request, and they are not wire-compatible with the extension (SEP-2663). A 2025-era client never declares io.modelcontextprotocol/tasks, so it meets the server's -32021. Migrate a 2025-era server covers the rest of the old protocol.

The extension changes under you. Tasks is Beta, on its way into the core protocol, and the SDK may ship its own Tasks extension in a later release. Either is an update trigger for this chapter. When the SDK ships one, replace lib.tasks with it and keep the shop's tool as it is: the tool never knew it ran as a task.

Working rule: Before tasks go to production, move their storage to where every replica can read it, record each task's owner and check it on every task request, and set a TTL with a purge that runs on a schedule. Then watch -32602 from tasks/get by replica, the ratio of tasks/get to tools/call, the number of stored tasks, and how long work runs after a cancel.

Decisions this chapter made

  • The shop's restock report runs as a task, through the Tasks extension, because its work outlives a connection.
  • The shop's server refuses restock_report with error -32021 for clients without the extension, instead of running a three-minute plain call.
  • The book builds its own small Tasks extension, lib.tasks, on the SDK's extension framework, because mcp 2.3.0 ships none. It implements tasks/get and tasks/cancel, and leaves out tasks/update and pushed notifications.
  • A task runs the tool's ordinary handler, so its result is the same CallToolResult a plain call returns.
  • Task IDs carry 192 random bits, and every task request carries its ID in the Mcp-Name header.
  • The client polls at the server's pollIntervalMs inside a client extension, so the host sees plain tool calls.
  • Tasks expire one hour after they are created, and cancelling is cooperative: the restock report checks after each product.
  • recount_stock keeps chapter 6's progress, and get_order_status stays a plain call.
  • MCP Apps are described, not built, because the book's host loop renders no HTML. A restock view would keep the report's text, and hide its ordering tool from the model with a visibility of ["app"].
  • In production, tasks move to storage that both of chapter 6's replicas share, and every task request is checked against the caller who started the task.

This chapter, in this edition

Edition 2026.11.5Examples passed Nov 4, 20264 of 4 examples in this chapter pass

Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.

Cite this section

Format

Every format names this edition. Its link opens the current edition, where a section’s heading says when its text last changed. Accessed and retrieved dates are the day you open this.

On this page

Chapter 7 of 12: Run long work as tasks

  1. Negotiate an extension
  2. Start a task instead of waiting
  3. Poll a task and fetch its result
  4. Expire and cancel tasks
  5. Know what MCP Apps add
  6. Choose a task, progress or a plain call
  7. What goes wrong in production
  8. Decisions this chapter made

Back to the topAll chapters