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.

MCP Tasks Extension: Async and Background Tasks Explained
Cristian Da Conceicao
Founder of Picasso IA

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

Paper order tickets clipped along a steel rail in a busy restaurant kitchen pass

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

A customer's hand receiving a numbered paper claim ticket over a wooden repair shop counter

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.

SideWhat it must doWhy it matters
ClientDeclare the extension, handle two result shapesA server never returns a task to a client that did not opt in
ServerAdvertise the extension, create the task before replyingA crash right after the reply cannot orphan the ID
BothTreat taskId as the only handleReconnects and restarts become harmless

The Task Lifecycle

Getting a Handle and Polling

Over-the-shoulder view of a developer checking a laptop terminal in a sunlit home office

The flow has five beats:

  1. The client sends tools/call with the tasks capability attached.
  2. The server decides the work is long and returns a CreateTaskResult marked resultType: "task".
  3. The task is durably created before that response leaves the server.
  4. The client calls tasks/get with the taskId, waiting at least pollIntervalMs between calls.
  5. 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:

{
  "resultType": "task",
  "taskId": "tsk_8f3a91c2",
  "status": "working",
  "statusMessage": "Rendering 120 pages",
  "createdAt": "2026-10-06T09:00:00Z",
  "lastUpdatedAt": "2026-10-06T09:00:04Z",
  "ttlMs": 3600000,
  "pollIntervalMs": 2000
}

Five statuses describe every task:

StatusMeaningTerminal?
workingThe operation is in progressNo
input_requiredThe server needs client input, see inputRequestsNo
completedThe operation finished, the result field holds the outputYes
failedA JSON-RPC error occurred, the error field has detailsYes
cancelledStopped on request, although not always honoredYes

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

Overhead view of a manager's hands stamping an approval onto a stack of printed forms

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

Side profile of a programmer typing in a dim home office at dusk

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:

@mcp.tool(task=True)
async def process_files(
    files: list[str],
    progress: Progress = Progress()
) -> str:
    await progress.set_total(len(files))
    for file in files:
        await progress.set_message(f"Processing {file}")
        await progress.increment()
    return f"Processed {len(files)} files"

For finer control, replace the boolean with a TaskConfig. Three modes decide how the tool behaves:

ModeBehavior
optionalRuns synchronously for legacy clients and in the background for task-capable ones
requiredErrors if the client lacks task support, runs in the background otherwise
forbiddenAlways 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).

Symmetrical aisle between rows of black server racks inside a data center

Client Patterns That Hold Up

Poll Politely, Persist Everything

Commuter holding a smartphone on a rainy morning train

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

Shelf of brown cardboard parcels with handwritten labels in a post office back room

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:

MistakeWhat goes wrongFix
Ignoring ttlMsThe task expires before a slow client reads the resultRead results promptly, and give the server a TTL that fits real client behavior
Polling faster than pollIntervalMsWasted requests and avoidable loadSleep for the suggested interval
Treating cancel as instantThe UI claims the job stopped while it keeps runningWait for a terminal status before reporting it
Returning a task to a client that never opted inThe client cannot read the responseCheck the declared capabilities first
Wrapping every tool in a taskQuick calls gain latency for no reasonLet fast operations return normally
Sharing task IDs looselyAnother caller could read someone else's outputTreat 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.

How to Use Claude Sonnet 5

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.

  1. Open the Claude Sonnet 5 page on Picasso IA.
  2. Paste your tool signature and the task fields from this article into the Prompt box, then ask for an async handler with status messages.
  3. Set Effort to high for tricky state machines, or leave it at low for quick edits.
  4. Add a System Prompt such as "Write Python, async only, no blocking calls" so every reply keeps the same style.
  5. Raise Max Tokens above the 8,192 default if you want tests generated in the same answer.
  6. 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

Product photographer's studio table with a ceramic mug on white paper and a laptop showing the cutout

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.

Share this article