Skip to content

Commit 2e52131

Browse files
committed
Add typed client task functions in mcp.client.tasks
The private driver module becomes the public mcp.client.tasks: get_task, wait_task, update_task, and cancel_task are typed free functions over ClientSession, so manually driving a task no longer means hand-building wire requests. wait_task accepts a bare task id (the persist-and-resume shape -- task ids are bearer capabilities that survive reconnects) or the CreateTaskResult, which additionally seeds the poll-interval fallback; the polling loop now exists once, shared with the TasksExtension claim resolver. update_task and cancel_task hide the empty acknowledgement and return None. The task errors gain a common TaskError base (one except arm for any non-completion) and pickle support, since they are public API. The manual-driving docs, story leg, and tutorial are rewritten onto the functions.
1 parent 0b2e16c commit 2e52131

13 files changed

Lines changed: 673 additions & 232 deletions

File tree

docs/advanced/tasks.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ then come back.
2929
`TaskFailedError` carrying the inlined JSON-RPC error; a `cancelled` one raises
3030
`TaskCancelledError`; an `input_required` one raises `TaskInputRequiredError`
3131
the automatic in-task input loop is not implemented yet, so drive that task
32-
manually (below).
32+
manually (below). All three subclass `TaskError`, so one `except TaskError`
33+
catches any non-completion.
3334

3435
Degradation is built in. A modern client that does not declare the extension is
3536
never augmented: it keeps getting plain `CallToolResult`s. And a legacy
@@ -105,20 +106,30 @@ Background execution is on the roadmap (below).
105106
## Driving the task yourself
106107

107108
The transparent flow is a convenience, not a requirement. Drop one layer to get the
108-
`CreateTaskResult` and poll on your own schedule:
109+
`CreateTaskResult`, then drive `tasks/*` with the typed functions in
110+
`mcp.client.tasks`:
109111

110-
```python title="client.py" hl_lines="22 27-28"
112+
```python title="client.py" hl_lines="19 24 29"
111113
--8<-- "docs_src/tasks/tutorial003.py"
112114
```
113115

114116
* `session.call_tool(..., allow_claimed=True)` returns the typed
115117
`CreateTaskResult` instead of polling. Without the flag, an unexpected
116118
`CreateTaskResult` raises `RuntimeError` rather than leaking the widened union
117119
into code that expected a `CallToolResult`.
118-
* The `mcp.shared.tasks` wrappers — `GetTaskRequest`, `UpdateTaskRequest`,
119-
`CancelTaskRequest` — drive the `tasks/*` methods over `session.send_request`,
120-
and `GetTaskResult` parses the snapshot: `result` is set on a `completed` task,
121-
`error` on a `failed` one, never both.
120+
* `get_task` is one `tasks/get`: it returns the `GetTaskResult` snapshot —
121+
`result` is set on a `completed` task, `error` on a `failed` one, never both.
122+
* `wait_task` polls to a terminal status and returns the final `CallToolResult`,
123+
raising the same typed errors as the transparent flow. Pass the
124+
`CreateTaskResult` and its `pollIntervalMs` hint seeds the cadence — or pass a
125+
bare task id: task ids are bearer capabilities (above), so a client that
126+
reconnected, or restarted with nothing but the persisted id, can resume a task
127+
it no longer holds the `CreateTaskResult` for.
128+
* `update_task` answers a task's in-task `inputRequests`, and `cancel_task` asks
129+
the server to stop one. Both hide the empty acknowledgement and return `None`.
130+
Cancellation is cooperative in SEP-2663 — it may never take effect, and in this
131+
SDK the work has always finished already — so follow with `get_task` for the
132+
status that actually resulted.
122133

123134
## Who sees what
124135

docs/migration.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -484,11 +484,12 @@ Two reference extensions ship in their own modules:
484484
resultType on `tools/call`, so `Client.call_tool` admits the
485485
`CreateTaskResult`, polls `tasks/get` (honoring `pollIntervalMs`), and
486486
returns the final `CallToolResult` unchanged, while `failed`/`cancelled`
487-
tasks surface as the typed `TaskFailedError`/`TaskCancelledError`. Manual
488-
driving stays available —
487+
tasks surface as the typed `TaskFailedError`/`TaskCancelledError` (all task
488+
errors share the `TaskError` base). Manual driving stays available —
489489
`client.session.call_tool(..., allow_claimed=True)` returns the typed
490-
`CreateTaskResult`, and the `mcp.shared.tasks` request wrappers drive
491-
`tasks/get`/`tasks/update`/`tasks/cancel` over `session.send_request`, with
490+
`CreateTaskResult`, and the typed `mcp.client.tasks` functions
491+
(`get_task`/`wait_task`/`update_task`/`cancel_task`) drive
492+
`tasks/get`/`tasks/update`/`tasks/cancel` over the session, with
492493
the `Mcp-Name` routing header stamped automatically over Streamable HTTP.
493494
This is the core SEP-2663 surface — see [Tasks](advanced/tasks.md);
494495
background execution (`working` tasks), the in-task `input_required` loop

docs_src/tasks/tutorial003.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
from typing import cast
2-
3-
import mcp_types as types
4-
51
from mcp import Client
62
from mcp.client import TasksExtension
3+
from mcp.client.tasks import get_task, wait_task
74
from mcp.server.mcpserver import MCPServer
8-
from mcp.server.tasks import CreateTaskResult, Tasks
9-
from mcp.shared.tasks import GetTaskRequest, GetTaskRequestParams, GetTaskResult
5+
from mcp.server.tasks import Tasks
6+
from mcp.shared.tasks import CreateTaskResult
107

118
mcp = MCPServer("bakery", extensions=[Tasks()])
129

@@ -24,8 +21,11 @@ async def main() -> None:
2421
print(created.status)
2522
# completed
2623

27-
request = GetTaskRequest(params=GetTaskRequestParams(task_id=created.task_id))
28-
polled = await client.session.send_request(cast("types.ClientRequest", request), GetTaskResult)
24+
polled = await get_task(client.session, created.task_id)
2925
assert polled.result is not None
3026
print(polled.result["content"])
3127
# [{'text': 'One mocha cake, ready.', 'type': 'text'}]
28+
29+
result = await wait_task(client.session, created)
30+
print(result.content)
31+
# [TextContent(text='One mocha cake, ready.')]

examples/stories/tasks/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ uv run python -m stories.tasks.client --http
3939
call returns the plain `CallToolResult` — the story guards its manual leg
4040
on the negotiated capability.
4141
- The manual leg — `session.call_tool(..., allow_claimed=True)` returns the
42-
typed `CreateTaskResult` (mirroring `allow_input_required`), and the shared
43-
`mcp.shared.tasks` wrappers (`GetTaskRequest`/`GetTaskResult`) drive `tasks/get`
44-
by hand over `session.send_request`.
42+
typed `CreateTaskResult` (mirroring `allow_input_required`), and the typed
43+
`mcp.client.tasks` functions drive it by hand: `get_task` fetches one
44+
`tasks/get` snapshot with the outcome inlined, then `wait_task` polls the
45+
same task to its final `CallToolResult` — from the bare persisted id, the
46+
resume-after-reconnect shape.
4547

4648
## Scope
4749

examples/stories/tasks/client.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@
66
advises clients to keep a fixed public contract and drive the polling internally —
77
`Client.call_tool` does exactly that, so the modern path is the same typed call a
88
task-less server would get. A compact manual leg then shows the raw wire flow:
9-
`session.call_tool(allow_claimed=True)` for the typed `CreateTaskResult`, and
10-
the shared `mcp.shared.tasks` wrappers over `session.send_request` for `tasks/get`.
9+
`session.call_tool(allow_claimed=True)` for the typed `CreateTaskResult`, and the
10+
typed `mcp.client.tasks` functions (`get_task`, `wait_task`) to drive `tasks/get`.
1111
"""
1212

13-
from typing import cast
14-
1513
import mcp_types as types
1614

1715
from mcp.client import Client, TasksExtension
18-
from mcp.shared.tasks import EXTENSION_ID, CreateTaskResult, GetTaskRequest, GetTaskRequestParams, GetTaskResult
16+
from mcp.client.tasks import get_task, wait_task
17+
from mcp.shared.tasks import EXTENSION_ID, CreateTaskResult
1918
from stories._harness import Target, run_client
2019

2120

@@ -38,20 +37,23 @@ async def main(target: Target, *, mode: str = "auto") -> None:
3837
return
3938
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}
4039

41-
# The manual leg: the same flow driven by hand on the raw wire.
42-
# allow_claimed=True hands back the typed CreateTaskResult instead of
43-
# polling, and the shared SEP-2663 request wrappers fetch the outcome.
40+
# The manual leg: the same flow driven by hand. allow_claimed=True hands
41+
# back the typed CreateTaskResult instead of polling, and get_task fetches
42+
# one tasks/get snapshot with the outcome inlined.
4443
created = await client.session.call_tool("render_report", {"title": "Q3", "sections": 1}, allow_claimed=True)
4544
assert isinstance(created, CreateTaskResult), created
4645

47-
task = await client.session.send_request(
48-
cast("types.ClientRequest", GetTaskRequest(params=GetTaskRequestParams(task_id=created.task_id))),
49-
GetTaskResult,
50-
)
46+
task = await get_task(client.session, created.task_id)
5147
assert task.status == "completed", task
5248
assert task.result is not None, task
5349
assert task.result["content"][0]["text"].startswith("# Q3"), task
5450

51+
# wait_task polls the same task to its final CallToolResult — from the
52+
# bare persisted id here, the resume-after-reconnect shape.
53+
final = await wait_task(client.session, created.task_id)
54+
assert isinstance(final.content[0], types.TextContent), final
55+
assert final.content[0].text.startswith("# Q3"), final
56+
5557

5658
if __name__ == "__main__":
5759
run_client(main)

src/mcp/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@
5959
from mcp_types import Role as SamplingRole
6060

6161
from .client._input_required import InputRequiredRoundsExceededError
62-
from .client._tasks import TaskCancelledError, TaskFailedError, TaskInputRequiredError, TasksExtension
6362
from .client.client import Client
6463
from .client.session import ClientSession
6564
from .client.session_group import ClientSessionGroup
6665
from .client.stdio import StdioServerParameters, stdio_client
66+
from .client.tasks import TaskCancelledError, TaskError, TaskFailedError, TaskInputRequiredError, TasksExtension
6767
from .server.session import ServerSession
6868
from .server.stdio import stdio_server
6969
from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError
@@ -130,6 +130,7 @@
130130
"StopReason",
131131
"SubscribeRequest",
132132
"TaskCancelledError",
133+
"TaskError",
133134
"TaskFailedError",
134135
"TaskInputRequiredError",
135136
"TasksExtension",

src/mcp/client/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""MCP Client module."""
22

33
from mcp.client._input_required import InputRequiredRoundsExceededError
4-
from mcp.client._tasks import TaskCancelledError, TaskFailedError, TaskInputRequiredError, TasksExtension
54
from mcp.client._transport import Transport
65
from mcp.client.caching import (
76
CacheConfig,
@@ -22,6 +21,7 @@
2221
advertise,
2322
)
2423
from mcp.client.session import ClientSession
24+
from mcp.client.tasks import TaskCancelledError, TaskError, TaskFailedError, TaskInputRequiredError, TasksExtension
2525

2626
__all__ = [
2727
"CacheConfig",
@@ -39,6 +39,7 @@
3939
"ResponseCacheStore",
4040
"ResultClaim",
4141
"TaskCancelledError",
42+
"TaskError",
4243
"TaskFailedError",
4344
"TaskInputRequiredError",
4445
"TasksExtension",

src/mcp/client/_tasks.py

Lines changed: 0 additions & 164 deletions
This file was deleted.

src/mcp/client/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -723,7 +723,8 @@ async def call_tool(
723723
reached `input_required`; the SDK's automatic in-task input
724724
loop is not implemented yet — drive the task manually via
725725
`session.call_tool(..., allow_claimed=True)` and the
726-
`mcp.shared.tasks` wrappers.
726+
`mcp.client.tasks` functions (`get_task`, `update_task`,
727+
`wait_task`). The task errors share the `TaskError` base.
727728
"""
728729

729730
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:

0 commit comments

Comments
 (0)