The short version: the 2026-07-28 MCP core is stateless by design — any request can hit any server instance, and there's no session to keep alive. That's great for scaling and terrible for a tool call that takes four minutes. The Tasks extension is the answer: instead of blocking, the server hands back a task handle and returns immediately; your client polls tasks/get until the work is done, and can tasks/update or tasks/cancel along the way. Because the handle carries its own identity, the poll can be answered by a different instance than the one that started the job — so long work survives the stateless world. Here's the exact message flow.

Why blocking is off the table#

In the old stateful model you could get away with a synchronous slow call: open the connection, hold it, return the result when ready. The stateless core removes the ground that stood on. There's no session pinning your request to one process, requests are meant to be short so they can round-robin across instances, and a minutes-long held connection is exactly the shape stateless infrastructure is built to shed.

So MCP swaps hold the line for take a ticket and check back. That ticket is a task handle.

Step 1 — advertise that your client can handle tasks#

Task creation is server-directed, but it only happens if the client opts in. Your client declares, in the capabilities it sends, that it understands tasks. (Tasks is an extension, so it's negotiated like any other — the client and server agree on it up front rather than assuming it.)

// client capabilities (illustrative shape)
{
  "capabilities": {
    "tasks": {},                 // "I can receive and drive task handles"
    "extensions": {
      "io.modelcontextprotocol.tasks": { "version": "1" }
    }
  }
}

The rule to internalize: advertising the capability doesn't force every call to become a task. It grants the server permission to return a handle when it judges the work heavy enough.

Step 2 — call the tool, then branch on what comes back#

You issue a perfectly ordinary tools/call. The twist is the response: for a light call you get an inline result, exactly as before; for a heavy one the server answers with a task handle instead. Your client has to be ready for either.

// → request
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": { "name": "deep_research", "arguments": { "query": "…" } } }

// ← response (heavy call: a handle, not a result)
{ "jsonrpc": "2.0", "id": 1,
  "result": { "task": { "taskId": "tsk_9f3k2", "status": "working" } } }
# client-side branch (pseudocode against the RC shape)
resp = await session.call_tool("deep_research", {"query": q})
if resp.task:                     # server chose to run it as a task
    result = await drive_task(session, resp.task.task_id)
else:
    result = resp.content         # inline result, we're already done

Persist the taskId the moment you get it. There is no tasks/list — it was cut from the redesign over scoping concerns — so the server will not enumerate your tasks for you. The client owns the record of every handle it created.

Step 3 — poll tasks/get until it's terminal#

Now you check back. tasks/get returns the current status; once the work finishes it returns the final result in the same shape the inline call would have.

// → request
{ "jsonrpc": "2.0", "id": 2, "method": "tasks/get",
  "params": { "taskId": "tsk_9f3k2" } }

// ← still running
{ "jsonrpc": "2.0", "id": 2,
  "result": { "task": { "taskId": "tsk_9f3k2", "status": "working" } } }

// ← done
{ "jsonrpc": "2.0", "id": 3,
  "result": { "task": { "taskId": "tsk_9f3k2", "status": "completed" },
              "content": [ { "type": "text", "text": "…the result…" } ] } }
async def drive_task(session, task_id, interval=1.0, max_interval=15.0):
    while True:
        r = await session.tasks_get(task_id)
        status = r.task.status
        if status == "completed":
            return r.content
        if status in ("failed", "cancelled"):
            raise TaskError(status, r.task)
        await asyncio.sleep(interval)
        interval = min(interval * 1.5, max_interval)   # back off; don't hammer

The reason this survives statelessness is the part you don't see: the poll for tsk_9f3k2 doesn't have to reach the instance that started it. The handle is self-contained, so any instance behind the load balancer can answer — which is the whole point of the redesign.

Step 4 — feed it, or stop it#

Two more methods complete the lifecycle. tasks/update sends additional input to a running task — useful when the task needs a value mid-flight, for example one gathered by an elicitation prompt. tasks/cancel asks it to stop.

// supply input the task is waiting on
{ "jsonrpc": "2.0", "id": 4, "method": "tasks/update",
  "params": { "taskId": "tsk_9f3k2", "input": { "approved": true } } }

// stop it — on user cancel, timeout, or a result you no longer need
{ "jsonrpc": "2.0", "id": 5, "method": "tasks/cancel",
  "params": { "taskId": "tsk_9f3k2" } }

Always wire tasks/cancel to your own timeouts and user-cancel paths. A stateless server won't tear the task down just because a client walked away; cancellation is a message you're expected to send.

The shape to build against#

Strip it to the loop and it's four moves: advertise the capability, call the tool and branch on handle-versus-inline, poll tasks/get with backoff until terminal, and tasks/update/tasks/cancel as needed. That's the entire long-work story in the stateless world — and it's the Tasks extension moving out of the experimental core that made it possible.

One honesty note on timing: the spec is a release candidate through July 28, and the Tier-1 SDK betas (mcp 2.0.0b1, TypeScript v2, Go 1.7.0-pre.1, C# 2.0.0-preview.1) lead with the stateless core — Tasks helpers land as they catch up. The JSON-RPC shapes above are what the RC defines and are stable to design your server and client around now; bind them to the SDK conveniences as those arrive, and pin exact preview versions when you do. If you haven't done the core migration yet, that's the prerequisite — here's the ordered checklist.