|
1 | | -"""Declare the tasks extension, let the server defer a tool call, then fetch the result via tasks/get. |
| 1 | +"""Declare the tasks extension and let `Client.call_tool` drive the task transparently. |
2 | 2 |
|
3 | 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. |
| 4 | +so the server is free to answer `tools/call` with a `CreateTaskResult`. SEP-2663 |
| 5 | +advises clients to keep a fixed public contract and drive the polling internally — |
| 6 | +`Client.call_tool` does exactly that, so the modern path is the same typed call a |
| 7 | +task-less server would get. A compact manual leg then shows the raw wire flow: |
| 8 | +`session.call_tool(allow_create_task=True)` for the typed `CreateTaskResult`, and |
| 9 | +the shared `mcp.shared.tasks` wrappers over `session.send_request` for `tasks/get`. |
7 | 10 | """ |
8 | 11 |
|
9 | | -from typing import Any, Literal, cast |
| 12 | +from typing import cast |
10 | 13 |
|
11 | 14 | import mcp_types as types |
12 | | -from pydantic import TypeAdapter |
13 | 15 |
|
14 | | -from mcp.client import Client, ClientSession |
15 | | -from mcp.server.tasks import EXTENSION_ID, GetTaskRequestParams |
| 16 | +from mcp.client import Client |
| 17 | +from mcp.server.tasks import EXTENSION_ID |
| 18 | +from mcp.shared.tasks import CreateTaskResult, GetTaskRequest, GetTaskRequestParams, GetTaskResult |
16 | 19 | from stories._harness import Target, run_client |
17 | 20 |
|
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 | | - |
30 | 21 |
|
31 | 22 | async def main(target: Target, *, mode: str = "auto") -> None: |
32 | 23 | async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client: |
33 | | - # The extension is a modern-only capability negotiated over server/discover. |
34 | | - # A legacy connection (today's stdio) cannot carry it, and the server then |
35 | | - # must not augment: the same tools/call degrades to a plain CallToolResult. |
| 24 | + # The transparent path. On the modern wire the server augments this |
| 25 | + # tools/call into a task (we declared the extension) and Client.call_tool |
| 26 | + # polls tasks/get to the final result; on a legacy connection (today's |
| 27 | + # stdio) the extension cannot be negotiated, the server must not augment, |
| 28 | + # and the very same call simply returns the plain CallToolResult. |
| 29 | + result = await client.call_tool("render_report", {"title": "Q3", "sections": 2}) |
| 30 | + assert isinstance(result.content[0], types.TextContent), result |
| 31 | + assert result.content[0].text.startswith("# Q3"), result |
| 32 | + # No 2025-style related-task _meta either; the task plumbing never leaks |
| 33 | + # into the surfaced result. |
| 34 | + assert result.meta is None, result |
| 35 | + |
36 | 36 | 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 |
| 37 | + # Legacy wire: nothing more to show — the degradation above is the point. |
43 | 38 | return |
44 | 39 | assert client.server_capabilities.extensions == {EXTENSION_ID: {}} |
45 | 40 |
|
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}) |
| 41 | + # The manual leg: the same flow driven by hand on the raw wire. |
| 42 | + # allow_create_task=True hands back the typed CreateTaskResult instead of |
| 43 | + # polling, and the shared SEP-2663 request wrappers fetch the outcome. |
| 44 | + created = await client.session.call_tool( |
| 45 | + "render_report", {"title": "Q3", "sections": 1}, allow_create_task=True |
49 | 46 | ) |
50 | | - created = await _send(client.session, call) |
51 | | - assert created["resultType"] == "task", created |
52 | | - task_id = created["taskId"] |
| 47 | + assert isinstance(created, CreateTaskResult), created |
53 | 48 |
|
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 |
| 49 | + task = await client.session.send_request( |
| 50 | + cast("types.ClientRequest", GetTaskRequest(params=GetTaskRequestParams(task_id=created.task_id))), |
| 51 | + GetTaskResult, |
| 52 | + ) |
| 53 | + assert task.status == "completed", task |
| 54 | + assert task.result is not None, task |
| 55 | + assert task.result["content"][0]["text"].startswith("# Q3"), task |
57 | 56 |
|
58 | 57 |
|
59 | 58 | if __name__ == "__main__": |
|
0 commit comments