# MCP Tasks: long-running work without holding a connection

October 24, 2026 · Part 26 of 35

![MCP Tasks: long-running work without holding a connection](https://pournasserian.com/_astro/lead.Bhrd0RkO_ZPANWD.webp)

In today's Model Context Protocol (MCP), a dropped HTTP stream cancels the request, and nothing can resume it. That's fine for a three-second call and fatal for a 20-minute report. What I like about the Tasks extension is how little it asks of the connection: the server hands back a handle, the client polls, and nothing stays connected while the work runs.

This is part 26 of my series on what an MCP server does under the 2026-07-28 specification, after [building an MCP App in part 25](https://pournasserian.com/writing/mcp-2026-apps-building). The facts are as I read them in October 2026.

## In brief

1. **A task is a handle in place of a result.** The server answers `tools/call` with a task, and the client polls `tasks/get` until it ends.
2. **The server decides, one request at a time, whether to return a task,** and it may return one only to a `tools/call` that declared the extension.
3. **A tool result with `isError: true` makes a `completed` task, not a `failed` one.** `failed` is reserved for JSON-RPC 2.0 errors.
4. **Store the task durably before you return it,** give it an unguessable ID, and check authorization on every task call.
5. **The official Python software development kit (SDK) doesn't implement Tasks yet,** so the Task type below is plain Pydantic.

## Why Tasks exist, and who decides

The Tasks extension's identifier is `io.modelcontextprotocol/tasks`. It's defined by [Specification Enhancement Proposal (SEP) 2663](https://modelcontextprotocol.io/seps/2663-tasks-extension) and negotiated like the other extensions in [part 23](https://pournasserian.com/writing/mcp-2026-extensions). It fits expensive computations, batch processing and work that wraps an external job application programming interface (API). Blocking isn't a real alternative ([part 16](https://pournasserian.com/writing/mcp-2026-progress-and-cancellation)): the [Tasks overview](https://modelcontextprotocol.io/extensions/tasks/overview) warns that many clients and transport intermediaries impose timeouts that make it impractical beyond a few seconds.

The extension also fixes the experimental tasks of the 2025-11-25 specification: a fragile opt-in at both the method and the tool level, a blocking `tasks/result` that forced long-lived connections, and a `tasks/list` that couldn't be scoped to one caller once sessions were gone.

Negotiation leaves the choice with the server:

- The client declares the extension in its per-request capabilities, and the server advertises it in [`server/discover`](https://pournasserian.com/writing/mcp-2026-server-discover).
- The server alone decides, request by request, whether to return a task (a `CreateTaskResult`, marked `resultType: "task"`) or a normal result. Declaring support doesn't mean the client requires a task.
- A server MUST NOT return a task to a request that didn't declare the extension, and a declaring client MUST handle either shape. If the work can't be done without a task and the client didn't opt in, the server returns `-32021` (Missing Required Client Capability).
- Only `tools/call` supports tasks today, and implementations SHOULD expect more methods later.

## The task, its states and its methods

### The task object in Python

The official Python SDK, `mcp` 2.3.0, doesn't implement the extension yet: [pull request #3005](https://github.com/modelcontextprotocol/python-sdk/pull/3005), which adds it, was still open on 9 October 2026. The SDK's own `mcp.types.Task` is documented as 2025-11-25 only, with `ttl` and `poll_interval`, so it isn't SEP-2663's task. Here is that task in plain Pydantic, with the wire's camelCase names as aliases:

```python
from datetime import datetime
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

TaskStatus = Literal["working", "input_required", "completed", "cancelled", "failed"]

class Task(BaseModel):
    """The Task object of the Tasks extension (SEP-2663)."""

    # JSON in and out uses the wire names: taskId, ttlMs
    model_config = ConfigDict(
        alias_generator=to_camel, validate_by_name=True, serialize_by_alias=True
    )

    task_id: str                         # stable, server-generated, unguessable
    status: TaskStatus
    status_message: str | None = None    # human-readable progress or reason
    created_at: datetime                 # ISO 8601
    last_updated_at: datetime            # ISO 8601
    ttl_ms: int | None                   # required; None means unlimited
    poll_interval_ms: int | None = None  # suggested polling cadence

class TaskState(Task):
    """A tasks/get result: the task plus the field its status adds."""

    input_requests: dict[str, Any] | None = None  # input_required
    result: dict[str, Any] | None = None          # completed
    error: dict[str, Any] | None = None           # failed
```

Here it parses the final `tasks/get` reply of the flow drawn further down:

```python
reply = """{
  "resultType": "complete", "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
  "status": "completed", "createdAt": "2026-10-09T10:30:00Z",
  "lastUpdatedAt": "2026-10-09T10:50:00Z", "ttlMs": 3600000, "pollIntervalMs": 5000,
  "result": {"content": [{"type": "text", "text": "Hello, Luca!"}], "isError": false}
}"""

state = TaskState.model_validate_json(reply)
print(state.status, state.ttl_ms, state.result["content"][0]["text"])
```

It printed:

```text
completed 3600000 Hello, Luca!
```

Both `ttlMs` and `pollIntervalMs` may change during a task's life.

### Five statuses, three of them final

A task starts `working`, may wait at `input_required` for an answer, and ends `completed`, `failed` or `cancelled`:

```mermaid
flowchart TB
    start([tools/call])
    working(working)
    ir(input_required)
    completed(completed)
    failed(failed)
    cancelled(cancelled)
    start -->|returns resultType task| working
    working -->|server needs<br/>client input| ir
    ir -->|tasks/update with<br/>inputResponses| working
    working -->|result available,<br/>including isError results| completed
    working -->|JSON-RPC error<br/>during execution| failed
    working -->|tasks/cancel honoured| cancelled
    ir -->|tasks/cancel honoured| cancelled
```

| Status | `tasks/get` also returns |
|---|---|
| `working` | Just the task |
| `input_required` | `inputRequests`: all outstanding server-to-client requests |
| `completed` | `result`: exactly what the original request would have returned |
| `failed` | `error`: the JSON-RPC error, only for protocol-level failures |
| `cancelled` | Just the task |

### Three methods and a notification

| Method | Purpose | Response |
|---|---|---|
| `tasks/get` | Poll the current state | The full task with its status-specific field; a pure, idempotent read |
| `tasks/update` | Supply `inputResponses` for outstanding `inputRequests` | An empty acknowledgement, eventually consistent |
| `tasks/cancel` | Signal the intent to cancel | An empty acknowledgement, cooperative and eventually consistent |
| `notifications/tasks` | Optional server push of the full task state | Opt in through [`subscriptions/listen`](https://pournasserian.com/writing/mcp-2026-subscriptions-listen) with `taskIds` |

Reads and writes are separate on purpose: `tasks/get` stays cacheable and safe to retry, and all write semantics live in `tasks/update`. Here is a complete flow, with a question for the user midway:

```mermaid
sequenceDiagram
    actor U as User
    participant C as Client
    participant S as Server
    participant W as Worker

    C->>S: tools/call hello_world<br/>+ clientCapabilities<br/>.extensions tasks
    Note over S: create task durably<br/>(tasks/get would<br/>now succeed)
    S->>W: enqueue job
    S-->>C: resultType task,<br/>taskId 7865...,<br/>status working,<br/>pollIntervalMs 5000

    loop every pollIntervalMs
        C->>S: tasks/get<br/>(Mcp-Name 7865...)
        S-->>C: status working
    end

    W->>S: needs the<br/>user's name
    C->>S: tasks/get
    S-->>C: status input_required,<br/>inputRequests name<br/>(elicitation form)
    C->>U: "Please enter<br/>your name"
    U-->>C: Luca
    C->>S: tasks/update<br/>inputResponses name<br/>(accept, Luca)
    S-->>C: ack (empty result)

    C->>S: tasks/get
    S-->>C: status working
    W->>S: done
    C->>S: tasks/get
    S-->>C: status completed,<br/>result content<br/>"Hello, Luca!"
```

## Running tasks on the server

### What the extension asks of the server

- **Durable before acknowledging.** Don't return the task until a `tasks/get` for it would succeed; in an eventually consistent store, wait for consistency.
- **Unique keys.** An `inputRequests` key is unique for the task's whole life and never reused. Outstanding requests reappear on every poll, so clients SHOULD deduplicate.
- **Eventual consistency.** An update or a cancellation may be acknowledged before `tasks/get` shows it. Cancellation is cooperative: the server needn't stop, and the task may still end `completed`.
- **Expiry.** Once `ttlMs` has elapsed, a server may mark the task `failed` and delete it; "task not found" (`-32602`) for a purged task is compliant.
- **Routing.** Over Streamable HTTP, clients MUST set the [`Mcp-Name` header](https://pournasserian.com/writing/mcp-2026-transports) to the `taskId` on every `tasks/*` call, beyond the three core methods part 7 lists, so load balancers can route to the instance or partition holding the task's state.
- **No progress or log notifications.** Tasks support neither `notifications/progress` nor `notifications/message`; use `statusMessage`.
- **No old methods.** `tasks/result` returns `-32601`, and there's no `tasks/list`.

If you need input before deciding to start, resolve those rounds of Multi Round-Trip Requests (MRTR, [part 13](https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests)) synchronously, then return the task; the two phases keep separate `inputRequests` key spaces. I recommend this for work that is both long and needs a confirmation, with the rule from [part 14](https://pournasserian.com/writing/mcp-2026-request-state): no side effects before the final input round.

### Security

A task ID may work as a bearer token, so give it enough entropy that it can't be enumerated, and check authentication and authorization on every task-related request. Without `tasks/list`, no caller can learn that another's tasks exist. And `inputRequests` arriving through a task get the same trust and user-experience treatment as direct [elicitation](https://pournasserian.com/writing/mcp-2026-elicitation).

### A durable implementation

The rule above asks that `tasks/get` can find a task before you return it. My advice goes further: back the task store with something durable, not memory, or a restart loses every task. Separate request handling from execution with a queue and workers, and make the workers idempotent, since queues redeliver. Have them write `statusMessage` as they go, because it's your only progress channel. Put together:

```mermaid
flowchart TB
    c["MCP client"]
    subgraph api["MCP server instances"]
        direction TB
        h1["tools/call handler<br/>create record, enqueue, return task"]
        h2["tasks/get handler<br/>read record"]
        h3["tasks/update / tasks/cancel<br/>write record, ack"]
    end
    store[("Durable task store<br/>Cosmos DB, SQL, Table Storage")]
    q[["Job queue<br/>Service Bus, SQS"]]
    w["Workers<br/>idempotent, write statusMessage"]
    bus[["Pub/sub, optional<br/>for notifications/tasks"]]

    c -->|tools/call| h1
    c -->|tasks/get<br/>Mcp-Name = taskId| h2
    c -->|tasks/update, tasks/cancel| h3
    h1 --> store
    h1 --> q
    h2 --> store
    h3 --> store
    q --> w
    w --> store
    w -.-> bus
```

Finally, ask clients to persist task IDs, as the SEP says they SHOULD, so polling can resume after a crash.

## When to use a task

| Situation | Use |
|---|---|
| Finishes in seconds | Plain `tools/call` |
| Quick, but needs a confirmation or a missing value first | `tools/call` with MRTR |
| Minutes or hours, batch jobs, wraps an external job API | Tasks |
| Long and needs input before starting | MRTR rounds, then return a task |
| Long and needs input midway | Tasks with `input_required`, answered by `tasks/update` |

Because the server decides, you can return a direct result when the work is fast or cached and a task when it isn't, without changing the tool's contract. Client SDKs can hide the polling and expose only the final result. In [part 12](https://pournasserian.com/writing/mcp-2026-choosing-a-primitive) I drew the line at about a minute, as a rule of thumb; the overview's timeout warning suggests a lower bar.

## Method and caveats

- Built from my guide's chapter on Tasks and one pattern from its design-patterns chapter, written against the 2026-07-28 specification and SEP-2663.
- The Python sample is plain Pydantic 2.14, installed with `mcp` 2.3.0, not SDK code. It printed the output shown on 9 October 2026, the day I also checked pull request #3005.
