MCP Tasks: long-running work without holding a connection

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. 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 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, 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, 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)."""

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

tools/call → working: returns resultType task; working → input_required: server needs client input; input_required → working: tasks/update with inputResponses; working → completed: result available, including isError results; working → failed: JSON-RPC error during execution; working → cancelled: tasks/cancel honoured; input_required → cancelled: tasks/cancel honouredtools/callworkinginput_requiredcompletedfailedcancelledreturns resultType taskserver needsclient inputtasks/update withinputResponsesresult available,including isError resultsJSON-RPC errorduring executiontasks/cancel honouredtasks/cancel honoured
Statustasks/get also returns
workingJust the task
input_requiredinputRequests: all outstanding server-to-client requests
completedresult: exactly what the original request would have returned
failederror: the JSON-RPC error, only for protocol-level failures
cancelledJust the task

Three methods and a notification

MethodPurposeResponse
tasks/getPoll the current stateThe full task with its status-specific field; a pure, idempotent read
tasks/updateSupply inputResponses for outstanding inputRequestsAn empty acknowledgement, eventually consistent
tasks/cancelSignal the intent to cancelAn empty acknowledgement, cooperative and eventually consistent
notifications/tasksOptional server push of the full task stateOpt 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:

Client → Server: tools/call hello_world + clientCapabilities .extensions tasks; Note (Server): create task durably (tasks/get would now succeed); Server → Worker: enqueue job; Server → Client: resultType task, taskId 7865..., status working, pollIntervalMs 5000; loop: every pollIntervalMs; Client → Server: tasks/get (Mcp-Name 7865...); Server → Client: status working; Worker → Server: needs the user's name; Client → Server: tasks/get; Server → Client: status input_required, inputRequests name (elicitation form); Client → User: "Please enter your name"; User → Client: Luca; Client → Server: tasks/update inputResponses name (accept, Luca); Server → Client: ack (empty result); Client → Server: tasks/get; Server → Client: status working; Worker → Server: done; Client → Server: tasks/get; Server → Client: status completed, result content "Hello, Luca!"WorkerServerClientcreate task durably(tasks/get wouldnow succeed)loop[every pollIntervalMs]Usertools/call hello_world+ clientCapabilities.extensions tasksenqueue jobresultType task,taskId 7865...,status working,pollIntervalMs 5000tasks/get(Mcp-Name 7865...)status workingneeds theuser's nametasks/getstatus input_required,inputRequests name(elicitation form)"Please enteryour name"Lucatasks/updateinputResponses name(accept, Luca)ack (empty result)tasks/getstatus workingdonetasks/getstatus completed,result content"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 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) 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:

MCP server instances: tools/call handler create record, enqueue, return task, tasks/get handler read record, tasks/update / tasks/cancel write record, ack; MCP client → tools/call handler create record, enqueue, return task: tools/call; MCP client → tasks/get handler read record: tasks/get Mcp-Name = taskId; MCP client → tasks/update / tasks/cancel write record, ack: tasks/update, tasks/cancel; tools/call handler create record, enqueue, return task → Durable task store Cosmos DB, SQL, Table Storage; tools/call handler create record, enqueue, return task → Job queue Service Bus, SQS; tasks/get handler read record → Durable task store Cosmos DB, SQL, Table Storage; tasks/update / tasks/cancel write record, ack → Durable task store Cosmos DB, SQL, Table Storage; Job queue Service Bus, SQS → Workers idempotent, write statusMessage; Workers idempotent, write statusMessage → Durable task store Cosmos DB, SQL, Table Storage; Workers idempotent, write statusMessage → Pub/sub, optional for notifications/tasksMCP server instancesMCP clienttools/call handlercreate record, enqueue, return tasktasks/get handlerread recordtasks/update / tasks/cancelwrite record, ackDurable task storeCosmos DB, SQL, Table StorageJob queueService Bus, SQSWorkersidempotent, write statusMessagePub/sub, optionalfor notifications/taskstools/calltasks/getMcp-Name = taskIdtasks/update, tasks/cancel

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

SituationUse
Finishes in secondsPlain tools/call
Quick, but needs a confirmation or a missing value firsttools/call with MRTR
Minutes or hours, batch jobs, wraps an external job APITasks
Long and needs input before startingMRTR rounds, then return a task
Long and needs input midwayTasks 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 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.