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:
taskId— the unique handlestatus— the current statestatusMessage— an optional human-readable descriptioncreatedAt/lastUpdatedAt— ISO 8601 timestampsttlMs— how long the handle lives (null means unlimited)pollIntervalMs— the server's suggested polling intervalinputRequests— a map of pending inputs, populated when the task needs something from you
The status moves through a small state machine:
| Status | Meaning |
|---|---|
working | operation in progress — keep polling |
input_required | the task is blocked waiting on client input |
completed | succeeded; the response carries result |
failed | errored; the response carries error |
cancelled | the 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:
tasks/listis removed. It can't be scoped safely without sessions, and the 2026-07-28 core is stateless. Track your owntaskIds client-side instead of asking the server to enumerate them.- Creation is server-directed and extension-gated. You advertise
io.modelcontextprotocol/tasks; the server decides when a call becomes a task. Don't assume you can force any call into task mode.
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#
- Negotiate the extension. Advertise
io.modelcontextprotocol/tasks; let the server decide when atools/callruns as a task. - Take the handle. A
resultType: "task"response gives you ataskId,pollIntervalMs, andttlMs. - Poll
tasks/getat the server's interval until a terminal status; the result rides inside the terminal task. - Answer with
tasks/updatewhen status hitsinput_required;tasks/cancelto stop. - Persist the
taskIdso 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.



