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. The facts are as I read them in October 2026.
In brief #
- A task is a handle in place of a result. The server answers
tools/callwith a task, and the client pollstasks/getuntil it ends. - The server decides, one request at a time, whether to return a task, and it may return one only to a
tools/callthat declared the extension. - A tool result with
isError: truemakes acompletedtask, not afailedone.failedis reserved for JSON-RPC 2.0 errors. - Store the task durably before you return it, give it an unguessable ID, and check authorization on every task call.
- 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 and negotiated like the other extensions in part 23. 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): the 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. - The server alone decides, request by request, whether to return a task (a
CreateTaskResult, markedresultType: "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/callsupports 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, 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:
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)."""
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 outstandinginputRequests |
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 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/getfor it would succeed; in an eventually consistent store, wait for consistency. - Unique keys. An
inputRequestskey 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/getshows it. Cancellation is cooperative: the server needn’t stop, and the task may still endcompleted. - Expiry. Once
ttlMshas elapsed, a server may mark the taskfailedand delete it; “task not found” (-32602) for a purged task is compliant. - Routing. Over Streamable HTTP, clients MUST set the
Mcp-Nameheader to thetaskIdon everytasks/*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/progressnornotifications/message; usestatusMessage. - No old methods.
tasks/resultreturns-32601, and there’s notasks/list.
If you need input before deciding to start, resolve those rounds of Multi Round-Trip Requests (MRTR, part 13) 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: 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.
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 bytasks/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 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
mcp2.3.0, not SDK code. It printed the output shown on 9 October 2026, the day I also checked pull request #3005.