- Published on
MCP Tasks: Durable Asynchronous Tool Calls
- Authors

- Name
- Jared Chung
Introduction
Some tools finish in milliseconds. Others generate a report, process a dataset, wait for a person, or call an external job that may take minutes. Keeping one request open for the entire operation makes reconnecting, cancellation, and recovery difficult.
The Model Context Protocol's experimental Tasks work gives long-running requests an explicit lifecycle. A caller can start work, receive a task identifier, poll for state, provide requested input, cancel the operation, and retrieve the eventual result.
The Tasks extension is a draft whose messages and behavior can change. Treat this as a design guide tied to the referenced revision rather than stable implementation documentation.
Protocol state simulator
Protocol receipt
01 events- 01task/create → created
A Task Is a State Machine
Without a task abstraction, applications often hide asynchronous behavior behind timeouts and private job IDs. A shared lifecycle makes the state visible to clients and intermediaries.
submitted
|
v
working -------> input_required
| |
|<------------------+
|
+------> completed
+------> failed
+------> cancelled
A task needs more than a status string. The July draft includes an identifier, timestamps, and state-specific information. Clients need to know when to poll and how to distinguish protocol failure from a tool result whose own isError field is true.
Conceptually, starting a task-augmented tool call looks like:
{
"jsonrpc": "2.0",
"id": 17,
"method": "tools/call",
"params": {
"name": "build_report",
"arguments": {"dataset": "sales-q2"},
"task": {"ttl": 3600000}
}
}
The immediate response identifies the task. The final tool result is retrieved later through the task lifecycle.
Capability Negotiation Comes First
Clients and servers cannot assume that every request supports Tasks. They must negotiate the relevant extension capability and request category.
An implementation should keep a normal synchronous path for peers that do not support Tasks:
async def call_report_tool(peer, arguments):
if peer.supports_task_augmented_tools:
return await start_and_poll_task("build_report", arguments)
return await call_tool_synchronously("build_report", arguments)
This compatibility layer should return the same final application result where possible. New clients may expose intermediate task state, while older clients can wait and surface only completion.
Do not turn every tool call into a task. The extra state, storage, and polling are useful for expensive work, external jobs, interruption, or human input. They are overhead for a quick deterministic lookup.
Handle Polling, Input, and Cancellation
Polling must respect the server's suggested interval and the task's time-to-live. Clients should avoid tight loops that create unnecessary load.
async def wait_for_task(client, task_id):
seen_inputs = set()
while True:
task = await client.get_task(task_id)
for request in task.input_requests:
if request.key in seen_inputs:
continue
seen_inputs.add(request.key)
await handle_input_request(request)
if task.status in {"completed", "failed", "cancelled"}:
return task
await sleep(task.poll_interval_ms / 1000)
An input_required state may carry an elicitation or sampling request through the task. It is not more trustworthy because it arrived inside a task. Apply the same user-facing review and trust policy used for the equivalent direct request. Deduplicate input keys so reconnects and repeated polls do not show the same prompt twice.
Cancellation is a request, not proof that the underlying side effect never happened. A client can race with completion, and an external job may already have committed work. Tools that create real-world effects still need idempotency and reconciliation.
Bind Every Task to Authorization
A task ID may provide access to stored state and results. It must be unguessable, but entropy is not a replacement for authorization.
def get_task(task_id: str, requestor_id: str):
task = task_store.load(task_id)
if task is None:
raise NotFound()
if task.requestor_id != requestor_id:
raise NotFound()
return task
Perform the authorization check for get, update, cancel, input, and result operations. Returning “not found” for another user's task can avoid exposing whether it exists.
Also enforce resource limits:
- maximum concurrent tasks per requestor;
- maximum time-to-live;
- stored-result size and retention;
- polling rate limits;
- cleanup of expired tasks.
Log creation, state transitions, input, cancellation, and result retrieval with the authorization context. Avoid placing sensitive task payloads in general logs.
Build a Failure-Oriented Demo
A useful demonstration is a fake report generator that takes several steps and occasionally requests a display preference.
Test these cases:
- the client disconnects and later resumes polling;
- the worker restarts while the task is working;
- the same input request appears in consecutive polls;
- cancellation races with completion;
- another user attempts to fetch or cancel the task;
- the task expires before result retrieval;
- the tool completes with
isError: true.
The task store should persist independently of the worker process if restart recovery is part of the claim. Record expected and observed transitions rather than showing only the happy path.
Conclusion
MCP Tasks make long-running tool work explicit: start it, observe it, supply input, cancel it, and retrieve the result later. The valuable part is not polling itself. It is the shared state model around recovery, trust, and ownership.
Because the extension is experimental, pin the referenced revision and isolate protocol details behind a small adapter. The durable design lesson is to treat asynchronous work as state with an owner, limits, and an auditable lifecycle.
References
- MCP Tasks extension draft, revision dated 28 July 2026
- SEP-2663: Tasks Extension, Model Context Protocol
- Model Context Protocol specification: Tasks, experimental 25 November 2025 design