Large Language ModelsRemove backgroundsGenerate images
MCP Tasks Extension: Async and Background Tasks Explained
Long tool calls time out, drop connections and lose work. The MCP Tasks extension fixes that with a durable taskId, tasks/get polling, input_required pauses and cooperative cancel. See the lifecycle, JSON payloads, a FastMCP server example and client habits that survive crashes.
Your agent calls a tool, the tool needs forty minutes, and somewhere around minute two a proxy closes the connection. The job may still be running on the server, but nobody can reach it anymore, and the model is left holding an error instead of an answer. That gap is exactly what the MCP Tasks extension was built to close. Instead of keeping one request open until the work ends, the server returns a durable taskId right away, and the client checks in whenever it wants.
This article shows how async and background tasks work in the Model Context Protocol: what the extension is, what each status means, what the payloads look like, how to build a task-capable server, and which client habits keep long jobs safe. Field names below come from the published extension spec (io.modelcontextprotocol/tasks, SEP-2663) and from the FastMCP documentation.
Why Blocking Calls Break
A standard MCP tools/call behaves like a customer standing at a counter waiting for a dish. The request goes out, the connection stays open, and the answer returns on the same line. For a weather lookup, that is perfect. For a CI pipeline, a bulk import or a model training run, it is a bad bargain.
A restaurant solves this differently. Nobody stands at the kitchen pass staring at the chef. The waiter clips a paper ticket to the rail, and that ticket is the handle to the order. Tasks give MCP the same ticket rail.
The Timeout Problem
Many clients and transport intermediaries impose timeouts that make holding a request open impractical beyond a few seconds. Load balancers, corporate proxies and serverless gateways all cut quiet connections. When one does, the caller sees a failure even though the server is still busy, and the natural reaction is to retry and launch the same expensive job twice.
Lost Work After a Disconnect
A blocking call ties the result to the connection. A laptop lid closes, a mobile client leaves wifi, or the host process restarts, and the answer has nowhere to go. With a task, the ID is a durable handle: the client reconnects, calls tasks/get with the same ID, and picks up exactly where it stopped.
💡 Rule of thumb: if an operation regularly takes longer than a few seconds, or pauses for a human decision, it belongs behind a task.
What the Tasks Extension Adds
From Core Spec to Extension
Tasks began as an experimental feature in the core MCP specification. The protocol has since moved them out of core and into an optional extension identified as io.modelcontextprotocol/tasks, written up as SEP-2663. Living outside the core keeps the base protocol small, while servers and clients that need long-running work opt in on purpose. The official docs describe the result as asynchronous task execution for long-running MCP operations, and the full specification lives in the ext-tasks repository.
One change deserves attention. Older descriptions of the feature mention a separate tasks/result call. In the extension, the final output arrives inside the tasks/get response, which keeps the client loop down to a single polling method.
The Extension Identifier and Opt In
Support is negotiated, never assumed:
The client lists io.modelcontextprotocol/tasks in its per-request capabilities, inside _meta under io.modelcontextprotocol/clientCapabilities.
The server advertises the same extension in the capabilities it returns to clients.
If a server requires task support and the client never declared it, the server answers with error code -32003 and the message Missing required client capability.
The decision about when to create a task belongs to the server. There is no per-tool flag on the client side. The client opts in once and must be ready for two result shapes: the normal result, or a task handle. Today, tools/call is the only request type that can produce a task.
Side
What it must do
Why it matters
Client
Declare the extension, handle two result shapes
A server never returns a task to a client that did not opt in
Server
Advertise the extension, create the task before replying
A crash right after the reply cannot orphan the ID
Both
Treat taskId as the only handle
Reconnects and restarts become harmless
The Task Lifecycle
Getting a Handle and Polling
The flow has five beats:
The client sends tools/call with the tasks capability attached.
The server decides the work is long and returns a CreateTaskResult marked resultType: "task".
The task is durably created before that response leaves the server.
The client calls tasks/get with the taskId, waiting at least pollIntervalMs between calls.
Each response carries the current status and, once the task is terminal, the result or the error.
Here is a simplified view of a fresh task. The exact envelope is defined in the spec, so treat this as an illustration of the fields:
The operation finished, the result field holds the output
Yes
failed
A JSON-RPC error occurred, the error field has details
Yes
cancelled
Stopped on request, although not always honored
Yes
Once a task reaches a terminal status, its state never changes again. tasks/get is idempotent, so polling ten times is exactly as safe as polling once.
Pausing for Human Input
Some jobs hit a decision point halfway through: approve a deploy, confirm a purchase, pick one of three options. The task moves to input_required, and the next tasks/get response includes an inputRequests map holding elicitations or other server requests.
The client shows those requests to a user or a model, then answers with tasks/update, sending inputResponses that line up with the outstanding requests. The server acknowledges with an empty result and ignores responses for unknown or already satisfied entries. Once every request has an answer, the server carries on.
💡 Why this is neat: no second connection and no unsolicited server-to-client message. The human step rides on the same polling loop as everything else.
Finishing, Failing and Cancelling
When the task finishes successfully, the result field contains what the original request would have returned synchronously. For a tool call, that means the same content blocks a blocking call would have produced. When the status is failed, the error field holds the JSON-RPC error.
Cancellation uses tasks/cancel. The server acknowledges with an empty result, but cancellation is cooperative. The work may already be past the point of no return, so a task can still land on a different terminal status.
Servers may also push updates through notifications/tasks. Clients opt in through subscriptions/listen, and each notification carries the full task state, the same shape a tasks/get response would return.
Building a Task Server
A Minimal FastMCP Tool
FastMCP 4.0 added support for the extension. You install fastmcp-tasks, register TasksExtension, and mark the tool as task-capable:
import asyncio
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
mcp = FastMCP("ReportServer")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def slow_computation(duration: int) -> str:
"""A long-running operation."""
for i in range(duration):
await asyncio.sleep(1)
return f"Finished in {duration} seconds"
Two details matter here. Background tasks require async functions, and using task=True on a sync function raises a ValueError at registration time. And task=True only signals that the tool can run in the background. Whether it actually does depends on the client opting in and on the server's execution mode. The docs note that Docket powers the distributed scheduler, which is what makes the setup production ready.
Progress and Execution Modes
Tools report progress through an injected Progress dependency, and that is where the statusMessage your clients display comes from:
For finer control, replace the boolean with a TaskConfig. Three modes decide how the tool behaves:
Mode
Behavior
optional
Runs synchronously for legacy clients and in the background for task-capable ones
required
Errors if the client lacks task support, runs in the background otherwise
forbidden
Always synchronous, never backgrounded
The shortcuts map cleanly: task=True means optional, and task=False means forbidden. You can also suggest a polling cadence with poll_interval=timedelta(seconds=2).
Client Patterns That Hold Up
Poll Politely, Persist Everything
A client that talks to task-capable servers needs five habits:
Declare the extension in the per-request capabilities.
Handle polymorphic results, since a tools/call can return a normal result or a task.
Respect pollIntervalMs, because the server may change it between responses.
Answer inputRequests through tasks/update instead of ignoring them.
Store task IDs durably so polling can resume after a crash or restart.
The loop below is pseudocode, not tied to a specific SDK:
async def run_tool(session, name, args):
reply = await session.call_tool(name, args)
if reply.get("resultType") != "task":
return reply # ordinary synchronous result
task = reply
store.save(task["taskId"]) # survive a crash
while task["status"] in ("working", "input_required"):
if task["status"] == "input_required":
answers = await ask_user(task["inputRequests"])
await session.request("tasks/update", {
"taskId": task["taskId"],
"inputResponses": answers,
})
await asyncio.sleep(task["pollIntervalMs"] / 1000)
task = await session.request("tasks/get", {"taskId": task["taskId"]})
if task["status"] == "failed":
raise RuntimeError(task["error"])
return task.get("result")
The commuter on a train is the mental model. The connection drops in every tunnel, yet the ticket in their pocket stays valid. A client built this way reconnects after the tunnel and keeps going.
Notifications Instead of Polling
Polling is the default, and it works everywhere. If a server supports notifications/tasks, a client can subscribe once and skip most tasks/get round-trips, since every notification already contains the full task state. Keep polling as the fallback for servers that do not push.
Mistakes to Avoid
Task handles behave like parcels in a post office back room. Leave one long enough and it gets cleared out. These are the traps that show up most often:
Mistake
What goes wrong
Fix
Ignoring ttlMs
The task expires before a slow client reads the result
Read results promptly, and give the server a TTL that fits real client behavior
Polling faster than pollIntervalMs
Wasted requests and avoidable load
Sleep for the suggested interval
Treating cancel as instant
The UI claims the job stopped while it keeps running
Wait for a terminal status before reporting it
Returning a task to a client that never opted in
The client cannot read the response
Check the declared capabilities first
Wrapping every tool in a task
Quick calls gain latency for no reason
Let fast operations return normally
Sharing task IDs loosely
Another caller could read someone else's output
Treat the ID as a handle and bind it to the authenticated caller (good practice, beyond what the spec lists)
A ttlMs of null means unlimited, which sounds friendly until storage fills up with finished jobs nobody collects.
Pairing Tasks With PicassoIA
Generative media is the textbook long-running job, which is why task patterns feel natural next to image and video tooling. Two PicassoIA models fit straight into a task workflow.
Claude Sonnet 5 handles multi-step coding and tool-use work, so it is a solid pair programmer for the handler code in this article. Other options in the same category include GPT 5.6 Sol, if you want a second opinion on the same code.
Paste your tool signature and the task fields from this article into the Prompt box, then ask for an async handler with status messages.
Set Effort to high for tricky state machines, or leave it at low for quick edits.
Add a System Prompt such as "Write Python, async only, no blocking calls" so every reply keeps the same style.
Raise Max Tokens above the 8,192 default if you want tests generated in the same answer.
Run it, read the result, and paste the handler into your project.
💡 Tip: attach a screenshot of an error with the Image field. The model reads it as context.
Clean Cutouts for Diagrams
Documentation about task flows tends to need clean visuals: a device photo for a status card, a logo for an architecture slide. Remove Background returns a transparent PNG in seconds, and its Preserve Partial Alpha setting keeps soft edges natural. Switch it off when you want hard, fully opaque edges for product shots.
For fresh scene images, Flux 2 Pro and P Image both turn a written prompt into a photo that suits a blog header.
Make Your Own Images Next
You now have the whole picture: a handle instead of a held connection, five statuses, three methods, and a short list of habits that keep long jobs from vanishing. The best way to make it stick is to build something small. Write a tool that takes ten seconds, mark it task=True, and watch the status move from working to a terminal state.
Then give the project a face. Open Picasso IA, pick a text-to-image model, and generate a header image for your write-up. Experiment with camera angles, light and lens details in your prompts, remove a background for a clean logo, and see how quickly an idea turns into a finished visual. Your next task result deserves a picture worth sharing.