Skip to content

Commit 775463e

Browse files
committed
Polish the tasks surface from final review
- The polling driver floors a server-supplied negative pollIntervalMs to zero instead of crashing (trio) or busy-looping (asyncio); tested. - Client.call_tool documents TaskInputRequiredError alongside the other task errors, and the docs and story prose catch up: routing headers are no longer listed as deferred, failed tasks are named alongside completed ones in the store wording, and the migration guide's old removal note now points at the landed extension. - The tasks story imports EXTENSION_ID from mcp.shared.tasks.
1 parent 99337f5 commit 775463e

7 files changed

Lines changed: 36 additions & 11 deletions

File tree

docs/advanced/tasks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ then come back.
2424
`pollIntervalMs` hint, one second between polls in its absence — and surfaces
2525
only the final `CallToolResult`. A `failed` task raises the typed
2626
`TaskFailedError` carrying the inlined JSON-RPC error; a `cancelled` one raises
27-
`TaskCancelledError`.
27+
`TaskCancelledError`; an `input_required` one raises `TaskInputRequiredError`
28+
the automatic in-task input loop is not implemented yet, so drive that task
29+
manually (below).
2830

2931
Degradation is built in. A modern client that does not declare the extension is
3032
never augmented: it keeps getting plain `CallToolResult`s. And a legacy

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ Two reference extensions ship in their own modules:
467467
`tasks/get` (`tasks/update` and `tasks/cancel` are empty acknowledgements).
468468
The server decides augmentation (the legacy `params.task` field is ignored),
469469
passes multi round-trip `input_required` interims through un-augmented, and
470-
keeps completed tasks in a pluggable `TaskStore` (`Tasks(store=...)`,
470+
keeps finished (completed or failed) tasks in a pluggable `TaskStore` (`Tasks(store=...)`,
471471
in-memory default) that enforces `default_ttl_ms`. A `tasks/*` call from a
472472
non-declaring modern client is rejected with `-32021` (missing required
473473
client capability); legacy calls get `METHOD_NOT_FOUND`. On the client side,
@@ -1497,7 +1497,7 @@ Behavior changes:
14971497

14981498
Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions.
14991499

1500-
Tasks are expected to return as a separate MCP extension in a future release.
1500+
Tasks have since returned as the built-in `Tasks` extension ([SEP-2663](https://modelcontextprotocol.io/seps/2663-tasks-extension.md)), with a different wire shape than the experimental SEP-1686 surface — see [Server extensions API](#server-extensions-api-sep-2133) above and [Tasks](advanced/tasks.md).
15011501

15021502
## Deprecations
15031503

examples/stories/tasks/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,12 @@ uv run python -m stories.tasks.client --http
4444

4545
This is the core SEP-2663 surface. The tool runs to completion inline, so a task
4646
is recorded directly as `completed` (the SEP allows any initial status), and
47-
completed tasks live in a pluggable `TaskStore` (`Tasks(store=...)`, in-memory
48-
default) that enforces `default_ttl_ms`. Deferred to follow-ups, each needing
49-
deeper SDK plumbing: background execution (returning `working` tasks), the
50-
in-task `input_required`/`inputResponses` loop over `tasks/update`,
51-
`notifications/tasks`, and SEP-2243 task routing headers.
47+
finished (completed or failed) tasks live in a pluggable `TaskStore`
48+
(`Tasks(store=...)`, in-memory default) that enforces `default_ttl_ms`. Deferred
49+
to follow-ups, each needing deeper SDK plumbing: background execution (returning
50+
`working` tasks), the in-task `input_required`/`inputResponses` loop over
51+
`tasks/update`, and `notifications/tasks` (the SEP-2243 `Mcp-Name` routing
52+
header is already handled by the shared header table).
5253

5354
## Spec
5455

examples/stories/tasks/client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
import mcp_types as types
1515

1616
from mcp.client import Client
17-
from mcp.server.tasks import EXTENSION_ID
18-
from mcp.shared.tasks import CreateTaskResult, GetTaskRequest, GetTaskRequestParams, GetTaskResult
17+
from mcp.shared.tasks import EXTENSION_ID, CreateTaskResult, GetTaskRequest, GetTaskRequestParams, GetTaskResult
1918
from stories._harness import Target, run_client
2019

2120

src/mcp/client/_tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,4 @@ async def run_task_driver(
119119
if snapshot.status == "input_required":
120120
raise TaskInputRequiredError(created.task_id)
121121
interval_ms = snapshot.poll_interval_ms if snapshot.poll_interval_ms is not None else created.poll_interval_ms
122-
await sleep(DEFAULT_POLL_INTERVAL_SECONDS if interval_ms is None else interval_ms / 1000)
122+
await sleep(DEFAULT_POLL_INTERVAL_SECONDS if interval_ms is None else max(0, interval_ms) / 1000)

src/mcp/client/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,11 @@ async def call_tool(
640640
(a JSON-RPC error during execution).
641641
TaskCancelledError: The call was augmented into a task that was
642642
cancelled before completing.
643+
TaskInputRequiredError: The call was augmented into a task that
644+
reached `input_required`; the SDK's automatic in-task input
645+
loop is not implemented yet — drive the task manually via
646+
`session.call_tool(..., allow_create_task=True)` and the
647+
`mcp.shared.tasks` wrappers.
643648
"""
644649

645650
async def retry(

tests/client/test_tasks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,24 @@ async def test_no_interval_anywhere_falls_back_to_one_second() -> None:
149149
assert slept == [DEFAULT_POLL_INTERVAL_SECONDS] == [1.0]
150150

151151

152+
async def test_negative_poll_interval_is_floored_to_zero() -> None:
153+
"""SDK-defined: a misbehaving server's negative interval must not crash or
154+
busy-loop the driver — it is floored to a zero-length sleep."""
155+
get_task, _ = _scripted_get_task(
156+
[
157+
_snapshot("working", poll_interval_ms=-5000),
158+
_snapshot("completed", result=dict(_COMPLETED_RESULT)),
159+
]
160+
)
161+
sleep, slept = _recording_sleep()
162+
163+
with anyio.fail_after(5):
164+
result = await run_task_driver(_created(), get_task=get_task, sleep=sleep)
165+
166+
assert result.content == [TextContent(text="done")]
167+
assert slept == [0.0]
168+
169+
152170
async def test_failed_snapshot_raises_task_failed_error_with_code_and_status_message() -> None:
153171
"""SEP-2663: a `failed` task inlines the JSON-RPC error and SHOULD carry a
154172
`statusMessage` diagnostic — both surface on the typed error."""

0 commit comments

Comments
 (0)