Skip to content

Commit 53b6c65

Browse files
committed
Document the Tasks extension
Adds docs/advanced/tasks.md (sibling of the MCP Apps page) covering the server opt-in with the augment/default_ttl_ms/clock knobs, the pluggable TaskStore protocol and its per-process caveat, the inline execution model (tasks born terminal; acks without effect), transparent client polling with the typed task errors, manual driving over the mcp.shared.tasks wrappers, and the per-wire error-code table. Three compiled docs_src tutorials back the page, each behaviorally tested; the page is registered in the mkdocs nav (which also feeds llms.txt). The migration note and module docstring no longer list routing headers as deferred -- Mcp-Name stamping and validation landed with the shared header table.
1 parent b25a533 commit 53b6c65

9 files changed

Lines changed: 387 additions & 5 deletions

File tree

docs/advanced/extensions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ Pass instances at construction:
1818
Done. The server now advertises `io.modelcontextprotocol/ui` under
1919
`capabilities.extensions` and serves everything the extension contributes.
2020

21-
`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**.
21+
Two built-in reference extensions ship with the SDK, and each gets its own page:
22+
**[MCP Apps](apps.md)** and **[Tasks](tasks.md)**.
2223

2324
!!! note
2425
Extensions are fixed at construction. There is no `add_extension` to call later:

docs/advanced/tasks.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Tasks
2+
3+
A **task** is a `tools/call` answered by reference: instead of the `CallToolResult`,
4+
the server returns a `CreateTaskResult` carrying a task id, and the client fetches
5+
the outcome with `tasks/get`. That is
6+
[SEP-2663](https://modelcontextprotocol.io/seps/2663-tasks-extension.md), and the SDK
7+
ships it as the built-in `Tasks` extension (`io.modelcontextprotocol/tasks`).
8+
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
9+
then come back.
10+
11+
## Opting in, both sides
12+
13+
```python title="server.py" hl_lines="5 15 16"
14+
--8<-- "docs_src/tasks/tutorial001.py"
15+
```
16+
17+
* `extensions=[Tasks()]`: the server advertises `io.modelcontextprotocol/tasks`
18+
under `capabilities.extensions` and serves `tasks/get`, `tasks/update`, and
19+
`tasks/cancel`.
20+
* `Client(mcp, extensions={EXTENSION_ID: {}})`: the client declares the extension
21+
back. Only a declaring client's `tools/call` may be answered with a task.
22+
* `client.call_tool(...)` does not change. When the answer comes back as a
23+
`CreateTaskResult`, the client polls `tasks/get` — honoring the server's
24+
`pollIntervalMs` hint, one second between polls in its absence — and surfaces
25+
only the final `CallToolResult`. A `failed` task raises the typed
26+
`TaskFailedError` carrying the inlined JSON-RPC error; a `cancelled` one raises
27+
`TaskCancelledError`.
28+
29+
Degradation is built in. A modern client that does not declare the extension is
30+
never augmented: it keeps getting plain `CallToolResult`s. And a legacy
31+
(2025-11-25) connection cannot negotiate the extension at all — the capability
32+
rides `server/discover`, which a legacy handshake doesn't have — so for that
33+
client the feature does not exist. Your tools don't change either way.
34+
35+
## The server decides
36+
37+
Augmentation is the server's call, per request: the client's declaration is
38+
permission, not a trigger. `Tasks()` augments every call from a declaring client;
39+
pass `augment=` to be choosier:
40+
41+
```python title="server.py" hl_lines="9-10 13"
42+
--8<-- "docs_src/tasks/tutorial002.py"
43+
```
44+
45+
* `augment` sees the validated `CallToolRequestParams` for each call. Return
46+
`False` and the call passes through untouched, exactly as for a non-declaring
47+
client — errors included. Here `transcode` becomes a task; `ping` never does.
48+
* `default_ttl_ms` bounds retention. It is stamped on the wire as `ttlMs`, and the
49+
record is dropped once the deadline passes. The default `None` retains records
50+
for the store's lifetime.
51+
* `clock` (not shown) injects the source of time behind the wire timestamps and
52+
TTL deadlines. Inject a fixed clock for deterministic tests.
53+
54+
## Where tasks live
55+
56+
Records persist through a `TaskStore`, a two-method protocol:
57+
58+
```python
59+
class TaskStore(Protocol):
60+
async def put(self, record: TaskRecord) -> None: ...
61+
async def get(self, task_id: str) -> TaskRecord | None: ...
62+
```
63+
64+
The default `InMemoryTaskStore` is **per-process**: right for stdio servers and
65+
single-process development, wrong for a multi-worker HTTP deployment. SEP-2663
66+
requires a task to be durably recorded before its `CreateTaskResult` is returned,
67+
and a poll routed to another worker must find it — back `Tasks(store=...)` with
68+
shared storage there. `get` returning `None` is the whole expiry contract: unknown
69+
and expired ids look identical, and both answer `-32602` on the wire.
70+
71+
Task ids are unguessable bearer capabilities (at least 128 bits of entropy): any
72+
caller presenting a valid id may poll the task, which is what lets a reconnecting
73+
client resume. Need stricter scoping or audited access? That is a custom store's
74+
job.
75+
76+
## What execution actually looks like
77+
78+
In this SDK the tool still runs **inline**, to completion, inside the `tools/call`
79+
request. A task is therefore born terminal:
80+
81+
* The tool produced a result → a `completed` task, with the `CallToolResult`
82+
inlined on `tasks/get`. A result with `isError: true` is still `completed`;
83+
tool-level failure is a result, not a protocol error.
84+
* The call raised a JSON-RPC error (an `MCPError`) → a `failed` task, with the
85+
error inlined on `tasks/get`. The declaring client receives a failed
86+
`CreateTaskResult` instead of the JSON-RPC error, and the transparent driver
87+
turns it into `TaskFailedError`.
88+
* `tasks/cancel` and `tasks/update` acknowledge and change nothing: cancellation
89+
is cooperative in SEP-2663, and here the work has always finished before a
90+
`tasks/*` request can arrive.
91+
92+
A [multi-round-trip](multi-round-trip.md) interim (`input_required`) passes through
93+
un-augmented: the exchange resolves on the original `tools/call`, and only the leg
94+
that produces the final result becomes a task.
95+
96+
What augmentation buys today is the wire shape and the retention: the result of an
97+
expensive call outlives the request that computed it, fetchable by id for `ttlMs`.
98+
Background execution is on the roadmap (below).
99+
100+
## Driving the task yourself
101+
102+
The transparent flow is a convenience, not a requirement. Drop one layer to get the
103+
`CreateTaskResult` and poll on your own schedule:
104+
105+
```python title="client.py" hl_lines="21 26-27"
106+
--8<-- "docs_src/tasks/tutorial003.py"
107+
```
108+
109+
* `session.call_tool(..., allow_create_task=True)` returns the typed
110+
`CreateTaskResult` instead of polling. Without the flag, an unexpected
111+
`CreateTaskResult` raises `RuntimeError` rather than leaking the widened union
112+
into code that expected a `CallToolResult`.
113+
* The `mcp.shared.tasks` wrappers — `GetTaskRequest`, `UpdateTaskRequest`,
114+
`CancelTaskRequest` — drive the `tasks/*` methods over `session.send_request`,
115+
and `GetTaskResult` parses the snapshot: `result` is set on a `completed` task,
116+
`error` on a `failed` one, never both.
117+
118+
## Who sees what
119+
120+
| Caller | `tools/call` | `tasks/*` |
121+
|---|---|---|
122+
| Declaring 2026-07-28 client | may be augmented into a task | served |
123+
| Non-declaring 2026-07-28 client | plain `CallToolResult`, always | `-32021` missing required client capability |
124+
| Legacy (2025-11-25) connection | plain `CallToolResult`, always | `-32601` method not found |
125+
126+
The split on `tasks/*` is deliberate. A modern client could fix its declaration, so
127+
it gets the capability error with the machine-readable `requiredCapabilities`
128+
payload; a legacy client could not, so for it the methods simply don't exist. A
129+
declaring client naming an unknown — or expired — task id gets `-32602` (invalid
130+
params).
131+
132+
Over Streamable HTTP, every `tasks/*` request carries the `Mcp-Name: <taskId>`
133+
routing header (SEP-2663 via SEP-2243) so intermediaries can route the poll to the
134+
instance holding the task's state. The SDK stamps it client-side and validates it
135+
server-side; you never touch it.
136+
137+
## Roadmap
138+
139+
This is the core SEP-2663 surface. Background execution (tasks created `working`
140+
and resolved later), the in-task `input_required` loop over `tasks/update`, and
141+
`notifications/tasks` over `subscriptions/listen` build on it as planned
142+
follow-ups — each needs deeper SDK plumbing, and the wire contract above is
143+
already shaped for them.

docs/migration.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -478,10 +478,11 @@ Two reference extensions ship in their own modules:
478478
`TaskFailedError`/`TaskCancelledError`. Manual driving stays available —
479479
`client.session.call_tool(..., allow_create_task=True)` returns the typed
480480
`CreateTaskResult`, and the `mcp.shared.tasks` request wrappers drive
481-
`tasks/get`/`tasks/update`/`tasks/cancel` over `session.send_request`. This
482-
is the core SEP-2663 surface; background execution (`working` tasks), the
483-
in-task `input_required` loop over `tasks/update`, `notifications/tasks`,
484-
and task routing headers are deferred.
481+
`tasks/get`/`tasks/update`/`tasks/cancel` over `session.send_request`, with
482+
the `Mcp-Name` routing header stamped automatically over Streamable HTTP.
483+
This is the core SEP-2663 surface — see [Tasks](advanced/tasks.md);
484+
background execution (`working` tasks), the in-task `input_required` loop
485+
over `tasks/update`, and `notifications/tasks` are deferred.
485486

486487
Extension methods are strictly additive: a `MethodBinding` cannot name a
487488
spec-defined request method, and registering one whose method collides with

docs_src/tasks/__init__.py

Whitespace-only changes.

docs_src/tasks/tutorial001.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from mcp import Client
2+
from mcp.server.mcpserver import MCPServer
3+
from mcp.server.tasks import EXTENSION_ID, Tasks
4+
5+
mcp = MCPServer("bakery", extensions=[Tasks()])
6+
7+
8+
@mcp.tool()
9+
def bake(flavor: str) -> str:
10+
"""Bake a cake."""
11+
return f"One {flavor} cake, ready."
12+
13+
14+
async def main() -> None:
15+
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
16+
result = await client.call_tool("bake", {"flavor": "lemon"})
17+
print(result.content)
18+
# [TextContent(text='One lemon cake, ready.')]

docs_src/tasks/tutorial002.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from mcp_types import CallToolRequestParams
2+
3+
from mcp.server.mcpserver import MCPServer
4+
from mcp.server.tasks import Tasks
5+
6+
SLOW_TOOLS = {"transcode"}
7+
8+
9+
def augment(params: CallToolRequestParams) -> bool:
10+
return params.name in SLOW_TOOLS
11+
12+
13+
mcp = MCPServer("studio", extensions=[Tasks(augment=augment, default_ttl_ms=60_000)])
14+
15+
16+
@mcp.tool()
17+
def transcode(clip: str) -> str:
18+
"""Transcode a clip to the house format."""
19+
return f"{clip} transcoded."
20+
21+
22+
@mcp.tool()
23+
def ping() -> str:
24+
"""Liveness probe."""
25+
return "pong"

docs_src/tasks/tutorial003.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import cast
2+
3+
import mcp_types as types
4+
5+
from mcp import Client
6+
from mcp.server.mcpserver import MCPServer
7+
from mcp.server.tasks import EXTENSION_ID, CreateTaskResult, Tasks
8+
from mcp.shared.tasks import GetTaskRequest, GetTaskRequestParams, GetTaskResult
9+
10+
mcp = MCPServer("bakery", extensions=[Tasks()])
11+
12+
13+
@mcp.tool()
14+
def bake(flavor: str) -> str:
15+
"""Bake a cake."""
16+
return f"One {flavor} cake, ready."
17+
18+
19+
async def main() -> None:
20+
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
21+
created = await client.session.call_tool("bake", {"flavor": "mocha"}, allow_create_task=True)
22+
assert isinstance(created, CreateTaskResult)
23+
print(created.status)
24+
# completed
25+
26+
request = GetTaskRequest(params=GetTaskRequestParams(task_id=created.task_id))
27+
polled = await client.session.send_request(cast("types.ClientRequest", request), GetTaskResult)
28+
assert polled.result is not None
29+
print(polled.result["content"])
30+
# [{'text': 'One mocha cake, ready.', 'type': 'text'}]

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ nav:
4747
- Middleware: advanced/middleware.md
4848
- Extensions: advanced/extensions.md
4949
- MCP Apps: advanced/apps.md
50+
- Tasks: advanced/tasks.md
5051
- OpenTelemetry: advanced/opentelemetry.md
5152
- Authorization: advanced/authorization.md
5253
- OAuth clients: advanced/oauth-clients.md

0 commit comments

Comments
 (0)