Tasks
A task is a tools/call answered by reference: instead of the CallToolResult,
the server returns a CreateTaskResult carrying a task id, and the client fetches
the outcome with tasks/get. That is
SEP-2663, and the SDK
ships it as the built-in Tasks extension (io.modelcontextprotocol/tasks).
If Extensions are new to you, skim that page first. One minute,
then come back.
Opting in, both sides
from mcp import Client
from mcp.client import TasksExtension
from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks
mcp = MCPServer("bakery", extensions=[Tasks()])
@mcp.tool()
def bake(flavor: str) -> str:
"""Bake a cake."""
return f"One {flavor} cake, ready."
async def main() -> None:
async with Client(mcp, extensions=[TasksExtension()]) as client:
result = await client.call_tool("bake", {"flavor": "lemon"})
print(result.content)
# [TextContent(text='One lemon cake, ready.')]
extensions=[Tasks()]: the server advertisesio.modelcontextprotocol/tasksundercapabilities.extensionsand servestasks/get,tasks/update, andtasks/cancel.Client(mcp, extensions=[TasksExtension()]): the client declares the extension back —TasksExtension(frommcp.client) is the client half, aClientExtensionwhose result claim admits and resolves thetaskresultType ontools/call. Only a declaring client'stools/callmay be answered with a task.client.call_tool(...)does not change. When the answer comes back as aCreateTaskResult, the client pollstasks/get— honoring the server'spollIntervalMshint, one second between polls in its absence — and surfaces only the finalCallToolResult. Afailedtask raises the typedTaskFailedErrorcarrying the inlined JSON-RPC error; acancelledone raisesTaskCancelledError; aninput_requiredone raisesTaskInputRequiredError— the automatic in-task input loop is not implemented yet, so drive that task manually (below). All three subclassTaskError, so oneexcept TaskErrorcatches any non-completion.
Degradation is built in. A modern client that does not declare the extension is
never augmented: it keeps getting plain CallToolResults. And a legacy
(2025-11-25) connection cannot negotiate the extension at all — the capability
rides server/discover, which a legacy handshake doesn't have — so for that
client the feature does not exist. Your tools don't change either way.
The server decides
Augmentation is the server's call, per request: the client's declaration is
permission, not a trigger. Tasks() augments every call from a declaring client;
pass augment= to be choosier:
from mcp_types import CallToolRequestParams
from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks
SLOW_TOOLS = {"transcode"}
def augment(params: CallToolRequestParams) -> bool:
return params.name in SLOW_TOOLS
mcp = MCPServer("studio", extensions=[Tasks(augment=augment, default_ttl_ms=60_000)])
@mcp.tool()
def transcode(clip: str) -> str:
"""Transcode a clip to the house format."""
return f"{clip} transcoded."
@mcp.tool()
def ping() -> str:
"""Liveness probe."""
return "pong"
augmentsees the validatedCallToolRequestParamsfor each call. ReturnFalseand the call passes through untouched, exactly as for a non-declaring client — errors included. Heretranscodebecomes a task;pingnever does.default_ttl_msbounds retention. It is stamped on the wire asttlMs, and the record is dropped once the deadline passes. The defaultNoneretains records for the store's lifetime.clock(not shown) injects the source of time behind the wire timestamps and TTL deadlines. Inject a fixed clock for deterministic tests.
Where tasks live
Records persist through a TaskStore, a two-method protocol:
class TaskStore(Protocol):
async def put(self, record: TaskRecord) -> None: ...
async def get(self, task_id: str) -> TaskRecord | None: ...
The default InMemoryTaskStore is per-process: right for stdio servers and
single-process development, wrong for a multi-worker HTTP deployment. SEP-2663
requires a task to be durably recorded before its CreateTaskResult is returned,
and a poll routed to another worker must find it — back Tasks(store=...) with
shared storage there. get returning None is the whole expiry contract: unknown
and expired ids look identical, and both answer -32602 on the wire.
Task ids are unguessable bearer capabilities (at least 128 bits of entropy): any caller presenting a valid id may poll the task, which is what lets a reconnecting client resume. Need stricter scoping or audited access? That is a custom store's job.
What execution actually looks like
In this SDK the tool still runs inline, to completion, inside the tools/call
request. A task is therefore born terminal:
- The tool produced a result → a
completedtask, with theCallToolResultinlined ontasks/get. A result withisError: trueis stillcompleted; tool-level failure is a result, not a protocol error. - The call raised a JSON-RPC error (an
MCPError) → afailedtask, with the error inlined ontasks/get. The declaring client receives a failedCreateTaskResultinstead of the JSON-RPC error, and the transparent driver turns it intoTaskFailedError. tasks/cancelandtasks/updateacknowledge and change nothing: cancellation is cooperative in SEP-2663, and here the work has always finished before atasks/*request can arrive.
A multi-round-trip interim (input_required) passes through
un-augmented: the exchange resolves on the original tools/call, and only the leg
that produces the final result becomes a task.
What augmentation buys today is the wire shape and the retention: the result of an
expensive call outlives the request that computed it, fetchable by id for ttlMs.
Background execution is on the roadmap (below).
Driving the task yourself
The transparent flow is a convenience, not a requirement. Drop one layer to get the
CreateTaskResult, then drive tasks/* with the typed functions in
mcp.client.tasks:
from mcp import Client
from mcp.client import TasksExtension
from mcp.client.tasks import get_task, wait_task
from mcp.server.mcpserver import MCPServer
from mcp.server.tasks import Tasks
from mcp.shared.tasks import CreateTaskResult
mcp = MCPServer("bakery", extensions=[Tasks()])
@mcp.tool()
def bake(flavor: str) -> str:
"""Bake a cake."""
return f"One {flavor} cake, ready."
async def main() -> None:
async with Client(mcp, extensions=[TasksExtension()]) as client:
created = await client.session.call_tool("bake", {"flavor": "mocha"}, allow_claimed=True)
assert isinstance(created, CreateTaskResult)
print(created.status)
# completed
polled = await get_task(client.session, created.task_id)
assert polled.result is not None
print(polled.result["content"])
# [{'text': 'One mocha cake, ready.', 'type': 'text'}]
result = await wait_task(client.session, created)
print(result.content)
# [TextContent(text='One mocha cake, ready.')]
session.call_tool(..., allow_claimed=True)returns the typedCreateTaskResultinstead of polling. Without the flag, an unexpectedCreateTaskResultraisesRuntimeErrorrather than leaking the widened union into code that expected aCallToolResult.get_taskis onetasks/get: it returns theGetTaskResultsnapshot —resultis set on acompletedtask,erroron afailedone, never both.wait_taskpolls to a terminal status and returns the finalCallToolResult, raising the same typed errors as the transparent flow. Pass theCreateTaskResultand itspollIntervalMshint seeds the cadence — or pass a bare task id: task ids are bearer capabilities (above), so a client that reconnected, or restarted with nothing but the persisted id, can resume a task it no longer holds theCreateTaskResultfor.update_taskanswers a task's in-taskinputRequests, andcancel_taskasks the server to stop one. Both hide the empty acknowledgement and returnNone. Cancellation is cooperative in SEP-2663 — it may never take effect, and in this SDK the work has always finished already — so follow withget_taskfor the status that actually resulted.
Who sees what
| Caller | tools/call |
tasks/* |
|---|---|---|
| Declaring 2026-07-28 client | may be augmented into a task | served |
| Non-declaring 2026-07-28 client | plain CallToolResult, always |
-32021 missing required client capability |
| Legacy (2025-11-25) connection | plain CallToolResult, always |
-32601 method not found |
The split on tasks/* is deliberate. A modern client could fix its declaration, so
it gets the capability error with the machine-readable requiredCapabilities
payload; a legacy client could not, so for it the methods simply don't exist. A
declaring client naming an unknown — or expired — task id gets -32602 (invalid
params).
Over Streamable HTTP, every tasks/* request carries the Mcp-Name: <taskId>
routing header (SEP-2663 via SEP-2243) so intermediaries can route the poll to the
instance holding the task's state. The SDK stamps it client-side and validates it
server-side; you never touch it.
Roadmap
This is the core SEP-2663 surface. Background execution (tasks created working
and resolved later), the in-task input_required loop over tasks/update, and
notifications/tasks over subscriptions/listen build on it as planned
follow-ups — each needs deeper SDK plumbing, and the wire contract above is
already shaped for them.