If you read one line: In the final MCP 2026-07-28 spec, Tasks became the io.modelcontextprotocol/tasks extension — so a server can answer a tools/call with a task handle instead of a final result, and your agent polls tasks/get until the job is done rather than holding an HTTP connection open for the whole run. Three methods matter: tasks/get (read), tasks/update (answer mid-run input), tasks/cancel (stop). Built on the old experimental API? You have to migrate.

Long-running agents finally have a first-class answer to a stubborn question: how do you run a job that takes ten minutes — or three hours — over a protocol whose core just went stateless? The old reflex was to keep a connection open and stream until done. That breaks the moment the socket drops, the load balancer reshuffles you to a different instance, or the work outlives any reasonable HTTP timeout. Tasks decouple the work from the transport.

1. What actually changed on July 28#

Tasks first shipped as an experimental core feature in the 2025-11-25 revision. In the final 2026-07-28 specification, the maintainers decided the right home for it was an extension, not the core spec. So Tasks moved out of the experimental core and into the io.modelcontextprotocol/tasks extension, with a poll-based tasks/get and a new tasks/update (SEP-2663). It now sits under the same versioned extensions framework (SEP-2133) as MCP Apps and Enterprise-Managed Authorization.

What it means for you: Tasks are no longer something every client and server must reason about. They're an opt-in capability you negotiate — a reverse-DNS-named extension — which keeps the stateless core small and lets long-running work be a deliberate choice rather than an ambient feature. If you want the wider platform view, we covered how Apps, Tasks, and the extensions framework reshape MCP separately.

2. The mental model: a handle, not a held connection#

Here's the shape. Your client advertises that it supports the Tasks extension. When it makes a tools/call that the server knows will run long, the server doesn't block and stream — it answers with a task handle: a CreateTaskResult carrying resultType: "task" and a task object with a taskId. Task creation is server-directed — the client advertises the extension, and the server decides when a given call should run as a task (RC notes).

From there, the client owns a durable handle it can poll. The connection that created the task can close. The client can reconnect to any server instance. The job keeps running.

A task handle turns "hold this socket open and pray" into "here's a receipt — check back whenever." That's the difference between an agent that survives a network blip and one that loses three hours of work to it.

What it means for you: this is the natural fit for long-running and deep agents. The agent fires off a slow tool call, gets a handle, and is free to do other work — or to be suspended and resumed — while the job churns. It also composes cleanly with the broader polling-vs-webhooks tradeoff you already face when wiring background work.

3. The task object and its status values#

The task carries everything the client needs to drive it, per the Tasks extension overview:

The status moves through a small state machine:

StatusMeaning
workingoperation in progress — keep polling
input_requiredthe task is blocked waiting on client input
completedsucceeded; the response carries result
failederrored; the response carries error
cancelledthe task was cancelled

completed, failed, and cancelled are terminal — once you see one, stop polling.

What it means for you: the server tells you how often to poll (pollIntervalMs) and how long the handle is good for (ttlMs). Honor both. Polling faster than pollIntervalMs just burns requests; ignoring ttlMs risks calling tasks/get on a handle the server has already reaped.

4. The poll loop: tasks/get until terminal#

The whole client-side flow is: make the call, get a handle, poll. First, the tools/call comes back as a task instead of a result:

// Server's response to a long-running tools/call:
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "task",              // the discriminator: this is a handle, not a result
    "task": {
      "taskId": "task_01JQ4X...",
      "status": "working",
      "pollIntervalMs": 2000,          // don't poll faster than this
      "ttlMs": 3600000,                // handle is valid for ~1h
      "createdAt": "2026-07-30T12:00:00Z"
    }
  }
}

Then the client polls tasks/get with that taskId, sleeping for pollIntervalMs between calls, until it hits a terminal status. There is no tasks/result method — the final result (or error) arrives inside tasks/get.

# Poll a task handle to completion. Field names track SEP-2663 / the Tasks extension.
def await_task(client, task):
    while task["status"] == "working":
        time.sleep(task.get("pollIntervalMs", 2000) / 1000)

        # tasks/get is the read side — idempotent and cacheable.
        resp = client.request("tasks/get", {"taskId": task["taskId"]})
        task = resp["task"]

        if task["status"] == "input_required":
            fulfill_inputs(client, task)      # see section 5, then loop again

    if task["status"] == "completed":
        return task["result"]                 # final result lives on the terminal task
    if task["status"] == "failed":
        raise TaskFailed(task.get("error"))
    if task["status"] == "cancelled":
        raise TaskCancelled(task["taskId"])

What it means for you: because reads are idempotent, a dropped tasks/get is safe to retry, and an intermediary can cache it. Store the taskId somewhere durable — if your agent restarts mid-job, the handle is how it reconnects. (Losing the handle is a real failure mode; we wrote up how not to orphan an MCP task.)

5. tasks/update: answering the task mid-run#

Sometimes a long job needs something partway through — a confirmation, a missing parameter, a choice. When that happens the task goes to status: "input_required" and populates its inputRequests map. You answer with tasks/update.

def fulfill_inputs(client, task):
    # inputRequests describes what the server is blocked on.
    answers = {}
    for request_id, req in task["inputRequests"].items():
        # gather from the user or the model, keyed to each request
        answers[request_id] = collect_answer(req)

    # tasks/update is the WRITE side — it submits the answers so the task continues.
    # (Exact param field names follow SEP-2663 / the Tasks extension spec.)
    client.request("tasks/update", {
        "taskId": task["taskId"],
        "inputResponses": answers,
    })
    # After updating, resume polling tasks/get — status returns to "working".

Reads and writes are deliberately split: tasks/get (read) stays idempotent and cacheable, while tasks/update (write) is where state changes. To stop a task early, there's tasks/cancel — a signal of cancellation intent, after which you poll until the status settles to cancelled.

What it means for you: input_required is what makes Tasks usable for jobs that aren't fully autonomous — a data export that needs a confirmation, an agent that hits an ambiguous fork. The pattern is a clean async version of elicitation: block, ask, resume.

6. If you built on the old Tasks API, migrate#

This is a breaking change for early adopters. Anyone who shipped against the 2025-11-25 experimental Tasks API needs to migrate to the new lifecycle. Two specifics to plan around:

What it means for you: if Tasks are load-bearing in your stack, treat this like the rest of the 2026-07-28 migration — test against a client that speaks the extension before you flip production over. The deprecation grace on old behavior buys you time, but the Tasks lifecycle itself genuinely changed shape.

The one-screen recap#

  1. Negotiate the extension. Advertise io.modelcontextprotocol/tasks; let the server decide when a tools/call runs as a task.
  2. Take the handle. A resultType: "task" response gives you a taskId, pollIntervalMs, and ttlMs.
  3. Poll tasks/get at the server's interval until a terminal status; the result rides inside the terminal task.
  4. Answer with tasks/update when status hits input_required; tasks/cancel to stop.
  5. Persist the taskId so a restarted agent can reconnect — no held connection required.

The through-line is the same one that runs through everything we cover on keeping long-running agents alive inside their context: the hard part of a slow agent isn't the model, it's the plumbing that lets the work outlive the connection. Tasks are that plumbing, now standardized.