MCP in Practice

Lesson 25 of 36

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.

A task runs, may pause for input, and ends in one of three terminal states
A task runs, may pause for input, and ends in one of three terminal states

What comes back instead of an answer

A client declares the extension in its capabilities:

json
{
  "_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:

json
{
  "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:

json
{"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:

json
{
  "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. statusMessage is 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. result holds the answer.
  • failed — errored. error holds 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

python
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"):
            break

Two 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.

← Previous