MCP Tasks Tutorial: Run Long-Running Tools Asynchronously with Polling and Approval
Introduction
A normal MCP tool call blocks until the tool finishes. That design works for a database lookup that takes 200 milliseconds, and it breaks for a video render, a batch migration, or a report that waits for a manager to approve it. The client times out, the connection drops, and nobody knows whether the work finished, failed, or is still running. MCP Tasks solve that exact problem by splitting one slow request into three parts: start it now, check on it later, and collect the result when it is ready. In this tutorial you will build that pattern with a small runnable demo, wire up polling, cancellation, and a human approval pause, and learn the failure modes that make async tools safe in production.
Quick answer
Return a task handle immediately instead of blocking: advertise Tasks support, run the slow tool in the background, and let the client poll tasks/get until the status is completed, then read the result from the final tasks/get response.
Evidence
Tasks arrived as an experimental capability in the MCP specification version 2025-11-25, and the design has since moved into the io.modelcontextprotocol/tasks extension, so older writeups that describe Tasks as core protocol may already be out of date. The historical proposal, SEP-1686: Tasks, was created on 2025-10-20 by Surbhi Bansal and Luca Chang, and it frames the whole idea as a “call-now, fetch-later” pattern that augments an existing request instead of replacing it. The current Tasks extension overview states the motivation plainly: “Not every tool call returns instantly.” It then names the cases that matter, namely CI pipelines, batch processing, and human approvals that take seconds, minutes, or longer. The 2025-11-25 specification is the version where Tasks first shipped as experimental, and it remains the reference point for the capability negotiation described below.
The numbers here come from a demo run on November 1, 2026, not from a vendor benchmark. The demo job runs in five steps of one second each, so a poll after two seconds returned progress 40 with status working, a poll after six seconds returned progress 100 with status completed, and the final result read: “Batch query finished: 120 rows checked, 3 flagged for review.” The server suggested a pollFrequency of 1000 milliseconds and a keepAlive of 60000 milliseconds, so a client polling once per second makes about five requests for this job, and it has 60 seconds after completion to fetch the result. The state machine itself is small, with exactly five states, working, input_required, completed, failed, and cancelled, and that small size is a feature, because every client only needs to handle five transitions.
Prerequisites
- Python 3.10 or newer, with no extra packages required, because the demo uses only the standard library
- curl, for sending JSON-RPC requests from the terminal
- A basic MCP server or client you already understand, since this tutorial adds async handling rather than teaching MCP from zero
- A slow job to model, such as a batch query, a file conversion, or a report that needs approval, because Tasks add no value to instant tools
If you are new to MCP servers, start with our guide to build your first MCP server and our guide to build an MCP server in Python, then return here to make one of those tools async.
Step 1: Negotiate Tasks support before you create any task
Tasks are opt-in on both sides, so the first step is capability negotiation, not code for the slow job. The client declares the Tasks extension in its capabilities, and the server advertises the same extension in its own capabilities. A server must never return a task handle to a client that did not opt in, because that client will not know how to poll for it, and it will wait for a normal response that never arrives in the usual form. In the demo, the initialize response advertises the capability like this:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {"tasks": {}},
"serverInfo": {"name": "tasks-demo", "version": "0.1.0"}
}
}
Treat this negotiation as a gate in your real server. If the client did not advertise Tasks support, run the tool synchronously, return an error that says async is unsupported, or refuse the slow operation with a clear message. Do not silently fall back to blocking for ten minutes, because that recreates the timeout problem Tasks exist to fix.
Step 2: Design the task record
A task is a small record, not a second database. It needs a taskId, a status, optional progress, a pollFrequency suggestion, a keepAlive retention window, and a place to store the final result or error. Keep the record in memory for a demo, and move it to a durable store such as Redis or Postgres before production, because an in-memory task disappears when the server restarts. Set keepAlive deliberately: too short, and a slow client loses the result; too long, and finished tasks pile up.
The five states drive every client decision. Working means keep polling. Input_required means stop and answer the pending question, as Step 6 shows. Completed means read the result once and stop. Failed means read the error, decide whether to retry, and stop. Cancelled means the work was abandoned, so stop polling and do not retry automatically, because the user or the system chose to stop it.
Step 3: Return a task handle immediately
The core change is in the tool handler. Instead of running the slow work and then responding, the handler creates the task, starts the work in a background thread, and responds at once with a task result. In the demo, a tools/call request for a slow batch query returns immediately:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "task",
"taskId": "bc2e2bb3-597f-43de-9217-7b62244a701e",
"status": "working",
"pollFrequency": 1000,
"keepAlive": 60000
}
}
Two details matter here. First, the task must be created before the response is sent, so a client that polls a millisecond later finds it instead of getting a not-found error for a task the server just promised. Second, validate inputs before creating the task, because a task created for an invalid request wastes a background worker. In the demo, the background worker is a thread that advances progress by 20 points per second:
def run_job(task_id, steps=5):
for i in range(1, steps + 1):
time.sleep(1)
with LOCK:
task = TASKS.get(task_id)
if not task or task["status"] == "cancelled":
return
task["progress"] = i * 20
if i == steps:
task["status"] = "completed"
task["result"] = {"content": [{"type": "text", "text": "Batch query finished: 120 rows checked, 3 flagged for review."}]}
Note the cancellation check at the top of each step. Without it, a cancelled task keeps consuming worker time until it finishes, and the client sees a completed result for work it explicitly abandoned.
Step 4: Poll with tasks/get, and respect the suggested interval
The client polls tasks/get with the taskId, and the server returns the current status plus progress and, for terminal states, the final result or error. Send the first poll after the suggested interval, not instantly, because the server just told you how often it wants to be asked. A simple poll loop in bash looks like this:
TASK_ID="bc2e2bb3-597f-43de-9217-7b62244a701e"
for i in 1 2 3 4 5 6 7 8 9 10; do
curl -s -X POST http://127.0.0.1:8931/ \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
echo
sleep 1
done
In the demo run, a poll after two seconds returned status working with progress 40, and a poll after six seconds returned status completed with progress 100 and the result text. Stop polling the moment you see completed, failed, or cancelled, and fetch the result from that final response rather than issuing one more poll, because the task may be deleted once keepAlive expires. For production clients, add a timeout of your own, for example ten times the expected duration, after which you cancel the task and report a timeout.
Step 5: List and cancel tasks
Two more methods complete the client surface. Tasks/list returns the tasks visible to the caller, which is useful for resuming after a client restart. Tasks/cancel asks the server to stop a task. Cancellation is a request, not a guarantee, so the client must keep polling until it sees cancelled, because a worker in the middle of a step may finish that step before it notices the flag. The demo proves this: after a cancel request at progress 40, the next tasks/get returned status cancelled with progress still at 40, and no further progress happened.
Cancel promptly when the user abandons a chat, closes a tab, or starts a replacement job. A slow job that nobody wants is the most expensive kind of background work, because it holds a worker, a connection, or an API quota for a result nobody will read.
Step 6: Pause for human approval with input_required
Some slow jobs should not finish without a human. A report sent to 120 recipients, a payment, or a database migration all deserve a pause. When the job reaches such a step, the server sets the status to input_required and stores the pending question, instead of guessing or proceeding. The client then answers through tasks/update, and only then does the server resume the work. In the demo, a report review tool returns input_required at once, and the approval flow runs like this: tasks/get shows status input_required with progress 0, tasks/update with approved set to true returns status working, and a later tasks/get returns status completed with progress 100 and the same batch result text. If the approver rejects the report, set the status to failed or cancelled with a clear message, and do not retry the send automatically.
Design the approval payload to be auditable: record who approved, when, and what exact content was approved. An approval for “send the report” is not an approval for a report that changed after the approval was given, so if the content changes, create a new approval step rather than reusing the old one. The task survives the wait, the client can disconnect while the human decides, and polling resumes with the same taskId when the client returns.
Step 7: Handle the retention window and restarts
A taskId is not permanent storage. After keepAlive expires, the server may delete the task, and tasks/get will return a task-not-found error, which in the demo uses JSON-RPC code -32602 with the message “Task not found”. Clients should treat that error after a terminal state as “result already collected or expired”, not as a retry signal, and they should never retry a completed job blindly, because a batch query or a send operation may not be idempotent. Store the final result in your own system the first time you see it.
For restarts, decide the policy before launch: either tasks survive a restart through a durable store, or they do not and clients get a clear failed or not-found result. The worse option is silence, where a restarted server accepts polls for tasks it no longer knows and the client waits forever. If your server is stateless, as in our guide to the stateless MCP spec migration, you need an external task store before you offer Tasks at all.
Troubleshooting
Polling returns “Task not found” for a task you just created
This usually means the client polled before the server finished creating the task, the taskId was copied with a typo, or keepAlive expired. Check the taskId character by character against the tools/call response, wait one poll interval and try again for a fresh task, and check whether the server restarted since creation. If the error persists for a fresh task, the server is not storing the task before responding, which is a server bug: creation must happen before the response is sent.
The client times out even though Tasks are enabled
The client may be waiting for a synchronous tool result instead of handling the task result shape. Log the raw tools/call response and check for resultType set to task. If the response is a normal tool result, the server did not treat this request as task-augmented, which usually means capability negotiation failed: the client did not advertise Tasks support, or the server does not support Tasks for that tool. Fix negotiation first, and only then debug polling.
A cancelled task keeps running
The worker is not checking the cancellation flag often enough, or at all. Add a check at the top of each loop iteration and before each expensive step, as the demo does, and keep polling after sending cancel until tasks/get returns cancelled. If the underlying work is a single blocking call with no loop, split it into steps or run it in a thread you can abandon, and document that cancellation takes effect at the next step boundary rather than instantly.
An approval task never leaves input_required
The client answered the wrong taskId, answered with a shape the server does not recognize, or the server stored the pending question but never wired tasks/update to resume the worker. Poll tasks/get to confirm the status is still input_required, resend tasks/update with the exact taskId from the task creation response, and check server logs for the update request. If approval succeeds but the job restarts from zero instead of resuming, the server did not persist progress with the task record, so add progress to the stored record before shipping approvals.
Results disappear after a short delay
KeepAlive expired before the client collected the result, or the server restarts and drops in-memory tasks. Increase keepAlive for jobs whose clients poll slowly, fetch and store the result on the first completed poll, and move task storage to a durable store for production. Do not “fix” this by setting keepAlive to unlimited for every task, because finished tasks then accumulate until the store fills up.
FAQ
The answers above cover the questions readers ask most.
Conclusion
MCP Tasks turn a fragile long wait into a durable, inspectable job: a handle at creation, a small state machine during execution, and a result that the client collects when it is ready. Start with one slow tool, measure its real duration and polling cost, add cancellation and an approval pause where the stakes justify them, and only then expand Tasks to more tools. For the next step, review your server design against our guide to build your first MCP server.
Frequently asked questions
What is an MCP Task?
An MCP Task is a durable handle for a request that takes too long to answer in one round trip. The server returns a taskId and a status right away, keeps working in the background, and the client polls tasks/get until the status reaches a terminal state such as completed, failed, or cancelled.
When should I use Tasks instead of a normal tool call?
Use Tasks when the work takes seconds, minutes, or longer, when the client may disconnect and reconnect, or when a human must approve a step in the middle. Keep short, instant tools synchronous, because Tasks add polling, state storage, and cleanup that a fast tool does not need.
What are the five MCP Task states?
The five states are working, input_required, completed, failed, and cancelled. Working means the job is still running, input_required means it is paused for input or approval, and completed, failed, and cancelled are terminal states after which the client should stop polling and read the final result or error.
How does a client know how often to poll?
The server may suggest an interval with pollFrequency in milliseconds in the task response. Clients should respect that suggestion instead of polling as fast as possible, and they should back off or stop when the task reaches a terminal state or when polling returns a task-not-found error after the retention window ends.
Do all MCP clients support Tasks?
No. Task support requires explicit opt-in from both sides, so a client that does not advertise the Tasks extension will simply get a normal synchronous response, and a server that does not support Tasks for a given request will process that request normally. Check the client matrix and your SDK version before you depend on Tasks in production.
What happens to a task result after it finishes?
A finished task stays retrievable only for its retention window, expressed as keepAlive in milliseconds, or indefinitely when keepAlive is null. After that window, the server may delete the task and its result, so clients should fetch and store the final result promptly instead of treating the taskId as permanent storage.