Skip to content

Commit c755729

Browse files
committed
Adapt the Tasks extension to main after rebasing onto the released 2026-07-28 line
The 15-commit branch rebased onto latest main with four textual conflicts (extension.py + mcpserver/server.py, the everything-server, client/__init__.py, whats-new.md), all resolved to main's structure plus the branch's additions. This commit carries the semantic adaptations that the merge could not surface: - Gate task augmentation on the declared-capabilities fact, not clientInfo. Main made clientInfo optional on the 2026-07-28 wire (spec #3002) and split `session.client_capabilities` from `session.client_params`, so a conformant client can declare the extension while `client_params` is None. The interceptor's `_client_declared_tasks` now reads `client_capabilities`, matching main's rewritten `require_client_extension` (whose main body the relocated function carries). A pair-only declaring envelope is augmented. - Interceptors now run at the handler layer (`compose_tool_call_handler`), so `call_next` returns the wrapped handler's domain result rather than a serialized dict, and every modern-era result envelope carries the `serverInfo` `_meta` identity stamp. The stored/inlined tool result stays the un-enveloped payload. Reword `_wire_payload` and the error-fold comment accordingly, and have the tests assert-and-strip the envelope stamp through strict helpers mirroring tests/_stamp.py instead of hard-coding it into every snapshot. A short-circuited `input_required` interim is now sieved to the core shape like any handler result, so its fixture is core-shaped. - Declare `name_param = "taskId"` on GetTaskRequest/CancelTaskRequest/ UpdateTaskRequest: main added `Request.name_param` as the per-request key a client mirrors into the `Mcp-Name` header, built expressly for the tasks verbs (its placeholder test models `tasks/get`). The server-side NAME_BEARING_METHODS rows remain the source of the spec-mandated validation. - Follow main's spelling and toolchain moves: httpx -> httpx2 in the tasks server tests, `mcp.types` (not `mcp_types`) in docs/example imports with the matching `hl_lines` shift, and the GitHub form for SEP-2663 links. - Refresh prose that the merge left stale: the migration guide's "Detached work" note now points at the extension for the fetch-later half, and the tasks story's docstring states the fixed-contract invariant rather than claiming identity with a task-less server's stamped result.
1 parent c3a5157 commit c755729

10 files changed

Lines changed: 113 additions & 44 deletions

File tree

docs/advanced/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
A **task** is a `tools/call` answered by reference: instead of the `CallToolResult`,
44
the server returns a `CreateTaskResult` carrying a task id, and the client fetches
55
the outcome with `tasks/get`. That is
6-
[SEP-2663](https://modelcontextprotocol.io/seps/2663-tasks-extension.md), and the SDK
6+
[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), and the SDK
77
ships it as the built-in `Tasks` extension (`io.modelcontextprotocol/tasks`).
88
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
99
then come back.
@@ -44,7 +44,7 @@ Augmentation is the server's call, per request: the client's declaration is
4444
permission, not a trigger. `Tasks()` augments every call from a declaring client;
4545
pass `augment=` to be choosier:
4646

47-
```python title="server.py" hl_lines="9-10 13"
47+
```python title="server.py" hl_lines="8-9 12"
4848
--8<-- "docs_src/tasks/tutorial002.py"
4949
```
5050

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,7 +2010,7 @@ async def elicitation_callback(
20102010

20112011
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, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`.
20122012

2013-
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 [Extensions](advanced/extensions.md) and [Tasks](advanced/tasks.md) for the new design.
2013+
Tasks have since returned as the built-in `Tasks` extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), with a different wire shape than the experimental SEP-1686 surface. See [Extensions](advanced/extensions.md) and [Tasks](advanced/tasks.md) for the new design.
20142014

20152015
There is no drop-in replacement for the tasks runtime (`server.experimental.enable_tasks()`, `ctx.experimental.run_task()`, `ServerTaskContext`, and the client's `session.experimental.call_tool_as_task()` / `poll_task()` / `get_task_result()`); the port depends on what the code used tasks for.
20162016

@@ -2052,7 +2052,7 @@ result = await client.call_tool("long_running_task", {}, progress_callback=on_pr
20522052

20532053
**Gathering user input mid-work** (`task.elicit()`, `task.create_message()`). Don't port these to inline `ctx.elicit()` / `ctx.session.create_message()` calls: those are server-initiated requests, refused with `NoBackChannelError` on 2026-07-28 connections (the default for an in-process `Client(server)`). Use the resolver dependencies (`Elicit`, `Sample`) or return an `InputRequiredResult` — both work on every protocol version, and `Client.call_tool()` retries the `InputRequiredResult` rounds automatically; see [Multi-round-trip requests](handlers/multi-round-trip.md) and [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror). The client's existing `elicitation_callback` / `sampling_callback` serve both eras.
20542054

2055-
**Detached work** (create the task now, fetch its result on a later connection or after a client restart) has no v2 equivalent until the SEP-2663 extension is implemented.
2055+
**Detached work** (create the task now, fetch its result on a later connection or after a client restart): the built-in [Tasks](advanced/tasks.md) extension covers the fetch-later half for clients that declare `io.modelcontextprotocol/tasks` on a 2026-07-28 connection — an augmented `tools/call` returns a `CreateTaskResult`, and a later declaring connection holding the task id polls `tasks/get` (or calls `wait_task(session, task_id)` from `mcp.client.tasks`) to collect the outcome, which is retained per the task's `ttlMs` (with no TTL configured, for the store's lifetime). Two caveats: the default store is in-memory and per-process, so surviving a *server* restart or spanning workers needs a custom `Tasks(store=...)`; and the tool still runs inline inside the original `tools/call`, so there is no create-now, compute-in-the-background equivalent of the v1 task runtime yet.
20562056

20572057
Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool definitions: the `TASK_REQUIRED` / `TASK_OPTIONAL` / `TASK_FORBIDDEN` constants are gone from `mcp.types` (`ToolExecution.task_support` takes the plain `"required"` / `"optional"` / `"forbidden"` literal), and no v2 client or server reads the field.
20582058

docs_src/tasks/tutorial002.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
from mcp_types import CallToolRequestParams
2-
31
from mcp.server.mcpserver import MCPServer
42
from mcp.server.tasks import Tasks
3+
from mcp.types import CallToolRequestParams
54

65
SLOW_TOOLS = {"transcode"}
76

examples/stories/tasks/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ table).
5959

6060
## Spec
6161

62-
[SEP-2663 — Tasks extension](https://modelcontextprotocol.io/seps/2663-tasks-extension.md)
62+
[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)
6363
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
6464

6565
## See also

examples/stories/tasks/client.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
`TasksExtension()` into `Client(extensions=...)`),
55
so the server is free to answer `tools/call` with a `CreateTaskResult`. SEP-2663
66
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 the
10-
typed `mcp.client.tasks` functions (`get_task`, `wait_task`) to drive `tasks/get`.
7+
`Client.call_tool` does exactly that, so the caller keeps one fixed
8+
`CallToolResult` contract whether or not the server augments. A compact manual
9+
leg then shows the raw wire flow: `session.call_tool(allow_claimed=True)` for the
10+
typed `CreateTaskResult`, and the typed `mcp.client.tasks` functions (`get_task`,
11+
`wait_task`) to drive `tasks/get`.
1112
"""
1213

13-
import mcp_types as types
14-
14+
import mcp.types as types
1515
from mcp.client import Client, TasksExtension
1616
from mcp.client.tasks import get_task, wait_task
1717
from mcp.shared.tasks import EXTENSION_ID, CreateTaskResult
@@ -28,8 +28,10 @@ async def main(target: Target, *, mode: str = "auto") -> None:
2828
result = await client.call_tool("render_report", {"title": "Q3", "sections": 2})
2929
assert isinstance(result.content[0], types.TextContent), result
3030
assert result.content[0].text.startswith("# Q3"), result
31-
# No 2025-style related-task _meta either; the task plumbing never leaks
32-
# into the surfaced result.
31+
# The surfaced value is the tool's own CallToolResult - the payload the
32+
# task recorded, or on the legacy wire the plain result. No task plumbing
33+
# leaks in: no 2025-style related-task _meta, and no envelope-level identity
34+
# stamp either, since it is the tool's result and not a response envelope.
3335
assert result.meta is None, result
3436

3537
if client.server_capabilities.extensions is None:

src/mcp/server/mcpserver/server.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1312,4 +1312,3 @@ async def gated(ctx: ServerRequestContext[Any, Any], params: Any) -> HandlerResu
13121312
return await method.handler(ctx, params)
13131313

13141314
return gated
1315-

src/mcp/server/tasks.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
`CallToolResult`, the server immediately returns a `CreateTaskResult` carrying a
55
task id, and the client polls `tasks/get` for status and the eventual result.
66
7-
SEP-2663 (https://modelcontextprotocol.io/seps/2663-tasks-extension.md) is an
7+
SEP-2663 (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663) is an
88
opt-in extension, wire-incompatible with the 2025-11-25 in-core Tasks design that
99
still ships (types-only) in `mcp_types`. The SEP-2663-shaped wire models live in
1010
`mcp.shared.tasks` (re-exported here); this module is the server runtime.
@@ -50,7 +50,9 @@
5050
`notifications/tasks` over `subscriptions/listen` are deferred follow-ups, each
5151
needing deeper SDK plumbing. (SEP-2663's `Mcp-Name: <taskId>` routing header --
5252
the SEP-2243 header family -- is already handled by the shared header table in
53-
`mcp.shared.inbound`.)
53+
`mcp.shared.inbound`; the `tasks/*` request types in `mcp.shared.tasks` also
54+
declare `name_param`, the per-request params key a client mirrors into that
55+
header.)
5456
5557
Task ids are unguessable bearer capabilities: any caller presenting a valid id
5658
may poll the task. That is deliberate -- the modern wire has no sessions, and a
@@ -245,8 +247,10 @@ async def intercept_tool_call(
245247
try:
246248
result = await call_next(ctx)
247249
if isinstance(result, ErrorData):
248-
# A nested extension returned the error instead of raising (the
249-
# runner's middleware error channel); fold it into the same arm.
250+
# A handler or nested extension returned the error instead of
251+
# raising (the runner folds handler-returned `ErrorData` into an
252+
# `MCPError` only after this composed handler returns), so fold
253+
# it into the same arm here.
250254
raise MCPError.from_error_data(result)
251255
except MCPError as exc:
252256
# SEP-2663: a JSON-RPC error during execution is a `failed` task,
@@ -339,11 +343,12 @@ async def _require(self, task_id: str) -> TaskRecord:
339343

340344

341345
def _wire_payload(result: HandlerResult) -> dict[str, Any]:
342-
"""Normalize a `call_next` outcome to the wire dict the chain would emit.
346+
"""Normalize a `call_next` outcome to the wire dict it will be serialized as.
343347
344-
The stock composition hands the interceptor the already-serialized dict; an
345-
extension or middleware nested inside this one may short-circuit with a
346-
model (dumped the way the runner would) or `None` (an empty result).
348+
The stock composition hands the interceptor the wrapped handler's domain
349+
result (a `CallToolResult`/`InputRequiredResult` model); an extension nested
350+
inside this one may instead short-circuit with a wire-shaped dict or `None`
351+
(an empty result). Models are dumped the way the runner would.
347352
"""
348353
if isinstance(result, BaseModel):
349354
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
@@ -357,6 +362,9 @@ def _client_declared_tasks(ctx: ServerRequestContext[Any, Any]) -> bool:
357362
# capabilities happen to include the identifier.
358363
if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS:
359364
return False
360-
client_params = ctx.session.client_params
361-
declared = client_params.capabilities.extensions if client_params else None
365+
# Read the declared-capabilities fact, not `client_params`: on 2026-07-28 the
366+
# request envelope declares capabilities while `clientInfo` stays optional,
367+
# so a conformant client can be declaring with no `client_params` at all.
368+
capabilities = ctx.session.client_capabilities
369+
declared = capabilities.extensions if capabilities else None
362370
return bool(declared and EXTENSION_ID in declared)

src/mcp/shared/tasks.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Wire models for the Tasks extension (`io.modelcontextprotocol/tasks`, SEP-2663).
22
3-
SEP-2663 (https://modelcontextprotocol.io/seps/2663-tasks-extension.md) is an
3+
SEP-2663 (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663) is an
44
opt-in extension, wire-incompatible with the 2025-11-25 in-core Tasks design that
55
still ships (types-only) in `mcp_types`. This module therefore defines its own
66
SEP-2663-shaped models rather than reusing `mcp_types.{Task, CreateTaskResult, ...}`.
@@ -116,20 +116,23 @@ class GetTaskRequest(Request[GetTaskRequestParams, Literal["tasks/get"]]):
116116

117117
method: Literal["tasks/get"] = "tasks/get"
118118
params: GetTaskRequestParams
119+
name_param = "taskId"
119120

120121

121122
class CancelTaskRequest(Request[CancelTaskRequestParams, Literal["tasks/cancel"]]):
122123
"""SEP-2663 `tasks/cancel` request, typed for client-side `send_request`."""
123124

124125
method: Literal["tasks/cancel"] = "tasks/cancel"
125126
params: CancelTaskRequestParams
127+
name_param = "taskId"
126128

127129

128130
class UpdateTaskRequest(Request[UpdateTaskRequestParams, Literal["tasks/update"]]):
129131
"""SEP-2663 `tasks/update` request, typed for client-side `send_request`."""
130132

131133
method: Literal["tasks/update"] = "tasks/update"
132134
params: UpdateTaskRequestParams
135+
name_param = "taskId"
133136

134137

135138
class GetTaskResult(Task):

tests/docs_src/test_tasks.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from mcp.server.mcpserver import MCPServer
1111
from mcp.server.tasks import EXTENSION_ID, CreateTaskResult, Tasks
1212
from mcp.shared.tasks import GetTaskResult
13+
from tests._stamp import unstamped
1314

1415
# See test_index.py for why this is a per-module mark and not a conftest hook.
1516
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -48,9 +49,12 @@ async def test_the_bakery_client_program_runs_as_shown(capsys: pytest.CaptureFix
4849

4950
async def test_a_non_declaring_client_is_never_augmented() -> None:
5051
"""tutorial001 + the degradation paragraph: a modern client that did not declare
51-
the extension gets the plain `CallToolResult`, always."""
52+
the extension gets the plain `CallToolResult`, always -- no task metadata. The
53+
only `_meta` present is the era's `serverInfo` identity stamp, which `unstamped`
54+
asserts and pops, so `meta is None` proves nothing else rode along."""
5255
async with Client(tutorial001.mcp) as client:
5356
result = await client.call_tool("bake", {"flavor": "plain"})
57+
result = unstamped(result)
5458
assert result.content == [TextContent(type="text", text="One plain cake, ready.")]
5559
assert result.meta is None
5660

0 commit comments

Comments
 (0)