Tasks I: work that outlives the request
Eight minutes is not a timeout problem
Studio B is going offline for a fortnight, and every recurring booking in it has to be moved. That is reflow_month — a genuine constraint problem across a month of bookings, and it takes about eight minutes.
You cannot hold a request open for eight minutes. Clients time out, proxies time out, load balancers close idle connections, and a laptop lid closes. Raising the timeout is not the answer, because the failure is not really about duration — it is that a held connection is a single point of failure for work that has already started.
MCP's answer is the Tasks extension: return a handle immediately, let the client poll.
The dry-cleaning ticket
You hand over a coat and get a paper stub with a number and "ready Thursday". You leave. The shop does not hold the counter open. If you lose the stub you have a problem; if the shop burns down the stub is worthless; and the stub does not entitle you to the coat forever, because after a month they will have moved it on.
Every part of that maps onto something with a name. The number is taskId. "Ready Thursday" is pollIntervalMs — come back at about this rate, do not stand here asking. The month is ttlMs, how long the handle stays valid.
For a beginner the picture is complete without any protocol detail. For an engineer it is the async job pattern with the correct question already asked: what is the identifier, how often should I poll, and when does it expire.
What comes back instead of an answer
A client declares the extension in its capabilities:
{
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}The server decides per request whether the work warrants a task. When it does, resultType is task:
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"resultType": "task",
"task": {
"taskId": "tsk_8f21",
"status": "working",
"statusMessage": "Reflowing 142 bookings",
"ttlMs": 86400000,
"pollIntervalMs": 5000
}
}
}This is the third resultType you have met — complete in Lesson 6, input_required in Lesson 22, task here. A client must handle all three from any call, which is why Lesson 6 insisted on that field.
The task is durably created before the response is sent. A taskId that came back is a task that exists, even if the server dies immediately afterwards.
Polling:
{"jsonrpc": "2.0", "id": 12, "method": "tasks/get", "params": {"taskId": "tsk_8f21"}}Until a terminal state, where result holds exactly what the original call would have returned synchronously:
{
"jsonrpc": "2.0",
"id": 15,
"result": {
"resultType": "complete",
"task": {"taskId": "tsk_8f21", "status": "completed"},
"result": {"structuredContent": {"moved": 138, "unresolved": 4}}
}
}The five states
working— in progress.statusMessageis free text for the user, and worth setting: "reflowing 142 bookings" is a better wait than a spinner.input_required— the task needs an answer before it can continue. The next lesson.completed— finished.resultholds the answer.failed— errored.errorholds a JSON-RPC error.cancelled— cancellation was honoured.
The last three are terminal: once reached, the state never changes again, so a client can stop polling and cache the outcome.
When to return a task
When it will take longer than a few seconds. CI runs, batch jobs, anything queued.
When it needs a human mid-flight. An approval gate is a task, because the wait is unbounded by definition.
When you are wrapping something that already has job ids. A cloud deployment, an async API, a queue. You already have a durable handle; a task is how you expose it.
When clients are unreliable. Mobile, flaky networks. A task id survives a disconnect; a held connection does not.
And when not to: a call that takes 200 milliseconds should just answer. Tasks cost a round trip and force every client to handle a second result shape. Use them when the alternative is a timeout, not because they look more sophisticated.
Client-side
result = await client.call_tool("reflow_month", {"month": "2026-09", "offline_rooms": ["studio-b"]})
if result.result_type == "task":
task_id = result.task.task_id
while True:
await asyncio.sleep(result.task.poll_interval_ms / 1000)
result = await client.get_task(task_id)
if result.task.status in ("completed", "failed", "cancelled"):
breakTwo things to get right. Respect pollIntervalMs — it is the server telling you what it can afford. And persist the task id if the work matters, because the whole benefit is that it survives a crash, which it cannot do if the id was only ever in memory.
What to take into the next lesson
A task is a durable handle returned instead of a blocked connection: resultType: "task" carries a taskId, a poll interval and a TTL, and the five states end in three terminal ones. Return one when the work is long, needs a human, wraps an existing job system, or faces unreliable clients. Next: what happens when the task itself needs to ask a question.