Skip to content

Commit a56f75c

Browse files
committed
Drive task-augmented tool calls through the client extension API
TasksExtension contributes a ResultClaim for resultType 'task': a declaring Client admits CreateTaskResult on tools/call and resolves it by polling tasks/get (honoring pollIntervalMs with a 1s fallback and the caller's per-request read timeout) until terminal, returning the inlined CallToolResult; failed, cancelled, and input_required tasks surface as typed TaskFailedError, TaskCancelledError, and TaskInputRequiredError. Manual driving stays available via session.call_tool(..., allow_claimed=True) and the mcp.shared.tasks wrappers. The tasks story's modern path is the plain typed call_tool, with a compact manual leg over the shared wrappers.
1 parent 731a1a2 commit a56f75c

7 files changed

Lines changed: 592 additions & 85 deletions

File tree

examples/stories/tasks/README.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
Task-augmented execution (SEP-2663). A client declares the
44
`io.modelcontextprotocol/tasks` extension; the server may then answer a
55
`tools/call` with a `CreateTaskResult` (carrying a task id) instead of the
6-
`CallToolResult`, and the client fetches the result via `tasks/get`.
6+
`CallToolResult`. `Client.call_tool` drives the polling transparently and
7+
surfaces only the final result — the SEP's recommended client shape.
78

89
## Run it
910

1011
```bash
11-
# stdio (default) — stdio negotiates the modern wire too, so the extension is
12-
# carried on both legs: the server defers the call as a task and the client
13-
# reads the result back via tasks/get
12+
# stdio (default) — stdio negotiates the modern wire too, so both legs run the
13+
# full flow: the server defers the call as a task, Client.call_tool polls it
14+
# to completion, and a manual leg shows the raw CreateTaskResult -> tasks/get
15+
# wire flow
1416
uv run python -m stories.tasks.client
1517

1618
# HTTP — the same flow over streamable HTTP
@@ -27,12 +29,19 @@ uv run python -m stories.tasks.client --http
2729
the legacy `params.task` field is ignored. It augments only for a client that
2830
declared the extension on the request, returning a flat `CreateTaskResult`
2931
(`resultType: "task"`).
30-
- `client.py` `Client(target, extensions=[advertise(EXTENSION_ID)])` — declaring the
31-
extension is what lets the server defer; `main` then reads the `CreateTaskResult`
32-
and fetches `tasks/get`, whose completed envelope inlines the original
33-
`CallToolResult`. On a legacy connection the capability cannot be negotiated
34-
and the same `tools/call` degrades to a plain `CallToolResult`, so the story
35-
guards its task leg on the negotiated capability.
32+
- `client.py` `Client(target, extensions=[TasksExtension()])` — the client half
33+
is a `ClientExtension` whose result claim admits the `CreateTaskResult` and
34+
lets the server defer. The transparent path is then just
35+
`await client.call_tool(...)`: the claim's resolver polls `tasks/get`
36+
(honoring `pollIntervalMs`) and returns the final `CallToolResult`; a
37+
`failed` task raises `TaskFailedError`. On a legacy connection the
38+
capability cannot be negotiated, the server must not augment, and the same
39+
call returns the plain `CallToolResult` — the story guards its manual leg
40+
on the negotiated capability.
41+
- 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`.
3645

3746
## Scope
3847

examples/stories/tasks/client.py

Lines changed: 40 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,57 @@
1-
"""Declare the tasks extension, let the server defer a tool call, then fetch the result via tasks/get.
2-
3-
The client declares `io.modelcontextprotocol/tasks` (via `Client(extensions=...)`),
4-
so the server is free to answer `tools/call` with a `CreateTaskResult`. `Client`
5-
exposes only spec verbs, so the augmented call and `tasks/get` drop to
6-
`client.session`; the thin `_send` helper keeps that out of the story below.
1+
"""Declare the tasks extension and let `Client.call_tool` drive the task transparently.
2+
3+
The client declares `io.modelcontextprotocol/tasks` (by constructing
4+
`TasksExtension()` into `Client(extensions=...)`),
5+
so the server is free to answer `tools/call` with a `CreateTaskResult`. SEP-2663
6+
advises clients to keep a fixed public contract and drive the polling internally —
7+
`Client.call_tool` does exactly that, so the modern path is the same typed call a
8+
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`.
711
"""
812

9-
from typing import Any, Literal, cast
13+
from typing import cast
1014

1115
import mcp_types as types
12-
from pydantic import TypeAdapter
1316

14-
from mcp.client import Client, ClientSession, advertise
15-
from mcp.server.tasks import EXTENSION_ID, GetTaskRequestParams
17+
from mcp.client import Client, TasksExtension
18+
from mcp.server.tasks import EXTENSION_ID
19+
from mcp.shared.tasks import CreateTaskResult, GetTaskRequest, GetTaskRequestParams, GetTaskResult
1620
from stories._harness import Target, run_client
1721

18-
_RAW: TypeAdapter[dict[str, Any]] = TypeAdapter(dict)
19-
20-
21-
class _GetTaskRequest(types.Request[GetTaskRequestParams, Literal["tasks/get"]]):
22-
method: Literal["tasks/get"] = "tasks/get"
23-
params: GetTaskRequestParams
24-
25-
26-
async def _send(session: ClientSession, request: types.Request[Any, Any]) -> dict[str, Any]:
27-
"""Send a request whose result has a non-spec (extension) shape; return the raw dict."""
28-
return await session.send_request(cast("types.ClientRequest", request), cast("Any", _RAW))
29-
3022

3123
async def main(target: Target, *, mode: str = "auto") -> None:
32-
async with Client(target, mode=mode, extensions=[advertise(EXTENSION_ID)]) as client:
33-
# The extension is a modern-only capability negotiated over server/discover.
34-
# A legacy connection cannot carry it, and the server then must not
35-
# augment: the same tools/call degrades to a plain CallToolResult.
24+
async with Client(target, mode=mode, extensions=[TasksExtension()]) as client:
25+
# The transparent path. On the modern wire the server augments this
26+
# tools/call into a task (we declared the extension) and Client.call_tool
27+
# polls tasks/get to the final result; on a legacy connection the
28+
# extension cannot be negotiated, the server must not augment, and the
29+
# very same call simply returns the plain CallToolResult.
30+
result = await client.call_tool("render_report", {"title": "Q3", "sections": 2})
31+
assert isinstance(result.content[0], types.TextContent), result
32+
assert result.content[0].text.startswith("# Q3"), result
33+
# No 2025-style related-task _meta either; the task plumbing never leaks
34+
# into the surfaced result.
35+
assert result.meta is None, result
36+
3637
if client.server_capabilities.extensions is None:
37-
result = await client.call_tool("render_report", {"title": "Q3", "sections": 2})
38-
assert isinstance(result.content[0], types.TextContent), result
39-
assert result.content[0].text.startswith("# Q3"), result
40-
# No 2025-style related-task _meta either; SEP-2663 augmentation would
41-
# have replaced the whole result, failing CallToolResult parsing above.
42-
assert result.meta is None, result
38+
# Legacy wire: nothing more to show — the degradation above is the point.
4339
return
4440
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}
4541

46-
# The server augments this tools/call into a task because we declared the extension.
47-
call = types.CallToolRequest(
48-
params=types.CallToolRequestParams(name="render_report", arguments={"title": "Q3", "sections": 2})
49-
)
50-
created = await _send(client.session, call)
51-
assert created["resultType"] == "task", created
52-
task_id = created["taskId"]
42+
# The manual leg: the same flow driven by hand on the raw wire.
43+
# allow_claimed=True hands back the typed CreateTaskResult instead of
44+
# polling, and the shared SEP-2663 request wrappers fetch the outcome.
45+
created = await client.session.call_tool("render_report", {"title": "Q3", "sections": 1}, allow_claimed=True)
46+
assert isinstance(created, CreateTaskResult), created
5347

54-
task = await _send(client.session, _GetTaskRequest(params=GetTaskRequestParams(task_id=task_id)))
55-
assert task["status"] == "completed", task
56-
assert task["result"]["content"][0]["text"].startswith("# Q3"), task
48+
task = await client.session.send_request(
49+
cast("types.ClientRequest", GetTaskRequest(params=GetTaskRequestParams(task_id=created.task_id))),
50+
GetTaskResult,
51+
)
52+
assert task.status == "completed", task
53+
assert task.result is not None, task
54+
assert task.result["content"][0]["text"].startswith("# Q3"), task
5755

5856

5957
if __name__ == "__main__":

src/mcp/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
# ...` did, so `import mcp` followed by `mcp.types.Tool` keeps working.
6363
from . import types as types
6464
from .client._input_required import InputRequiredRoundsExceededError
65+
from .client._tasks import TaskCancelledError, TaskFailedError, TaskInputRequiredError, TasksExtension
6566
from .client.client import Client
6667
from .client.session import ClientSession
6768
from .client.session_group import ClientSessionGroup
@@ -131,6 +132,10 @@
131132
"StdioServerParameters",
132133
"StopReason",
133134
"SubscribeRequest",
135+
"TaskCancelledError",
136+
"TaskFailedError",
137+
"TaskInputRequiredError",
138+
"TasksExtension",
134139
"Tool",
135140
"ToolChoice",
136141
"ToolResultContent",

src/mcp/client/__init__.py

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

33
from mcp.client._input_required import InputRequiredRoundsExceededError
4+
from mcp.client._tasks import TaskCancelledError, TaskFailedError, TaskInputRequiredError, TasksExtension
45
from mcp.client._transport import Transport
56
from mcp.client.caching import (
67
CacheConfig,
@@ -38,6 +39,10 @@
3839
"NotificationBinding",
3940
"ResponseCacheStore",
4041
"ResultClaim",
42+
"TaskCancelledError",
43+
"TaskFailedError",
44+
"TaskInputRequiredError",
45+
"TasksExtension",
4146
"Transport",
4247
"UnexpectedClaimedResult",
4348
"advertise",

src/mcp/client/_tasks.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""SEP-2663 client-side task polling driver.
2+
3+
When a server augments a `tools/call` into a task — a `CreateTaskResult` in
4+
place of the `CallToolResult` — the client polls `tasks/get` until the task
5+
reaches a terminal status and surfaces only the final result. SEP-2663 advises
6+
exactly this shape: "existing code returning a fixed shape ... can transparently
7+
drive the polling flow internally and surface only the final, completed result".
8+
This module implements that loop as a pure function so it stays testable with
9+
plain closures; `Client` builds the `get_task` closure over its session,
10+
`ClientSession` stays mechanics-only (mirroring `_input_required`).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from collections.abc import Awaitable, Callable, Sequence
16+
from typing import Any, cast
17+
18+
import anyio
19+
from mcp_types import CallToolResult, ClientRequest, ErrorData
20+
21+
from mcp.client.extension import ClaimContext, ClientExtension, ResultClaim
22+
from mcp.shared.exceptions import MCPError
23+
from mcp.shared.tasks import (
24+
EXTENSION_ID,
25+
CreateTaskResult,
26+
GetTaskRequest,
27+
GetTaskRequestParams,
28+
GetTaskResult,
29+
)
30+
31+
DEFAULT_POLL_INTERVAL_SECONDS = 1.0
32+
"""Poll cadence when neither the snapshot nor the `CreateTaskResult` carries `pollIntervalMs`.
33+
34+
SEP-2663 makes the hint optional and only says clients SHOULD honor it when
35+
present; one second is the SDK's conservative default in its absence.
36+
"""
37+
38+
39+
class TaskFailedError(MCPError):
40+
"""The task reached `failed`: a JSON-RPC error occurred during execution (SEP-2663).
41+
42+
Carries the JSON-RPC error inlined on `tasks/get` as `code`/`message`/`data`,
43+
plus the snapshot's optional `statusMessage` diagnostic.
44+
"""
45+
46+
def __init__(self, error: ErrorData, status_message: str | None = None) -> None:
47+
super().__init__(code=error.code, message=error.message, data=error.data)
48+
self.status_message = status_message
49+
50+
51+
class TaskCancelledError(RuntimeError):
52+
"""The task reached `cancelled` before producing a result (SEP-2663)."""
53+
54+
def __init__(self, task_id: str, status_message: str | None = None) -> None:
55+
detail = f": {status_message}" if status_message is not None else ""
56+
super().__init__(f"Task {task_id!r} was cancelled{detail}")
57+
self.task_id = task_id
58+
self.status_message = status_message
59+
60+
61+
class TaskInputRequiredError(RuntimeError):
62+
"""The task reached `input_required`, which this driver does not drive yet.
63+
64+
SEP-2663's in-task input loop (fulfil `inputRequests` via `tasks/update`) is
65+
a deferred follow-up in this SDK. Drive it manually: poll with
66+
`mcp.shared.tasks.GetTaskRequest` and answer with
67+
`mcp.shared.tasks.UpdateTaskRequest` over `session.send_request`.
68+
"""
69+
70+
def __init__(self, task_id: str) -> None:
71+
super().__init__(
72+
f"Task {task_id!r} requires in-task input (status `input_required`); the SDK's automatic "
73+
"in-task input loop is not implemented yet. Drive it manually with the `mcp.shared.tasks` "
74+
"request wrappers (`GetTaskRequest`/`UpdateTaskRequest`) over `session.send_request`."
75+
)
76+
self.task_id = task_id
77+
78+
79+
async def run_task_driver(
80+
created: CreateTaskResult,
81+
*,
82+
get_task: Callable[[str], Awaitable[GetTaskResult]],
83+
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
84+
) -> CallToolResult:
85+
"""Poll a `CreateTaskResult` to its final `CallToolResult`.
86+
87+
Polls `tasks/get` (via `get_task`) until the task reaches a terminal status.
88+
Between polls it honors the SEP-2663 `pollIntervalMs` hint: each non-terminal
89+
snapshot sleeps its own `poll_interval_ms`, falling back to the
90+
`CreateTaskResult`'s, then to `DEFAULT_POLL_INTERVAL_SECONDS`.
91+
92+
The loop deliberately imposes no round cap or deadline of its own: SEP-2663
93+
tasks represent unbounded server-side work, so how long to wait is the
94+
caller's policy — cancel via an enclosing anyio cancel scope, or bound each
95+
`tasks/get` round with the session read timeout the `get_task` closure
96+
carries.
97+
98+
Args:
99+
created: The `CreateTaskResult` the augmented request returned.
100+
get_task: Sends one `tasks/get` for the given task id and returns the
101+
parsed `GetTaskResult` snapshot.
102+
sleep: Awaits the given number of seconds between polls (injectable for
103+
deterministic tests).
104+
105+
Raises:
106+
TaskFailedError: The task reached `failed`; carries the inlined JSON-RPC error.
107+
TaskCancelledError: The task reached `cancelled`.
108+
TaskInputRequiredError: The task reached `input_required` (the in-task
109+
input loop is not implemented yet).
110+
RuntimeError: The server violated SEP-2663 — a `completed` snapshot
111+
without `result`, or a `failed` snapshot without `error`.
112+
"""
113+
while True:
114+
snapshot = await get_task(created.task_id)
115+
if snapshot.status == "completed":
116+
if snapshot.result is None:
117+
raise RuntimeError(
118+
f"Task {created.task_id!r} is `completed` but carries no `result` (SEP-2663 violation)"
119+
)
120+
return CallToolResult.model_validate(snapshot.result, by_name=False)
121+
if snapshot.status == "failed":
122+
if snapshot.error is None:
123+
raise RuntimeError(f"Task {created.task_id!r} is `failed` but carries no `error` (SEP-2663 violation)")
124+
raise TaskFailedError(ErrorData.model_validate(snapshot.error), snapshot.status_message)
125+
if snapshot.status == "cancelled":
126+
raise TaskCancelledError(created.task_id, snapshot.status_message)
127+
if snapshot.status == "input_required":
128+
raise TaskInputRequiredError(created.task_id)
129+
interval_ms = snapshot.poll_interval_ms if snapshot.poll_interval_ms is not None else created.poll_interval_ms
130+
await sleep(DEFAULT_POLL_INTERVAL_SECONDS if interval_ms is None else interval_ms / 1000)
131+
132+
133+
class TasksExtension(ClientExtension):
134+
"""SEP-2663 Tasks as a client extension.
135+
136+
Declares `io.modelcontextprotocol/tasks` and claims the `task` resultType on
137+
`tools/call`: a `CreateTaskResult` is resolved by polling `tasks/get` to the
138+
final `CallToolResult` via `run_task_driver`.
139+
"""
140+
141+
identifier = EXTENSION_ID
142+
143+
def claims(self) -> Sequence[ResultClaim[Any]]:
144+
return (ResultClaim(result_type="task", model=CreateTaskResult, resolve=_resolve_created_task),)
145+
146+
147+
async def _resolve_created_task(created: CreateTaskResult, ctx: ClaimContext) -> CallToolResult:
148+
"""Poll an SEP-2663 task to its final `CallToolResult` (the transparent flow).
149+
150+
Each `tasks/get` round goes through `ctx.session.send_request`, so it carries
151+
the caller's per-request read timeout (falling back to the session read
152+
timeout) and the `Mcp-Name` routing header. `Client.call_tool` re-validates
153+
the returned result against the tool's output schema, exactly as on the
154+
direct path.
155+
"""
156+
session = ctx.session
157+
158+
async def get_task(task_id: str) -> GetTaskResult:
159+
request = GetTaskRequest(params=GetTaskRequestParams(task_id=task_id))
160+
return await session.send_request(
161+
cast("ClientRequest", request), GetTaskResult, request_read_timeout_seconds=ctx.read_timeout_seconds
162+
)
163+
164+
return await run_task_driver(created, get_task=get_task)

0 commit comments

Comments
 (0)