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

> Source: <https://pournasserian.com/writing/mcp-2026-tasks>
> Published: 2026-10-24 00:00:00+00:00

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

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:

```
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:

```
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`:

| 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:

## 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:

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.
