|
| 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. |
0 commit comments