Skip to content

Commit aff91c2

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 9bdd698 commit aff91c2

8 files changed

Lines changed: 388 additions & 1 deletion

File tree

docs/advanced/extensions.md

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

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

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

docs/advanced/tasks.md

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

docs_src/tasks/__init__.py

Whitespace-only changes.

docs_src/tasks/tutorial001.py

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

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ nav:
6464
- Middleware: advanced/middleware.md
6565
- Extensions: advanced/extensions.md
6666
- MCP Apps: advanced/apps.md
67+
- Tasks: advanced/tasks.md
6768
- Troubleshooting: troubleshooting.md
6869
- Migration Guide: migration.md
6970
- API Reference: api/

0 commit comments

Comments
 (0)