MCP tasks let a server answer a slow tool call with a handle instead of a result. The client gets a task ID right away, checks on it with tasks/get, and picks up the result when the work is done. With a client that supports tasks, nothing has to hold a connection open for five minutes, and nobody has to raise a timeout. Tasks are an official extension to the Model Context Protocol, identified as io.modelcontextprotocol/tasks. They started as an experimental part of the November 2025 spec and were redesigned in the 2026-07-28 release.

We care about this for a plain reason. A DialMCP tool call places a real phone call, and a phone call takes two to five minutes. That's exactly the kind of work tasks were built for. This guide explains how the extension works on the wire, what changed from the 2025 version, which clients and SDKs support it as of November 5, 2026, and what to do while most clients still don't. The server code below was run that day on fastmcp 4.1.6, and the JSON comes from a real exchange with it.

Why long tool calls fail

A black-and-white photograph from about 1918 of clerks working at rows of pneumatic tube terminals beneath overhead tubes in the basement of the Sears, Roebuck mail order plant in Chicago
The north pneumatic tube station at the Sears, Roebuck mail order plant in Chicago, about 1918. Historic American Buildings Survey, Library of Congress, via Wikimedia Commons, public domain.

A normal MCP tool call is a request and a response. The client sends tools/call and waits. That's fine for a database lookup. It goes wrong when the work takes minutes, because several timers sit between the model and your server and any of them can end the wait:

  • The client's own limit. Codex gives each tool call 60 seconds unless you raise tool_timeout_sec. The official TypeScript client SDK defaults to a 60-second request timeout, and progress notifications don't reset it unless the caller turns on resetTimeoutOnProgress.
  • A first-byte timer. Claude Code lets a tool run for a long time, but for HTTP servers it also expects the first byte of a response within a per-request window of at least 60 seconds. On top of that, by default a call to an HTTP server that sends no response and no progress notification for five minutes is aborted as idle.
  • Infrastructure in between. nginx's default proxy_read_timeout is 60 seconds, and many serverless platforms cap how long a function can run.

The Tasks overview puts it bluntly: many clients and intermediaries "impose timeouts that make this impractical beyond a few seconds." Blocking also has a second problem. If the client restarts or the network drops, the result is lost and the work may be repeated.

What the Tasks extension does

The idea is call now, fetch later. When a tool call will take a while, the server creates a durable record of the job and returns its ID. From then on the client asks about that ID instead of waiting on the original request. The whole exchange uses four parts:

PieceDirectionWhat it does
CreateTaskResultServer to clientReturned instead of the normal tool result, marked resultType: "task"
tasks/getClient to serverReads the task's status. On a finished task it also carries the result or error
tasks/updateClient to serverAnswers a question the task asked mid-run, such as a confirmation
tasks/cancelClient to serverAsks the server to stop. The server only has to acknowledge it

Right now only tools/call can become a task. The spec leaves room for other request types later. Servers can also push notifications/tasks updates to clients that subscribe, but polling is the default and the only part every client needs.

How a task flows, step by step

1. The client opts in on every request. In the 2026-07-28 protocol there's no session handshake, so capabilities travel with each request. A client that can handle tasks lists the extension in _meta:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "build_report",
    "arguments": { "pages": 3 },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {
        "extensions": { "io.modelcontextprotocol/tasks": {} }
      }
    }
  }
}

The server advertises the same extension in its server/discover response. The opt-in is a permission, not a request. The client isn't asking for a task, it's saying it can handle one.

2. The server decides. If the job will be quick, the server can simply return the normal result. If not, it returns a task. This is the response our test server sent back, with the server-info metadata removed:

{
  "resultType": "task",
  "taskId": "MIKSrGATQXjbVO5wxKrDK8tH2haC4tYTmMTRX7BPjD4",
  "status": "working",
  "createdAt": "2026-11-05T14:03:46.927794+00:00",
  "lastUpdatedAt": "2026-11-05T14:03:46.927794+00:00",
  "ttlMs": 900000,
  "pollIntervalMs": 5000
}

Two rules apply here. The server must never send a task to a client that didn't declare the extension on that request. If a tool can't run any other way, the server returns error -32021 (missing required client capability). And the task has to exist before the response goes out, so the first tasks/get can't come back "not found."

3. The client polls. It calls tasks/get with the ID, waiting pollIntervalMs between calls. Our server was halfway through when we asked (createdAt and server-info metadata removed). The resultType of "complete" isn't a typo. It describes the tasks/get response itself, not the task:

{
  "resultType": "complete",
  "taskId": "MIKSrGATQXjbVO5wxKrDK8tH2haC4tYTmMTRX7BPjD4",
  "status": "working",
  "statusMessage": "Rendering page 2 of 3",
  "lastUpdatedAt": "2026-11-05T14:03:48.438336+00:00",
  "ttlMs": 900000,
  "pollIntervalMs": 5000
}

The statusMessage is free text for the user or the model. Once the status is completed, the same call carries the result, which is exactly what tools/call would have returned (trimmed here):

{
  "resultType": "complete",
  "taskId": "MIKSrGATQXjbVO5wxKrDK8tH2haC4tYTmMTRX7BPjD4",
  "status": "completed",
  "lastUpdatedAt": "2026-11-05T14:03:51.445197+00:00",
  "result": {
    "content": [{ "type": "text", "text": "Report ready: 3 pages" }],
    "structuredContent": { "result": "Report ready: 3 pages" },
    "isError": false
  }
}

4. The task can ask a question. If it needs input, say a yes or no before deploying, the status becomes input_required and tasks/get includes an inputRequests map. The client shows those requests to the user, then sends the answers back with tasks/update, keyed the same way. The spec says the client must treat these like any other elicitation request. A question that arrives through a task gets no extra trust.

5. The client can cancel. tasks/cancel gets an empty acknowledgement, and that's all it guarantees. Cancellation is cooperative. If the work finishes before the server notices, the task still ends as completed.

Task statuses

StatusMeaningFinal?
workingThe job is runningNo
input_requiredThe server is waiting on answers to inputRequestsNo
completedFinished. The result field holds the tool resultYes
failedA JSON-RPC error happened. The error field holds itYes
cancelledStopped before it finishedYes

One detail trips people up. A tool that runs and reports a problem with isError: true ends as completed, not failed. The failed status is only for protocol-level errors. So check the result's isError flag the same way you would on a normal call.

Tasks don't live forever. ttlMs is how long the server may keep a task after creating it, and null means no limit. Once it passes, the server may mark the task failed and delete it later. A tasks/get for an unknown or deleted ID returns error -32602. The spec also advises clients to store task IDs durably, so they can pick up polling after a crash.

What changed from the 2025-11-25 version

If you read about MCP tasks earlier this year, much of it no longer applies. The 2025-11-25 spec added tasks as an experimental core feature. The 2026-07-28 release moved them into the extension and reworked them, based on the design in SEP-2663. The two versions aren't compatible on the wire.

2025-11-25 (experimental)2026-07-28 extension
Who decidesThe client adds a task field to the request, if the tool's execution.taskSupport allows itThe server decides per request. The client only declares support
Getting the resulttasks/result, which blocks until the task endsIncluded in the tasks/get response. tasks/result is gone
Answering questionsOver the open tasks/result streamtasks/update, a separate write
Listing taskstasks/listRemoved
Cancel responseReturns the task's stateEmpty acknowledgement
Client-hosted tasksAllowed for sampling and elicitationRemoved

The reasons are practical. Clients had to call tools/list first just to learn which tools accepted the flag. The blocking tasks/result call brought back the long-lived connection that tasks were meant to remove. And tasks/list needed some way to scope tasks to a caller, which got harder once the protocol dropped sessions. Without a list method, there's no way to enumerate other callers' tasks, and unguessable IDs plus a permission check on every request do the rest. The extensions page labels Tasks "Beta, to Core", and the SEP says the plan is to promote it into the core protocol once it has stabilized and seen broad adoption.

Which clients and SDKs support MCP tasks

This changes month to month, so treat it as a snapshot from November 5, 2026.

Clients. Claude Code is the only host app we found that documents support. Its documentation lists MCP tasks as a public beta in the Claude Code CLI from version 2.1.304. It covers terminal sessions, the Agent SDK and GitHub Actions runs, but not the VS Code extension or the desktop app. Anthropic switches it on per type of session, and one-shot claude -p runs don't get tasks. The server has to connect on the 2026-07-28 protocol, and the call has to come from the main conversation rather than a subagent. In that setup, the tool call returns as soon as the server accepts the job, Claude keeps working, and the result arrives later. A task on a remote server keeps running after you exit, and resuming the session picks it up again. The community-maintained support matrix on modelcontextprotocol.io had not caught up yet. Its Tasks column was empty for every client listed, including Claude Code. Codex's and Cursor's MCP docs didn't mention the extension when we last checked.

Python. FastMCP 4 is the practical choice. Its fastmcp-tasks package (4.1.6, released November 3) implements the server side on top of Docket and lets FastMCP's own client handle task polling for you. Importing fastmcp_tasks is what turns on that client support. The official mcp SDK, at 2.3.0, has a general extension mechanism but no tasks runtime. Its 1.x experimental tasks API was written for the old spec and is deprecated.

TypeScript. The official @modelcontextprotocol/server and @modelcontextprotocol/client packages, at 2.4.0, don't include tasks yet. A pull request to add them was opened in September. For now the ext-tasks project publishes @modelcontextprotocol/ext-tasks (0.2.3), which gives clients a way to call tools that may come back as tasks. It still needs extra wiring for a 2026-07-28 server, so read its adapter guide before you depend on it.

Building a task-enabled tool with FastMCP

With FastMCP, a tool becomes task-capable with one flag and one registered extension. This one fakes a slow report and reports progress as it goes:

import asyncio
from fastmcp import FastMCP
from fastmcp.dependencies import Progress
from fastmcp_tasks import TasksExtension

mcp = FastMCP("reports")
mcp.add_extension(TasksExtension())


@mcp.tool(task=True)
async def build_report(pages: int, progress: Progress = Progress()) -> str:
    """Build a report. Takes about a second per page."""
    await progress.set_total(pages)
    for n in range(pages):
        await progress.set_message(f"Rendering page {n + 1} of {pages}")
        await asyncio.sleep(1)
        await progress.increment()
    return f"Report ready: {pages} pages"


if __name__ == "__main__":
    mcp.run(transport="http", port=8765)

Install it with pip install "fastmcp[tasks]". task=True makes the tool optional-mode. A client that declares the extension gets a task, and a client that doesn't gets a normal blocking call. We checked both. A raw request without the capability got the finished report back in one response, and FastMCP's client in legacy mode did the same. The progress messages show up as the task's statusMessage, which is where the "Rendering page 2 of 3" in the JSON above came from.

On the client side, once fastmcp_tasks is imported, a plain client.call_tool() hides the task completely and just returns the result. To watch it yourself, ask for the handle:

import asyncio
from fastmcp import Client
from fastmcp_tasks import call_tool_task

from server import mcp


async def main():
    async with Client(mcp, mode="auto") as client:
        task = await call_tool_task(client, "build_report", {"pages": 3})
        print("task id:", task.task_id)
        while True:
            status = await task.status()
            print(status.status, "-", status.status_message)
            if status.status in ("completed", "failed", "cancelled"):
                break
            await asyncio.sleep(1)
        print((await task.result()).data)


asyncio.run(main())
task id: 32pt-gMFh3YPZv-B5hY-1cxMmHJu2HIom9SduAybLss
working - None
working - Rendering page 1 of 3
working - Rendering page 2 of 3
completed - None
Report ready: 3 pages

Two settings matter before production. FastMCP's default backend is in memory, so tasks vanish when the process restarts. Point TasksExtension at Redis or Valkey if the whole point is surviving restarts. And a background task keeps a snapshot of the caller's identity, access token included. That snapshot is stored as plain JSON in Redis unless you set FASTMCP_TASKS_ENCRYPTION_KEY, and every server and worker must share the same key. The key covers only the snapshot. Tool arguments and any answers gathered mid-task are still stored as plain text.

A short checklist for any server

  • Check the capability on each request. No extension in _meta means no task. Run the call normally or return -32021.
  • Make task IDs unguessable. The spec allows servers to treat them as bearer tokens, so use real entropy.
  • Authorize every task request. tasks/get, tasks/update and tasks/cancel each need the same access check as the original call.
  • Write the task before you answer. Store it first, then return the handle.
  • Pick sensible timing. Set pollIntervalMs to match how fast the job really changes, and a ttlMs long enough for a client that comes back late.
  • Answer fast on HTTP. Accept the job and respond first, then do the work. Claude Code's docs point out that the task response itself still has to start within the client's per-request timer.

Long-running tools before clients catch up

Most of your users today are on clients without task support. So the tool design that works everywhere is still the older one: put the handle in the tool result itself and give the model a second tool to check on it. That's how DialMCP works. Here's how its four tools line up with the extension:

Tasks extensionDialMCP toolWhat it does
CreateTaskResultplace_callDials and returns a call_id with status queued right away
tasks/getget_callReturns the status, the live transcript and, shortly after hang-up, the structured result
tasks/cancelend_callEnds the call, with a short goodbye if the voice agent is already talking
(removed tasks/list)list_callsRecent calls, scoped to the signed-in account
pollIntervalMs"Poll get_call every ~20s"A hint in the place_call response

A few choices made this work in practice. get_call accepts wait_seconds from 0 to 50. With a wait, the server holds the request and returns as soon as the status changes, which cuts down on round trips. The cap sits under the common 60-second client limit, so a long poll doesn't trip the timeout it's meant to avoid. A call ends in one of eight final statuses and never changes after that, which gives the model a clear point to stop polling. Call IDs belong to the account that created them, and anyone else's ID simply comes back not_found. That's the concern behind the spec dropping tasks/list: a task ID should only work for the caller it belongs to. DialMCP can enforce that because every call belongs to an account. The full contract is on the tool reference, and our MCP tools guide covers the spec's advice on designing handles.

The cost of doing it this way is that the model does the polling. Each check is a tool call and a model turn, while with real tasks the host polls quietly and only hands over the result. So the pattern is a bridge, not the end state.

To be clear about where we stand: DialMCP doesn't implement the Tasks extension today. The server still runs the 2025-era session protocol on version 1.23.0 of the TypeScript SDK, as our MCP architecture guide notes. Because place_call and get_call already behave like a create and a poll, the tool design maps cleanly onto tasks. Adopting the extension would still mean moving the server to the 2026-07-28 protocol first, since tasks aren't defined on the older one, and the official TypeScript SDK doesn't ship tasks support yet.

When you don't need tasks

Most tools shouldn't use them. If a call reliably finishes in a few seconds, a plain result is simpler for everyone, and the extension lets the server choose that per call anyway. Tasks also don't help when the slow part is the model's own thinking, or when a job needs constant back-and-forth with the user. For a single question in the middle of a call, the Multi Round-Trip Requests pattern in our MCP client vs server guide is a better fit. Reach for tasks when work takes minutes, runs in an outside job system, waits on a person, or has to survive a dropped connection. If you're still choosing a framework, our FastMCP guide compares it with the official SDK, and the MCP server examples post includes a short, tested start-then-check job server built on the official SDK.

Further reading

Try a remote MCP server that does real work. DialMCP lets Claude, ChatGPT, Codex, Cursor or VS Code place real phone calls from your own verified number and hand back a transcript and a structured outcome. Add https://mcp.dialmcp.com/mcp as a connector and sign in. US and Canada, free during launch.

Connect DialMCP to your client

FAQ

What are MCP tasks?

MCP tasks are an official extension to the Model Context Protocol, io.modelcontextprotocol/tasks, for tool calls that take a long time. Instead of holding the request open, the server returns a task ID right away. The client checks on it with tasks/get, answers any questions with tasks/update, can cancel with tasks/cancel, and gets the normal tool result once the task completes.

Are MCP tasks part of the core spec?

Not anymore. Tasks were added as an experimental core feature in the 2025-11-25 spec. The 2026-07-28 release moved them into an official extension and redesigned them, and the two versions aren't wire-compatible. The extension is at the Beta stage with Core as its stated destination, so the plan is to promote it back into the core spec once it has stabilized.

Which clients support MCP tasks?

As of November 5, 2026, Claude Code supports them in public beta in the CLI from version 2.1.304, including Agent SDK and GitHub Actions sessions, but not in its VS Code extension or desktop app. The community support matrix on modelcontextprotocol.io didn't list Tasks support for any client yet, and Codex's and Cursor's MCP docs didn't mention the extension when we last checked.

How do I add tasks to a Python MCP server?

The simplest route today is FastMCP 4 with the fastmcp-tasks package. Register TasksExtension on the server and add task=True to an async tool. Clients that declare the extension get a task handle, and other clients get a normal blocking call. On the client side, importing fastmcp_tasks turns on task support. Use a Redis or Valkey backend in production, since the default in-memory backend loses tasks on restart.

What should a long-running tool do for clients without task support?

Return a handle inside the normal tool result and give the model a second tool to check on it. DialMCP works this way: place_call returns a call ID at once, and get_call reports progress, with an optional server-side wait of up to 50 seconds so each check stays under common 60-second client timeouts.